chore: refresh gramsrv public release
This commit is contained in:
parent
75cebe8dbf
commit
70b6820474
1274 changed files with 378751 additions and 59919 deletions
134
cmd/telesrv-admin/web/src/pages/AccountDetailPage.tsx
Normal file
134
cmd/telesrv-admin/web/src/pages/AccountDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
cmd/telesrv-admin/web/src/pages/AccountsPage.tsx
Normal file
124
cmd/telesrv-admin/web/src/pages/AccountsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
cmd/telesrv-admin/web/src/pages/ChannelDetailPage.tsx
Normal file
92
cmd/telesrv-admin/web/src/pages/ChannelDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
cmd/telesrv-admin/web/src/pages/ChannelsPage.tsx
Normal file
122
cmd/telesrv-admin/web/src/pages/ChannelsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
cmd/telesrv-admin/web/src/pages/Dashboard.tsx
Normal file
59
cmd/telesrv-admin/web/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
cmd/telesrv-admin/web/src/pages/GroupMessageDetailPage.tsx
Normal file
100
cmd/telesrv-admin/web/src/pages/GroupMessageDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
cmd/telesrv-admin/web/src/pages/GroupMessagesPage.tsx
Normal file
119
cmd/telesrv-admin/web/src/pages/GroupMessagesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
cmd/telesrv-admin/web/src/pages/LoginPage.tsx
Normal file
61
cmd/telesrv-admin/web/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
cmd/telesrv-admin/web/src/pages/MessageDetailPage.tsx
Normal file
116
cmd/telesrv-admin/web/src/pages/MessageDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
cmd/telesrv-admin/web/src/pages/MessagesPage.tsx
Normal file
157
cmd/telesrv-admin/web/src/pages/MessagesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
cmd/telesrv-admin/web/src/pages/Routes.tsx
Normal file
52
cmd/telesrv-admin/web/src/pages/Routes.tsx
Normal 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} />;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue