chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -0,0 +1,134 @@
import { ArrowLeft, BadgeCheck, CircleAlert, Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const [months, setMonths] = useState("1");
async function load() {
setBusy(true);
setError("");
try {
setDetail(await api.account(id));
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? "加载账号详情" : "等待数据"} />;
}
const account = detail.Account;
return (
<PageFrame
title={`账号 #${account.ID}`}
eyebrow="账号档案"
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "无用户名"} · {displayPhone(account.Phone) || "无手机号"}</div>
</div>
<div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good"></Badge> : <Badge></Badge>}
{detail.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{account.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="用户 ID" value={String(account.ID)} mono />
<Summary label="最后在线" value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label="会员到期" value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "无"} />
<Summary label="更新时间" value={formatDate(account.UpdatedAt) || "-"} />
<Summary label="授权设备" value={String(detail.Authorizations.length)} />
<Summary label="账号标记" value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label="限制状态" value={detail.HasRestriction ? detail.Restriction.Reason || "已限制" : "无"} />
<Summary label="创建时间" value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block">
<SectionHead title="授权设备" text={`${detail.Authorizations.length} 个授权`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section>
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={account.Frozen ? "解冻发消息" : "冻结发消息"}
icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
onDone={load}
/>
<label className="duration-field">
<span></span>
<input
aria-label="设置会员时长,单位月"
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
min="1"
max="120"
/>
</label>
<div className="action-stack">
<ActionButton
label="设置会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: toInt(months) })}
onDone={load}
/>
<ActionButton
label="取消会员"
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
payload={() => ({ user_id: account.ID, months: 0 })}
onDone={load}
/>
<ActionButton
label={detail.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-verified"
payload={() => ({ user_id: account.ID, verified: !detail.Verified })}
onDone={load}
/>
</div>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,124 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types";
export function AccountsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<AccountListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeActiveUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor.beforeID));
params.set("before_active_us", String(cursor.beforeActiveUS));
}
try {
const result = await api.accounts(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeActiveUS: result.next_before_active_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = accountMetrics(data?.rows ?? []);
return (
<PageFrame
title="账号"
eyebrow={data?.listing === false ? "查询结果" : "最近活跃账号"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页账号" value={String(data?.rows.length ?? 0)} />
<Metric label="在线设备记录" value={String(metrics.devices)} />
<Metric label="会员" value={String(metrics.premium)} tone="good" />
<Metric label="冻结" value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="用户 ID / 手机号 / 用户名" />
</label>
<label className="field-inline">
<span></span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{displayName(row)}</td>
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good"> {formatUnix(row.PremiumUntil)}</Badge> : <Badge></Badge>}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{row.Frozen ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,92 @@
import { ArrowLeft, BadgeCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.channel(id));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [id]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载频道详情" />;
}
const ch = detail.Channel;
return (
<PageFrame
title={`${channelKind(ch)} #${ch.ID}`}
eyebrow="频道档案"
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "无用户名"} · {ch.CreatorUserID}</div>
</div>
<div className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}
{ch.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="频道 ID" value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label="成员" value={`${ch.ParticipantsCount} / 管理员 ${ch.AdminsCount}`} />
<Summary label="治理状态" value={`封禁 ${ch.BannedCount} / 踢出 ${ch.KickedCount}`} />
<Summary label="频道标记" value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label="创建时间" value={formatUnix(ch.Date) || "-"} />
<Summary label="更新时间" value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<AuditTable rows={detail.AuditLogs} />
</section>
<section className="section-block">
<SectionHead title="频道原始行" text="数据库只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label={ch.Verified ? "取消认证" : "设置认证"}
icon={<BadgeCheck size={15} />}
tone="warn"
path="/api/actions/set-channel-verified"
payload={() => ({ channel_id: ch.ID, verified: !ch.Verified })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,122 @@
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null);
const [cursor, setCursor] = useState({ beforeID: 0, beforeUpdatedUS: 0 });
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load(next = false) {
setBusy(true);
setError("");
const params = new URLSearchParams({ limit });
if (q.trim()) {
params.set("q", q.trim());
} else if (next) {
params.set("before_id", String(cursor.beforeID));
params.set("before_updated_us", String(cursor.beforeUpdatedUS));
}
try {
const result = await api.channels(params);
setData(result);
setCursor({
beforeID: result.next_before_id,
beforeUpdatedUS: result.next_before_updated_us
});
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load(false);
}, []);
const metrics = channelMetrics(data?.rows ?? []);
return (
<PageFrame
title="超级群与频道"
eyebrow={data?.listing === false ? "查询结果" : "最近更新"}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} />
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页实体" value={String(data?.rows.length ?? 0)} />
<Metric label="超级群" value={String(metrics.megagroups)} />
<Metric label="频道" value={String(metrics.broadcasts)} />
<Metric label="已认证" value={String(metrics.verified)} tone="good" />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="频道 ID / 用户名 / 标题" />
</label>
<label className="field-inline">
<span></span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />}
</button>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} />
</button>
)}
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td>
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good"></Badge> : <Badge></Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}> <ChevronRight size={14} /></button></td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,59 @@
import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareText, ShieldCheck, Users } from "lucide-react";
import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui";
import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) {
return (
<div className="dashboard-layout">
<section className="overview-band">
<div>
<div className="eyebrow"></div>
<h2></h2>
</div>
<div className="overview-metrics">
<StatusItem label="读路径" value="PG 只读" tone="neutral" />
<StatusItem label="写路径" value="Admin API" tone="good" />
<StatusItem label="执行策略" value="先预演" tone="warn" />
</div>
</section>
<div className="command-grid">
<Launcher icon={<Users />} title="账号管理" text="账号状态、会员、认证、会话。" href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title="超级群与频道" text="公开实体、成员计数、认证状态。" href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title="消息审计" text="消息盒、update、outbox 状态。" href="/messages" navigate={navigate} />
</div>
<section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span></span></div>
<div className="strip-item"><KeyRound size={16} /><span> token</span></div>
<div className="strip-item"><Clock3 size={16} /><span>使</span></div>
<div className="strip-item"><FileJson size={16} /><span></span></div>
</section>
</div>
);
}
function Launcher({
icon,
title,
text,
href,
navigate
}: {
icon: ReactNode;
title: string;
text: string;
href: string;
navigate: Navigate;
}) {
return (
<AppLink className="launcher" href={href} navigate={navigate}>
<span className="launcher-icon">{icon}</span>
<span className="launcher-copy">
<strong>{title}</strong>
<span>{text}</span>
</span>
<ChevronRight size={16} />
</AppLink>
);
}

View file

@ -0,0 +1,100 @@
import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { GroupMessageDetail } from "../types";
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.groupMessage(channelID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [channelID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载群聊消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`群聊消息 #${msg.ID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> </button>}
>
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">/ {msg.ChannelID}</div>
<div className="entity-subtitle"> {msg.SenderUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
{msg.Pinned && <Badge tone="warn"></Badge>}
{msg.Post && <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息 ID" value={String(msg.ID)} mono />
<Summary label="频道 / 群" value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label="浏览" value={String(msg.ViewsCount)} />
</div>
<section className="section-block">
<SectionHead title="消息行" text="channel_messages 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<section className="section-block">
<SectionHead title="频道行" text="channels 只读快照" />
<JsonBlock value={detail.ChannelJSON} />
</section>
<section className="section-block">
<SectionHead title="频道更新事件" text="durable channel_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th> ID</th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => (
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
<td>{row.PTS}</td>
<td>{row.PTSCount}</td>
<td>{row.Type}</td>
<td>{row.MessageID}</td>
<td>{row.SenderUserID}</td>
<td>{formatUnix(row.Date)}</td>
</tr>
))}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={6} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="事件 JSON" />
<div className="raw-grid">
{detail.UpdateEvents.map((row) => (
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
))}
{detail.UpdateEvents.length === 0 && <div className="empty-panel"></div>}
</div>
</section>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,119 @@
import { ChevronRight, Search } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [data, setData] = useState<GroupMessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!channel) {
setError("请先搜索并选择超级群或频道");
return;
}
const params = new URLSearchParams({
channel_id: String(channel.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.ID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.ID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.groupMessages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeChannel(row: ChannelRow | null) {
setChannel(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
const rows = data?.rows ?? [];
return (
<PageFrame title="群聊消息" eyebrow="超级群 / 频道消息">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid single">
<ChannelPicker label="超级群 / 频道" value={channel} onChange={changeChannel} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(rows.length)} />
<Metric label="有媒体" value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label="频道帖子" value={String(rows.filter((row) => row.Post).length)} />
<Metric label="频道 / 群" value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th>From Peer</th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={`${row.ChannelID}-${row.ID}`}>
<td className="mono">{row.ID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.SenderUserID}</td>
<td className="mono">{row.FromPeerType}:{row.FromPeerID}</td>
<td>{row.PTS}</td>
<td>{row.ViewsCount}</td>
<td>
{row.Deleted ? <Badge tone="danger"></Badge> : row.Pinned ? <Badge tone="warn"></Badge> : <Badge></Badge>}
</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={9} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,61 @@
import type { FormEvent } from "react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { Alert } from "../components/ui";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const [secret, setSecret] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError("");
try {
const result = await api.login(secret);
onLogin(result.actor);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<main className="login-page">
<section className="login-panel">
<div className="login-head">
<div className="brand brand-elevated">
<span className="brand-mark">T</span>
<span>
<strong>telesrv</strong>
<small></small>
</span>
</div>
<span className="login-chip">访</span>
</div>
<div className="login-copy">
<h1></h1>
<p></p>
</div>
{error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}>
<label>
<span> token</span>
<input
autoFocus
type="password"
value={secret}
autoComplete="current-password"
onChange={(event) => setSecret(event.target.value)}
/>
</label>
<button className="btn primary full" type="submit" disabled={busy}>
{busy ? "登录中" : "登录"}
</button>
</form>
</section>
</main>
);
}

View file

@ -0,0 +1,116 @@
import { ArrowLeft, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState("");
async function load() {
setError("");
try {
setDetail(await api.message(ownerUserID, msgID));
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [ownerUserID, msgID]);
if (error) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载消息详情" />;
}
const msg = detail.Message;
return (
<PageFrame
title={`消息 #${msg.BoxID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> </button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title"> {msg.OwnerUserID} · {msg.PeerID}</div>
<div className="entity-subtitle"> {msg.FromUserID} · {formatUnix(msg.Date)}</div>
</div>
<div className="entity-badges">
{msg.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}
<Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? "发出" : "收到"}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息盒 ID" value={String(msg.BoxID)} mono />
<Summary label="私聊消息 ID" value={String(msg.PrivateMessageID)} mono />
<Summary label="发送方" value={String(msg.MessageSenderID)} mono />
<Summary label="时间" value={formatUnix(msg.Date)} />
</div>
<section className="section-block">
<SectionHead title="消息盒" text="message_boxes 只读快照" />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title="会话行" text="dialogs 只读快照" />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title="私聊消息行" text="private_messages 只读快照" />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
<section className="section-block">
<SectionHead title="更新事件" text="durable user_update_events" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>PTS</th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
</tbody>
</table>
</div>
</section>
<section className="section-block">
<SectionHead title="分发队列" text="在线/离线 dispatch_outbox" />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th></th><th>PTS</th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
</tbody>
</table>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"></div>
<ActionButton
label="删除此消息"
icon={<Trash2 size={15} />}
path="/api/actions/delete-messages"
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}
onDone={load}
/>
</section>
}
/>
</PageFrame>
);
}

View file

@ -0,0 +1,157 @@
import { ChevronRight, History, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) {
const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState("");
const [limit, setLimit] = useState("100");
const [ids, setIDs] = useState("");
const [revoke, setRevoke] = useState(true);
const [justClear, setJustClear] = useState(false);
const [maxID, setMaxID] = useState("");
const [maxBatches, setMaxBatches] = useState("1");
const [data, setData] = useState<MessageListResponse | null>(null);
const [error, setError] = useState("");
async function load(next = false) {
setError("");
if (!owner || !peer) {
setError("请先搜索并选择所属用户和对端用户");
return;
}
const params = new URLSearchParams({
owner_user_id: String(owner.ID),
peer_id: String(peer.ID),
limit
});
if (next && data?.rows.length) {
const last = data.rows[data.rows.length - 1];
params.set("before_date", String(last.Date));
params.set("before_id", String(last.BoxID));
setBeforeDate(String(last.Date));
setBeforeID(String(last.BoxID));
} else {
if (beforeDate) params.set("before_date", beforeDate);
if (beforeID) params.set("before_id", beforeID);
}
try {
setData(await api.messages(params));
} catch (err) {
setError(errorMessage(err));
}
}
function changeOwner(row: AccountRow | null) {
setOwner(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
function changePeer(row: AccountRow | null) {
setPeer(row);
setBeforeDate("");
setBeforeID("");
setData(null);
}
return (
<PageFrame title="私聊消息" eyebrow="私聊消息盒">
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid">
<UserPicker label="所属用户" value={owner} onChange={changeOwner} />
<UserPicker label="对端用户" value={peer} onChange={changePeer} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> </button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> </button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(data?.rows.length ?? 0)} />
<Metric label="已删除" value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label="发出消息" value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label="所属 / 对端" value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div>
<div className="operation-row">
<div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> </div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder="消息 ID逗号分隔" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-messages" label="预演删除" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
ids: parseIDs(ids),
revoke
})} />
</div>
<div className="operation-box">
<div className="operation-title"><History size={15} /> </div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder="max_id 截止消息" />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder="max_batches 批次数" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> </label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> </label>
<ActionButton path="/api/actions/delete-history" label="预演清历史" payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
max_id: toInt(maxID),
max_batches: toInt(maxBatches),
just_clear: justClear,
revoke
})} />
</div>
</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th> ID</th>
<th></th>
<th></th>
<th></th>
<th>PTS</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{data?.rows.map((row) => (
<tr key={`${row.OwnerUserID}-${row.BoxID}`}>
<td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</td>
<td className="mono">{row.FromUserID}</td>
<td>{row.Outgoing ? "发出" : "收到"}</td>
<td>{row.PTS}</td>
<td>{row.Deleted ? <Badge tone="danger"></Badge> : <Badge></Badge>}</td>
<td className="truncate">{row.Body}</td>
<td>
<button
className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
>
<ChevronRight size={14} />
</button>
</td>
</tr>
))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}

View file

@ -0,0 +1,52 @@
import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage";
import { AccountsPage } from "./AccountsPage";
import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage";
import { Dashboard } from "./Dashboard";
import { GroupMessageDetailPage } from "./GroupMessageDetailPage";
import { GroupMessagesPage } from "./GroupMessagesPage";
import { MessageDetailPage } from "./MessageDetailPage";
import { MessagesPage } from "./MessagesPage";
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
}
if (channelID) {
return <ChannelDetailPage id={Number(channelID)} navigate={navigate} />;
}
if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />;
}
if (route.path === "/channels") {
return <ChannelsPage navigate={navigate} />;
}
if (route.path === "/messages/detail" || route.path === "/messages/private/detail") {
return (
<MessageDetailPage
ownerUserID={Number(route.search.get("owner_user_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups/detail") {
return (
<GroupMessageDetailPage
channelID={Number(route.search.get("channel_id") || "0")}
msgID={Number(route.search.get("msg_id") || "0")}
navigate={navigate}
/>
);
}
if (route.path === "/messages/groups") {
return <GroupMessagesPage navigate={navigate} />;
}
if (route.path === "/messages" || route.path === "/messages/private") {
return <MessagesPage navigate={navigate} />;
}
return <Dashboard navigate={navigate} />;
}