admin-ui: sync English localization

This commit is contained in:
A 2026-07-02 16:53:51 +08:00
parent 4570df5939
commit 44e8cde27f
27 changed files with 1016 additions and 290 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,11 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="en" translate="no">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>telesrv admin</title> <title>telesrv admin</title>
<script type="module" crossorigin src="/assets/index-m3onrdER.js"></script> <script type="module" crossorigin src="/assets/index-DLK0oYaN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-IDEWWIS9.css"> <link rel="stylesheet" crossorigin href="/assets/index-W1-UPxwc.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="en" translate="no">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

View file

@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import type { CommandResult } from "../types"; import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui"; import { Alert, JsonBlock } from "./ui";
@ -25,6 +26,7 @@ export function ActionButton({
tone?: ActionTone; tone?: ActionTone;
onDone?: () => void; onDone?: () => void;
}) { }) {
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null); const [result, setResult] = useState<CommandResult | null>(null);
@ -39,7 +41,7 @@ export function ActionButton({
async function run(confirm: boolean) { async function run(confirm: boolean) {
if (!reason.trim()) { if (!reason.trim()) {
setError("请填写操作原因"); setError(t("action.reasonRequired"));
return; return;
} }
setBusy(true); setBusy(true);
@ -86,55 +88,55 @@ export function ActionButton({
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}> <section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
<div className="modal-head"> <div className="modal-head">
<div> <div>
<div className="eyebrow">操作流程</div> <div className="eyebrow">{t("action.flow")}</div>
<h2>{label}</h2> <h2>{label}</h2>
</div> </div>
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label="关闭"><X size={15} /></button> <button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label={t("action.close")}><X size={15} /></button>
</div> </div>
<div className="command-body"> <div className="command-body">
<div className="command-steps"> <div className="command-steps">
<div className={`command-step ${reason.trim() ? "done" : "active"}`}> <div className={`command-step ${reason.trim() ? "done" : "active"}`}>
<span>1</span><strong>填写原因</strong> <span>1</span><strong>{t("action.stepReason")}</strong>
</div> </div>
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}> <div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
<span>2</span><strong>预演检查</strong> <span>2</span><strong>{t("action.stepDryRun")}</strong>
</div> </div>
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}> <div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
<span>3</span><strong>确认执行</strong> <span>3</span><strong>{t("action.stepConfirm")}</strong>
</div> </div>
</div> </div>
<label className="form-field"> <label className="form-field">
<span>操作原因</span> <span>{t("action.reason")}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="说明本次操作原因" /> <textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={t("action.reasonPlaceholder")} />
</label> </label>
<div className="command-preview"> <div className="command-preview">
<div className="preview-head"><FileJson size={14} /> 请求预览</div> <div className="preview-head"><FileJson size={14} /> {t("action.requestPreview")}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} /> <JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{result && ( {result && (
<div className="result-box"> <div className="result-box">
<div className="result-title"> <div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />} {result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || "操作结果"}</strong> <strong>{result.message || result.error || t("action.result")}</strong>
</div> </div>
<div className="result-line"><span>命令 ID</span><strong>{result.command_id}</strong></div> <div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>状态</span><strong>{result.status}</strong></div> <div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>预演</span><strong>{result.dry_run ? "是" : "否"}</strong></div> <div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div>
<div className="result-message">{result.message || result.error}</div> <div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />} {result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div> </div>
)} )}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" type="button" onClick={() => setOpen(false)}>关闭</button> <button className="btn" type="button" onClick={() => setOpen(false)}>{t("common.close")}</button>
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />} {busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? "重新预演" : "先预演"} {result ? t("action.runAgain") : t("action.runDry")}
</button> </button>
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}> <button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<CheckCircle2 size={15} /> <CheckCircle2 size={15} />
确认执行 {t("action.confirm")}
</button> </button>
</div> </div>
</section> </section>

View file

@ -1,11 +1,13 @@
import { Cable, LogOut, ShieldCheck } from "lucide-react"; import { Cable, LogOut, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { formatDate } from "../lib/format"; import { formatDate } from "../lib/format";
import { useI18n } from "../i18n";
import type { AuthorizationRow } from "../types"; import type { AuthorizationRow } from "../types";
import { ActionButton } from "./ActionButton"; import { ActionButton } from "./ActionButton";
import { EmptyRow } from "./ui"; import { EmptyRow } from "./ui";
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) { export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
const { t } = useI18n();
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set()); const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
useEffect(() => { useEffect(() => {
@ -28,11 +30,11 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
<table className="data-table authorization-table"> <table className="data-table authorization-table">
<thead> <thead>
<tr> <tr>
<th>设备</th> <th>{t("auth.device")}</th>
<th>平台</th> <th>{t("auth.platform")}</th>
<th>IP</th> <th>{t("auth.ip")}</th>
<th>最近活跃</th> <th>{t("auth.lastActive")}</th>
<th className="device-actions-head">操作</th> <th className="device-actions-head">{t("common.actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -45,7 +47,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
<td className="device-actions-cell"> <td className="device-actions-cell">
<div className="device-actions"> <div className="device-actions">
<ActionButton <ActionButton
label="撤销当前" label={t("auth.revokeCurrent")}
icon={<LogOut size={13} />} icon={<LogOut size={13} />}
compact compact
path="/api/actions/revoke-sessions" path="/api/actions/revoke-sessions"
@ -53,7 +55,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))} onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
/> />
<ActionButton <ActionButton
label="保留当前" label={t("auth.keepCurrent")}
icon={<ShieldCheck size={13} />} icon={<ShieldCheck size={13} />}
compact compact
path="/api/actions/revoke-sessions" path="/api/actions/revoke-sessions"
@ -70,7 +72,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
</div> </div>
<div className="danger-zone"> <div className="danger-zone">
<ActionButton <ActionButton
label="撤销全部设备" label={t("auth.revokeAll")}
icon={<Cable size={15} />} icon={<Cable size={15} />}
path="/api/actions/revoke-sessions" path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, revoke_all: true })} payload={() => ({ user_id: userID, revoke_all: true })}

View file

@ -1,6 +1,7 @@
import { Check, Loader2, Search, X } from "lucide-react"; import { Check, Loader2, Search, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format"; import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
import type { AccountRow, ChannelRow } from "../types"; import type { AccountRow, ChannelRow } from "../types";
import { Badge } from "./ui"; import { Badge } from "./ui";
@ -14,6 +15,7 @@ export function UserPicker({
value: AccountRow | null; value: AccountRow | null;
onChange: (row: AccountRow | null) => void; onChange: (row: AccountRow | null) => void;
}) { }) {
const { t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]); const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -46,7 +48,7 @@ export function UserPicker({
<span>{label}</span> <span>{label}</span>
{value ? ( {value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}> <button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> 清除 <X size={13} /> {t("common.clear")}
</button> </button>
) : null} ) : null}
</div> </div>
@ -71,10 +73,10 @@ export function UserPicker({
void search(); void search();
} }
}} }}
placeholder="搜索 user_id / phone / username" placeholder={t("picker.userPlaceholder")}
/> />
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}> <button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"} {busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
</button> </button>
</div> </div>
{error && <div className="picker-error">{error}</div>} {error && <div className="picker-error">{error}</div>}
@ -89,10 +91,10 @@ export function UserPicker({
<span className="mono">{row.ID}</span> <span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong> <strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span> <span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{row.Verified ? <Badge tone="good">认证</Badge> : <Badge>普通</Badge>} {row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{t("picker.regular")}</Badge>}
</button> </button>
))} ))}
{rows.length === 0 && !busy ? <div className="picker-empty">无结果</div> : null} {rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
</div> </div>
</div> </div>
); );
@ -107,6 +109,7 @@ export function ChannelPicker({
value: ChannelRow | null; value: ChannelRow | null;
onChange: (row: ChannelRow | null) => void; onChange: (row: ChannelRow | null) => void;
}) { }) {
const { t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [rows, setRows] = useState<ChannelRow[]>([]); const [rows, setRows] = useState<ChannelRow[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -139,7 +142,7 @@ export function ChannelPicker({
<span>{label}</span> <span>{label}</span>
{value ? ( {value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}> <button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> 清除 <X size={13} /> {t("common.clear")}
</button> </button>
) : null} ) : null}
</div> </div>
@ -150,7 +153,7 @@ export function ChannelPicker({
<strong>{value.Title || "-"}</strong> <strong>{value.Title || "-"}</strong>
<span className="mono">{value.ID}</span> <span className="mono">{value.ID}</span>
</div> </div>
<span>{displayUsername(value.Username) || channelKind(value)}</span> <span>{displayUsername(value.Username) || channelKind(value, t)}</span>
</div> </div>
) : null} ) : null}
<div className="picker-search"> <div className="picker-search">
@ -164,10 +167,10 @@ export function ChannelPicker({
void search(); void search();
} }
}} }}
placeholder="搜索 channel_id / username / title" placeholder={t("picker.channelPlaceholder")}
/> />
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}> <button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "搜索"} {busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
</button> </button>
</div> </div>
{error && <div className="picker-error">{error}</div>} {error && <div className="picker-error">{error}</div>}
@ -181,11 +184,11 @@ export function ChannelPicker({
> >
<span className="mono">{row.ID}</span> <span className="mono">{row.ID}</span>
<strong>{row.Title || "-"}</strong> <strong>{row.Title || "-"}</strong>
<span>{displayUsername(row.Username) || channelKind(row)}</span> <span>{displayUsername(row.Username) || channelKind(row, t)}</span>
{row.Verified ? <Badge tone="good">认证</Badge> : <Badge>{channelKind(row)}</Badge>} {row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{channelKind(row, t)}</Badge>}
</button> </button>
))} ))}
{rows.length === 0 && !busy ? <div className="picker-empty">无结果</div> : null} {rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
</div> </div>
</div> </div>
); );

View file

@ -11,17 +11,19 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api"; import { api } from "../api";
import { LanguageSwitch, useI18n } from "../i18n";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing"; import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { AppLink } from "./AppLink"; import { AppLink } from "./AppLink";
export function BootScreen() { export function BootScreen() {
const { t } = useI18n();
return ( return (
<div className="boot-screen"> <div className="boot-screen">
<div className="brand compact brand-elevated"> <div className="brand compact brand-elevated">
<span className="brand-mark">T</span> <span className="brand-mark">T</span>
<span> <span>
<strong>telesrv</strong> <strong>telesrv</strong>
<small>管理控制台</small> <small>{t("app.adminConsole")}</small>
</span> </span>
</div> </div>
<div className="loader-bar" /> <div className="loader-bar" />
@ -42,6 +44,7 @@ export function Shell({
onLogout: () => void; onLogout: () => void;
children: ReactNode; children: ReactNode;
}) { }) {
const { t } = useI18n();
const messagesActive = route.path.startsWith("/messages"); const messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive); const [messagesOpen, setMessagesOpen] = useState(messagesActive);
@ -63,14 +66,14 @@ export function Shell({
<span className="brand-mark">T</span> <span className="brand-mark">T</span>
<span> <span>
<strong>telesrv</strong> <strong>telesrv</strong>
<small>管理控制台</small> <small>{t("app.adminConsole")}</small>
</span> </span>
</AppLink> </AppLink>
<div className="sidebar-label">导航</div> <div className="sidebar-label">{t("layout.navigation")}</div>
<nav className="nav-list" aria-label="主导航"> <nav className="nav-list" aria-label={t("layout.primaryNav")}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>总览</NavLink> <NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>账号</NavLink> <NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>超级群/频道</NavLink> <NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}> <div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button <button
className="nav-section-toggle" className="nav-section-toggle"
@ -79,7 +82,7 @@ export function Shell({
onClick={() => setMessagesOpen((open) => !open)} onClick={() => setMessagesOpen((open) => !open)}
> >
<MessageSquareText size={16} /> <MessageSquareText size={16} />
<span>消息</span> <span>{t("layout.messages")}</span>
<ChevronDown className="nav-section-chevron" size={15} /> <ChevronDown className="nav-section-chevron" size={15} />
</button> </button>
{messagesOpen && ( {messagesOpen && (
@ -90,7 +93,7 @@ export function Shell({
navigate={navigate} navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")} activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
> >
私聊 {t("layout.privateMessages")}
</NavLink> </NavLink>
<NavLink <NavLink
href="/messages/groups" href="/messages/groups"
@ -98,29 +101,30 @@ export function Shell({
navigate={navigate} navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")} activeWhen={(path) => path.startsWith("/messages/groups")}
> >
群聊 {t("layout.groupMessages")}
</NavLink> </NavLink>
</div> </div>
)} )}
</div> </div>
</nav> </nav>
<div className="sidebar-status"> <div className="sidebar-status">
<div className="sidebar-label">运行状态</div> <div className="sidebar-label">{t("layout.runtime")}</div>
<div className="runtime-row"><Server size={14} /><span>管理后台</span><strong>就绪</strong></div> <div className="runtime-row"><Server size={14} /><span>{t("layout.adminBackend")}</span><strong>{t("layout.ready")}</strong></div>
<div className="runtime-row"><Database size={14} /><span>PG 读取</span><strong>只读</strong></div> <div className="runtime-row"><Database size={14} /><span>{t("layout.pgRead")}</span><strong>{t("layout.readOnly")}</strong></div>
<div className="runtime-row"><Shield size={14} /><span>写操作</span><strong>预演</strong></div> <div className="runtime-row"><Shield size={14} /><span>{t("layout.writeOps")}</span><strong>{t("layout.dryRun")}</strong></div>
</div> </div>
</aside> </aside>
<div className="workspace"> <div className="workspace">
<header className="topbar"> <header className="topbar">
<div> <div>
<div className="eyebrow">{routeSubtitle(route.path)}</div> <div className="eyebrow">{routeSubtitle(route.path, t)}</div>
<h1>{routeTitle(route.path)}</h1> <h1>{routeTitle(route.path, t)}</h1>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<span className="actor-pill">操作者:{actor}</span> <LanguageSwitch />
<button className="btn ghost icon-text" type="button" onClick={logout} title="退出"> <span className="actor-pill">{t("layout.actor", { actor })}</span>
<LogOut size={16} /> 退出 <button className="btn ghost icon-text" type="button" onClick={logout} title={t("layout.logout")}>
<LogOut size={16} /> {t("layout.logout")}
</button> </button>
</div> </div>
</header> </header>

View file

@ -1,5 +1,6 @@
import { CircleAlert } from "lucide-react"; import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format"; import { formatDate } from "../lib/format";
import type { AuditLogRow } from "../types"; import type { AuditLogRow } from "../types";
@ -91,10 +92,11 @@ export function Summary({ label, value, mono = false }: { label: string; value:
} }
export function AuditTable({ rows }: { rows: AuditLogRow[] }) { export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
const { t } = useI18n();
return ( return (
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>ID</th><th>命令 ID</th><th>动作</th><th>操作者</th><th>状态</th><th>预演</th><th>原因</th><th>时间</th></tr></thead> <thead><tr><th>{t("audit.id")}</th><th>{t("audit.commandID")}</th><th>{t("audit.action")}</th><th>{t("audit.actor")}</th><th>{t("audit.status")}</th><th>{t("audit.dryRun")}</th><th>{t("audit.reason")}</th><th>{t("audit.time")}</th></tr></thead>
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.ID}> <tr key={row.ID}>
@ -103,7 +105,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
<td>{row.Action}</td> <td>{row.Action}</td>
<td>{row.Actor}</td> <td>{row.Actor}</td>
<td>{row.Status}</td> <td>{row.Status}</td>
<td>{row.DryRun ? "是" : "否"}</td> <td>{row.DryRun ? t("common.yes") : t("common.no")}</td>
<td className="truncate">{row.Reason}</td> <td className="truncate">{row.Reason}</td>
<td>{formatDate(row.CreatedAt)}</td> <td>{formatDate(row.CreatedAt)}</td>
</tr> </tr>
@ -116,7 +118,8 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
} }
export function EmptyRow({ colSpan }: { colSpan: number }) { export function EmptyRow({ colSpan }: { colSpan: number }) {
return <tr><td colSpan={colSpan} className="empty-cell">无结果</td></tr>; const { t } = useI18n();
return <tr><td colSpan={colSpan} className="empty-cell">{t("common.noResults")}</td></tr>;
} }
export function LoadingSurface({ label }: { label: string }) { export function LoadingSurface({ label }: { label: string }) {

View file

@ -0,0 +1,637 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Language = "en" | "zh";
export type TranslationParams = Record<string, string | number | boolean>;
export type TFunction = (key: string, params?: TranslationParams) => string;
const storageKey = "telesrv.admin.lang";
const translations: Record<Language, Record<string, string>> = {
en: {
"app.adminConsole": "Admin Console",
"app.localAccess": "Local access",
"app.title": "telesrv admin",
"common.actions": "Actions",
"common.admins": "Admins",
"common.backToList": "Back to list",
"common.channel": "Channel",
"common.channelOrGroup": "Channel / Group",
"common.clear": "Clear",
"common.close": "Close",
"common.count": "Count",
"common.deleted": "Deleted",
"common.detail": "Details",
"common.device": "Device",
"common.disabled": "Disabled",
"common.enabled": "Enabled",
"common.fromPeer": "From Peer",
"common.group": "Group",
"common.id": "ID",
"common.limit": "Limit",
"common.loading": "Loading",
"common.member": "Member",
"common.members": "Members",
"common.messageId": "Message ID",
"common.name": "Name",
"common.no": "No",
"common.noResults": "No results",
"common.none": "None",
"common.normal": "Normal",
"common.operations": "Operations",
"common.owner": "Owner",
"common.platform": "Platform",
"common.refresh": "Refresh",
"common.search": "Search",
"common.sender": "Sender",
"common.status": "Status",
"common.survived": "Live",
"common.time": "Time",
"common.type": "Type",
"common.updatedAt": "Updated",
"common.username": "Username",
"common.valid": "Valid",
"common.verified": "Verified",
"common.views": "Views",
"common.yes": "Yes",
"route.accounts": "Accounts",
"route.accountsSubtitle": "Console / Accounts",
"route.channels": "Supergroups and Channels",
"route.channelsSubtitle": "Console / Channels",
"route.dashboard": "Operations Console",
"route.dashboardSubtitle": "Console / Overview",
"route.messages": "Message Audit",
"route.messagesSubtitle": "Console / Messages",
"layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview",
"layout.accounts": "Accounts",
"layout.channels": "Supergroups / Channels",
"layout.messages": "Messages",
"layout.privateMessages": "Private",
"layout.groupMessages": "Groups",
"layout.runtime": "Runtime",
"layout.adminBackend": "Admin backend",
"layout.ready": "Ready",
"layout.pgRead": "PG read",
"layout.readOnly": "Read-only",
"layout.writeOps": "Write operations",
"layout.dryRun": "Dry-run",
"layout.actor": "Actor: {actor}",
"layout.logout": "Log out",
"language.en": "EN",
"language.zh": "中文",
"login.heading": "Operations Admin",
"login.body": "Enter credentials to open the console.",
"login.secret": "Admin password or token",
"login.submit": "Log in",
"login.submitting": "Logging in",
"dashboard.eyebrow": "Runtime Overview",
"dashboard.title": "Console Overview",
"dashboard.readPath": "Read path",
"dashboard.readPathValue": "PG read-only",
"dashboard.writePath": "Write path",
"dashboard.executionPolicy": "Execution policy",
"dashboard.dryRunFirst": "Dry-run first",
"dashboard.accountsText": "Account status, premium, verification, sessions.",
"dashboard.channelsText": "Public entities, member counts, verification state.",
"dashboard.messagesText": "Message boxes, updates, outbox state.",
"dashboard.strip.dryRun": "All dangerous actions start with dry-run",
"dashboard.strip.token": "Browser never stores internal tokens",
"dashboard.strip.pagination": "Lists use cursor pagination",
"dashboard.strip.snapshot": "Detail pages retain raw state snapshots",
"account.pageTitle": "Accounts",
"account.queryResults": "Search results",
"account.recentActive": "Recently active accounts",
"account.currentPage": "Accounts on page",
"account.onlineDevices": "Online device records",
"account.premium": "Premium",
"account.frozen": "Frozen",
"account.searchPlaceholder": "User ID / phone / username",
"account.userID": "User ID",
"account.phone": "Phone",
"account.lastActive": "Last active",
"account.notVerified": "Not verified",
"account.notPremium": "Not premium",
"account.premiumUntil": "Premium expires",
"account.activeSessions": "Authorized devices",
"account.accountFlags": "Account flags",
"account.restriction": "Restriction",
"account.restricted": "Restricted",
"account.createdAt": "Created",
"account.detailTitle": "Account #{id}",
"account.profile": "Account Profile",
"account.loadingDetail": "Loading account detail",
"account.waitingData": "Waiting for data",
"account.noUsername": "No username",
"account.noPhone": "No phone",
"account.sendFrozen": "Sending frozen",
"account.sendNormal": "Sending allowed",
"account.authorizationsTitle": "Authorized Devices",
"account.authorizationsCount": "{count} authorizations",
"account.recentAdminOps": "Recent Admin Actions",
"account.recent30Audit": "Last 30 audit rows",
"account.actionDock": "Account Actions",
"account.freezeSend": "Freeze sending",
"account.unfreezeSend": "Unfreeze sending",
"account.premiumMonths": "Premium duration (months)",
"account.premiumMonthsAria": "Set premium duration in months",
"account.setPremium": "Set premium",
"account.clearPremium": "Clear premium",
"account.setVerified": "Set verified",
"account.clearVerified": "Clear verified",
"channel.pageTitle": "Supergroups and Channels",
"channel.recentUpdated": "Recently updated",
"channel.currentPage": "Entities on page",
"channel.megagroups": "Supergroups",
"channel.broadcasts": "Channels",
"channel.verifiedCount": "Verified",
"channel.searchPlaceholder": "Channel ID / username / title",
"channel.channelID": "Channel ID",
"channel.kind": "Kind",
"channel.title": "Title",
"channel.pts": "PTS",
"channel.detailProfile": "Channel Profile",
"channel.loadingDetail": "Loading channel detail",
"channel.creator": "Creator {id}",
"channel.governance": "Moderation",
"channel.governanceValue": "Banned {banned} / Kicked {kicked}",
"channel.flags": "Channel flags",
"channel.rawRow": "Channel Raw Row",
"channel.rawRowText": "Database read-only snapshot",
"channel.actionDock": "Channel Actions",
"channel.setVerified": "Set verified",
"channel.clearVerified": "Clear verified",
"channel.kind.broadcast": "Channel",
"channel.kind.forum": "Supergroup / Forum",
"channel.kind.megagroup": "Supergroup",
"channel.kind.generic": "Channel / Group",
"messages.privateTitle": "Private Messages",
"messages.privateEyebrow": "Private message boxes",
"messages.groupTitle": "Group Messages",
"messages.groupEyebrow": "Supergroup / channel messages",
"messages.selectPrivatePeers": "Search and select the owner user and peer user first",
"messages.selectChannel": "Search and select a supergroup or channel first",
"messages.ownerUser": "Owner user",
"messages.peerUser": "Peer user",
"messages.beforeDatePlaceholder": "before_date cursor",
"messages.beforeIDPlaceholder": "before_msg_id cursor",
"messages.limitPlaceholder": "limit <= 100",
"messages.searchMessages": "Search messages",
"messages.nextPage": "Next page",
"messages.currentPage": "Messages on page",
"messages.deleted": "Deleted",
"messages.outgoing": "Outgoing",
"messages.incoming": "Incoming",
"messages.ownerPeer": "Owner / Peer",
"messages.deleteSelected": "Delete selected messages",
"messages.idsPlaceholder": "Message IDs, comma separated",
"messages.revoke": "Revoke for both sides",
"messages.previewDelete": "Dry-run delete",
"messages.clearHistory": "Clear private history",
"messages.maxIDPlaceholder": "max_id cutoff",
"messages.maxBatchesPlaceholder": "max_batches",
"messages.justClear": "Clear only this side",
"messages.previewClearHistory": "Dry-run clear history",
"messages.direction": "Direction",
"messages.body": "Body",
"messages.privateDetailTitle": "Message #{id}",
"messages.detailEyebrow": "Message Detail",
"messages.backPrivate": "Back to private messages",
"messages.backGroup": "Back to group messages",
"messages.ownerPeerTitle": "Owner {owner} · Peer {peer}",
"messages.senderSubtitle": "Sender {sender} · {date}",
"messages.boxID": "Message box ID",
"messages.privateMessageID": "Private message ID",
"messages.messageSender": "Message sender",
"messages.messageBox": "Message Box",
"messages.dialogRow": "Dialog Row",
"messages.privateRow": "Private Message Row",
"messages.channelMessageRow": "Channel Message Row",
"messages.channelRow": "Channel Row",
"messages.userUpdateEvents": "Update Events",
"messages.channelUpdateEvents": "Channel Update Events",
"messages.eventJson": "Event JSON",
"messages.dispatchOutbox": "Dispatch Queue",
"messages.messageBoxesSnapshot": "message_boxes read-only snapshot",
"messages.dialogSnapshot": "dialogs read-only snapshot",
"messages.privateSnapshot": "private_messages read-only snapshot",
"messages.channelMessagesSnapshot": "channel_messages read-only snapshot",
"messages.channelSnapshot": "channels read-only snapshot",
"messages.userEventsSource": "durable user_update_events",
"messages.channelEventsSource": "durable channel_update_events",
"messages.outboxSource": "online/offline dispatch_outbox",
"messages.attempts": "Attempts",
"messages.deleteThis": "Delete this message",
"messages.groupDetailTitle": "Group Message #{id}",
"messages.channelGroupTitle": "Channel / Group {id}",
"messages.mediaCount": "With media",
"messages.channelPosts": "Channel posts",
"messages.channelGroup": "Channel / Group",
"messages.pinned": "Pinned",
"messages.channelPost": "Channel post",
"messages.msgIDsInvalid": "Message IDs are invalid",
"auth.device": "Device",
"auth.platform": "Platform",
"auth.ip": "IP",
"auth.lastActive": "Last active",
"auth.revokeCurrent": "Revoke current",
"auth.keepCurrent": "Keep current",
"auth.revokeAll": "Revoke all devices",
"picker.userPlaceholder": "Search user_id / phone / username",
"picker.channelPlaceholder": "Search channel_id / username / title",
"picker.verified": "Verified",
"picker.regular": "Regular",
"action.reasonRequired": "Please enter an operation reason",
"action.flow": "Action Flow",
"action.close": "Close",
"action.stepReason": "Enter reason",
"action.stepDryRun": "Dry-run check",
"action.stepConfirm": "Confirm execution",
"action.reason": "Operation reason",
"action.reasonPlaceholder": "Describe why this operation is being performed",
"action.requestPreview": "Request preview",
"action.result": "Action result",
"action.commandID": "Command ID",
"action.status": "Status",
"action.dryRun": "Dry-run",
"action.runAgain": "Run dry-run again",
"action.runDry": "Run dry-run first",
"action.confirm": "Confirm execution",
"audit.id": "ID",
"audit.commandID": "Command ID",
"audit.action": "Action",
"audit.actor": "Actor",
"audit.status": "Status",
"audit.dryRun": "Dry-run",
"audit.reason": "Reason",
"audit.time": "Time"
},
zh: {
"app.adminConsole": "管理控制台",
"app.localAccess": "本地访问",
"app.title": "telesrv 管理后台",
"common.actions": "操作",
"common.admins": "管理员",
"common.backToList": "返回列表",
"common.channel": "频道",
"common.channelOrGroup": "频道/群",
"common.clear": "清除",
"common.close": "关闭",
"common.count": "数量",
"common.deleted": "已删除",
"common.detail": "详情",
"common.device": "设备",
"common.disabled": "已禁用",
"common.enabled": "已启用",
"common.fromPeer": "From Peer",
"common.group": "群组",
"common.id": "ID",
"common.limit": "条数",
"common.loading": "加载中",
"common.member": "成员",
"common.members": "成员",
"common.messageId": "消息 ID",
"common.name": "姓名",
"common.no": "否",
"common.noResults": "无结果",
"common.none": "无",
"common.normal": "正常",
"common.operations": "操作",
"common.owner": "所属",
"common.platform": "平台",
"common.refresh": "刷新",
"common.search": "查询",
"common.sender": "发送方",
"common.status": "状态",
"common.survived": "存活",
"common.time": "时间",
"common.type": "类型",
"common.updatedAt": "更新时间",
"common.username": "用户名",
"common.valid": "有效",
"common.verified": "已认证",
"common.views": "浏览",
"common.yes": "是",
"route.accounts": "账号管理",
"route.accountsSubtitle": "控制台 / 账号",
"route.channels": "超级群与频道",
"route.channelsSubtitle": "控制台 / 频道",
"route.dashboard": "运维控制台",
"route.dashboardSubtitle": "控制台 / 总览",
"route.messages": "消息审计",
"route.messagesSubtitle": "控制台 / 消息",
"layout.navigation": "导航",
"layout.primaryNav": "主导航",
"layout.dashboard": "总览",
"layout.accounts": "账号",
"layout.channels": "超级群/频道",
"layout.messages": "消息",
"layout.privateMessages": "私聊",
"layout.groupMessages": "群聊",
"layout.runtime": "运行状态",
"layout.adminBackend": "管理后台",
"layout.ready": "就绪",
"layout.pgRead": "PG 读取",
"layout.readOnly": "只读",
"layout.writeOps": "写操作",
"layout.dryRun": "预演",
"layout.actor": "操作者:{actor}",
"layout.logout": "退出",
"language.en": "EN",
"language.zh": "中文",
"login.heading": "运维后台",
"login.body": "输入凭据后进入控制台。",
"login.secret": "管理员密码或 token",
"login.submit": "登录",
"login.submitting": "登录中",
"dashboard.eyebrow": "运行总览",
"dashboard.title": "控制台总览",
"dashboard.readPath": "读路径",
"dashboard.readPathValue": "PG 只读",
"dashboard.writePath": "写路径",
"dashboard.executionPolicy": "执行策略",
"dashboard.dryRunFirst": "先预演",
"dashboard.accountsText": "账号状态、会员、认证、会话。",
"dashboard.channelsText": "公开实体、成员计数、认证状态。",
"dashboard.messagesText": "消息盒、update、outbox 状态。",
"dashboard.strip.dryRun": "所有危险操作先预演",
"dashboard.strip.token": "浏览器不持有内部 token",
"dashboard.strip.pagination": "列表使用游标分页",
"dashboard.strip.snapshot": "详情页保留原始状态快照",
"account.pageTitle": "账号",
"account.queryResults": "查询结果",
"account.recentActive": "最近活跃账号",
"account.currentPage": "当前页账号",
"account.onlineDevices": "在线设备记录",
"account.premium": "会员",
"account.frozen": "冻结",
"account.searchPlaceholder": "用户 ID / 手机号 / 用户名",
"account.userID": "用户 ID",
"account.phone": "手机号",
"account.lastActive": "最近活跃",
"account.notVerified": "未认证",
"account.notPremium": "非会员",
"account.premiumUntil": "会员到期",
"account.activeSessions": "授权设备",
"account.accountFlags": "账号标记",
"account.restriction": "限制状态",
"account.restricted": "已限制",
"account.createdAt": "创建时间",
"account.detailTitle": "账号 #{id}",
"account.profile": "账号档案",
"account.loadingDetail": "加载账号详情",
"account.waitingData": "等待数据",
"account.noUsername": "无用户名",
"account.noPhone": "无手机号",
"account.sendFrozen": "发消息冻结",
"account.sendNormal": "发送正常",
"account.authorizationsTitle": "授权设备",
"account.authorizationsCount": "共 {count} 个授权",
"account.recentAdminOps": "最近后台操作",
"account.recent30Audit": "最近 30 条审计",
"account.actionDock": "账号操作",
"account.freezeSend": "冻结发消息",
"account.unfreezeSend": "解冻发消息",
"account.premiumMonths": "会员时长(月)",
"account.premiumMonthsAria": "设置会员时长,单位月",
"account.setPremium": "设置会员",
"account.clearPremium": "取消会员",
"account.setVerified": "设置认证",
"account.clearVerified": "取消认证",
"channel.pageTitle": "超级群与频道",
"channel.recentUpdated": "最近更新",
"channel.currentPage": "当前页实体",
"channel.megagroups": "超级群",
"channel.broadcasts": "频道",
"channel.verifiedCount": "已认证",
"channel.searchPlaceholder": "频道 ID / 用户名 / 标题",
"channel.channelID": "频道 ID",
"channel.kind": "类型",
"channel.title": "标题",
"channel.pts": "PTS",
"channel.detailProfile": "频道档案",
"channel.loadingDetail": "加载频道详情",
"channel.creator": "创建者 {id}",
"channel.governance": "治理状态",
"channel.governanceValue": "封禁 {banned} / 踢出 {kicked}",
"channel.flags": "频道标记",
"channel.rawRow": "频道原始行",
"channel.rawRowText": "数据库只读快照",
"channel.actionDock": "频道操作",
"channel.setVerified": "设置认证",
"channel.clearVerified": "取消认证",
"channel.kind.broadcast": "频道",
"channel.kind.forum": "超级群/论坛",
"channel.kind.megagroup": "超级群",
"channel.kind.generic": "频道/群",
"messages.privateTitle": "私聊消息",
"messages.privateEyebrow": "私聊消息盒",
"messages.groupTitle": "群聊消息",
"messages.groupEyebrow": "超级群 / 频道消息",
"messages.selectPrivatePeers": "请先搜索并选择所属用户和对端用户",
"messages.selectChannel": "请先搜索并选择超级群或频道",
"messages.ownerUser": "所属用户",
"messages.peerUser": "对端用户",
"messages.beforeDatePlaceholder": "before_date 游标",
"messages.beforeIDPlaceholder": "before_msg_id 游标",
"messages.limitPlaceholder": "条数 <= 100",
"messages.searchMessages": "查询消息",
"messages.nextPage": "下一页",
"messages.currentPage": "当前页消息",
"messages.deleted": "已删除",
"messages.outgoing": "发出消息",
"messages.incoming": "收到",
"messages.ownerPeer": "所属 / 对端",
"messages.deleteSelected": "删除指定消息",
"messages.idsPlaceholder": "消息 ID,逗号分隔",
"messages.revoke": "同步撤回",
"messages.previewDelete": "预演删除",
"messages.clearHistory": "清空私聊历史",
"messages.maxIDPlaceholder": "max_id 截止消息",
"messages.maxBatchesPlaceholder": "max_batches 批次数",
"messages.justClear": "仅清本侧",
"messages.previewClearHistory": "预演清历史",
"messages.direction": "方向",
"messages.body": "正文",
"messages.privateDetailTitle": "消息 #{id}",
"messages.detailEyebrow": "消息详情",
"messages.backPrivate": "返回私聊消息",
"messages.backGroup": "返回群聊消息",
"messages.ownerPeerTitle": "所属 {owner} · 对端 {peer}",
"messages.senderSubtitle": "发送方 {sender} · {date}",
"messages.boxID": "消息盒 ID",
"messages.privateMessageID": "私聊消息 ID",
"messages.messageSender": "发送方",
"messages.messageBox": "消息盒",
"messages.dialogRow": "会话行",
"messages.privateRow": "私聊消息行",
"messages.channelMessageRow": "消息行",
"messages.channelRow": "频道行",
"messages.userUpdateEvents": "更新事件",
"messages.channelUpdateEvents": "频道更新事件",
"messages.eventJson": "事件 JSON",
"messages.dispatchOutbox": "分发队列",
"messages.messageBoxesSnapshot": "message_boxes 只读快照",
"messages.dialogSnapshot": "dialogs 只读快照",
"messages.privateSnapshot": "private_messages 只读快照",
"messages.channelMessagesSnapshot": "channel_messages 只读快照",
"messages.channelSnapshot": "channels 只读快照",
"messages.userEventsSource": "durable user_update_events",
"messages.channelEventsSource": "durable channel_update_events",
"messages.outboxSource": "在线/离线 dispatch_outbox",
"messages.attempts": "尝试",
"messages.deleteThis": "删除此消息",
"messages.groupDetailTitle": "群聊消息 #{id}",
"messages.channelGroupTitle": "频道/群 {id}",
"messages.mediaCount": "有媒体",
"messages.channelPosts": "频道帖子",
"messages.channelGroup": "频道 / 群",
"messages.pinned": "置顶",
"messages.channelPost": "频道帖子",
"messages.msgIDsInvalid": "消息 ID 无效",
"auth.device": "设备",
"auth.platform": "平台",
"auth.ip": "IP",
"auth.lastActive": "最近活跃",
"auth.revokeCurrent": "撤销当前",
"auth.keepCurrent": "保留当前",
"auth.revokeAll": "撤销全部设备",
"picker.userPlaceholder": "搜索 user_id / phone / username",
"picker.channelPlaceholder": "搜索 channel_id / username / title",
"picker.verified": "认证",
"picker.regular": "普通",
"action.reasonRequired": "请填写操作原因",
"action.flow": "操作流程",
"action.close": "关闭",
"action.stepReason": "填写原因",
"action.stepDryRun": "预演检查",
"action.stepConfirm": "确认执行",
"action.reason": "操作原因",
"action.reasonPlaceholder": "说明本次操作原因",
"action.requestPreview": "请求预览",
"action.result": "操作结果",
"action.commandID": "命令 ID",
"action.status": "状态",
"action.dryRun": "预演",
"action.runAgain": "重新预演",
"action.runDry": "先预演",
"action.confirm": "确认执行",
"audit.id": "ID",
"audit.commandID": "命令 ID",
"audit.action": "动作",
"audit.actor": "操作者",
"audit.status": "状态",
"audit.dryRun": "预演",
"audit.reason": "原因",
"audit.time": "时间"
}
};
type I18nContextValue = {
lang: Language;
setLang: (lang: Language) => void;
t: TFunction;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Language>(() => initialLanguage());
useEffect(() => {
try {
localStorage.setItem(storageKey, lang);
} catch {
// Language persistence is best-effort.
}
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
document.documentElement.dir = "ltr";
document.documentElement.setAttribute("translate", "no");
document.body.classList.add("notranslate");
document.title = translate(lang, "app.title");
}, [lang]);
const value = useMemo<I18nContextValue>(() => ({
lang,
setLang,
t: (key, params) => translate(lang, key, params)
}), [lang]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const value = useContext(I18nContext);
if (!value) {
throw new Error("useI18n must be used inside I18nProvider");
}
return value;
}
export function LanguageSwitch() {
const { lang, setLang, t } = useI18n();
return (
<div className="language-switch" role="group" aria-label="Language">
{(["en", "zh"] as const).map((item) => (
<button
key={item}
className={lang === item ? "active" : ""}
type="button"
aria-pressed={lang === item}
onClick={() => setLang(item)}
>
{t(`language.${item}`)}
</button>
))}
</div>
);
}
export function translate(lang: Language, key: string, params?: TranslationParams): string {
const value = translations[lang][key] ?? translations.en[key] ?? key;
if (!params) {
return value;
}
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ""));
}
function initialLanguage(): Language {
try {
const requested = normalizeLanguage(new URLSearchParams(window.location.search).get("lang"));
if (requested) {
return requested;
}
} catch {
// URLSearchParams can fail in unusual embedded contexts.
}
try {
const saved = normalizeLanguage(localStorage.getItem(storageKey));
if (saved) {
return saved;
}
} catch {
// Storage is optional.
}
const browserLanguages = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const item of browserLanguages) {
const normalized = normalizeLanguage(item);
if (normalized) {
return normalized;
}
}
return "en";
}
function normalizeLanguage(value: string | null | undefined): Language | null {
if (!value) {
return null;
}
const tag = value.trim().toLowerCase().replace("_", "-");
if (tag === "zh" || tag.startsWith("zh-")) {
return "zh";
}
if (tag === "en" || tag.startsWith("en-")) {
return "en";
}
return null;
}

View file

@ -1,4 +1,5 @@
import type { AccountRow, ChannelRow } from "../types"; import type { AccountRow, ChannelRow } from "../types";
import type { TFunction } from "../i18n";
export function displayPhone(value: string): string { export function displayPhone(value: string): string {
const phone = value.trim(); const phone = value.trim();
@ -16,11 +17,17 @@ export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): st
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-"; return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
} }
export function channelKind(ch: ChannelRow): string { export function channelKind(ch: ChannelRow, t?: TFunction): string {
if (ch.Broadcast && !ch.Megagroup) return "频道"; const translate = t ?? ((key: string) => ({
if (ch.Megagroup && ch.Forum) return "超级群/论坛"; "channel.kind.broadcast": "Channel",
if (ch.Megagroup) return "超级群"; "channel.kind.forum": "Supergroup / Forum",
return "频道/群"; "channel.kind.megagroup": "Supergroup",
"channel.kind.generic": "Channel / Group"
})[key] ?? key);
if (ch.Broadcast && !ch.Megagroup) return translate("channel.kind.broadcast");
if (ch.Megagroup && ch.Forum) return translate("channel.kind.forum");
if (ch.Megagroup) return translate("channel.kind.megagroup");
return translate("channel.kind.generic");
} }
export function formatDate(value: string): string { export function formatDate(value: string): string {
@ -43,14 +50,14 @@ export function toInt(value: string): number {
return Number.isFinite(parsed) ? parsed : 0; return Number.isFinite(parsed) ? parsed : 0;
} }
export function parseIDs(value: string): number[] { export function parseIDs(value: string, invalidMessage = "msg ids invalid"): number[] {
const ids = value const ids = value
.split(/[\s,]+/) .split(/[\s,]+/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean) .filter(Boolean)
.map((item) => Number.parseInt(item, 10)); .map((item) => Number.parseInt(item, 10));
if (ids.length === 0 || ids.some((id) => !Number.isFinite(id) || id <= 0)) { if (ids.length === 0 || ids.some((id) => !Number.isFinite(id) || id <= 0)) {
throw new Error("msg ids invalid"); throw new Error(invalidMessage);
} }
return ids; return ids;
} }

View file

@ -1,10 +1,13 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { I18nProvider } from "./i18n";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <I18nProvider>
<App />
</I18nProvider>
</React.StrictMode> </React.StrictMode>
); );

View file

@ -4,11 +4,13 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable"; import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui"; import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format"; import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountDetail } from "../types"; import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) { export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<AccountDetail | null>(null); const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -34,15 +36,15 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label={busy ? "加载账号详情" : "等待数据"} />; return <LoadingSurface label={busy ? t("account.loadingDetail") : t("account.waitingData")} />;
} }
const account = detail.Account; const account = detail.Account;
return ( return (
<PageFrame <PageFrame
title={`账号 #${account.ID}`} title={t("account.detailTitle", { id: account.ID })}
eyebrow="账号档案" eyebrow={t("account.profile")}
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> 返回列表</button>} actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
> >
<SplitLayout <SplitLayout
main={ main={
@ -50,49 +52,49 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">{displayName(account)}</div> <div className="entity-title">{displayName(account)}</div>
<div className="entity-subtitle">{displayUsername(account.Username) || "无用户名"} · {displayPhone(account.Phone) || "无手机号"}</div> <div className="entity-subtitle">{displayUsername(account.Username) || t("account.noUsername")} · {displayPhone(account.Phone) || t("account.noPhone")}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">会员</Badge> : <Badge>非会员</Badge>} {account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
{detail.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>} {detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
{account.Frozen ? <Badge tone="danger">发消息冻结</Badge> : <Badge>发送正常</Badge>} {account.Frozen ? <Badge tone="danger">{t("account.sendFrozen")}</Badge> : <Badge>{t("account.sendNormal")}</Badge>}
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label="用户 ID" value={String(account.ID)} mono /> <Summary label={t("account.userID")} value={String(account.ID)} mono />
<Summary label="最后在线" value={formatUnix(detail.LastSeenAt) || "-"} /> <Summary label={t("account.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label="会员到期" value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "无"} /> <Summary label={t("account.premiumUntil")} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : t("common.none")} />
<Summary label="更新时间" value={formatDate(account.UpdatedAt) || "-"} /> <Summary label={t("common.updatedAt")} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label="授权设备" value={String(detail.Authorizations.length)} /> <Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} />
<Summary label="账号标记" value={`support=${detail.Support} bot=${detail.Bot}`} /> <Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label="限制状态" value={detail.HasRestriction ? detail.Restriction.Reason || "已限制" : "无"} /> <Summary label={t("account.restriction")} value={detail.HasRestriction ? detail.Restriction.Reason || t("account.restricted") : t("common.none")} />
<Summary label="创建时间" value={formatDate(account.CreatedAt) || "-"} /> <Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} />
</div> </div>
{detail.About && <p className="about-text">{detail.About}</p>} {detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block"> <section className="section-block">
<SectionHead title="授权设备" text={`共 ${detail.Authorizations.length} 个授权`} /> <SectionHead title={t("account.authorizationsTitle")} text={t("account.authorizationsCount", { count: detail.Authorizations.length })} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} /> <AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" /> <SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<AuditTable rows={detail.AuditLogs} /> <AuditTable rows={detail.AuditLogs} />
</section> </section>
</div> </div>
} }
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">账号操作</div> <div className="dock-title">{t("account.actionDock")}</div>
<ActionButton <ActionButton
label={account.Frozen ? "解冻发消息" : "冻结发消息"} label={account.Frozen ? t("account.unfreezeSend") : t("account.freezeSend")}
icon={<CircleAlert size={15} />} icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send" path="/api/actions/freeze-send"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })} payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
onDone={load} onDone={load}
/> />
<label className="duration-field"> <label className="duration-field">
<span>会员时长(月)</span> <span>{t("account.premiumMonths")}</span>
<input <input
aria-label="设置会员时长,单位月" aria-label={t("account.premiumMonthsAria")}
value={months} value={months}
onChange={(event) => setMonths(event.target.value)} onChange={(event) => setMonths(event.target.value)}
type="number" type="number"
@ -102,7 +104,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</label> </label>
<div className="action-stack"> <div className="action-stack">
<ActionButton <ActionButton
label="设置会员" label={t("account.setPremium")}
icon={<Sparkles size={15} />} icon={<Sparkles size={15} />}
tone="warn" tone="warn"
path="/api/actions/grant-premium" path="/api/actions/grant-premium"
@ -110,7 +112,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load} onDone={load}
/> />
<ActionButton <ActionButton
label="取消会员" label={t("account.clearPremium")}
icon={<Sparkles size={15} />} icon={<Sparkles size={15} />}
tone="warn" tone="warn"
path="/api/actions/grant-premium" path="/api/actions/grant-premium"
@ -118,7 +120,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load} onDone={load}
/> />
<ActionButton <ActionButton
label={detail.Verified ? "取消认证" : "设置认证"} label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")}
icon={<BadgeCheck size={15} />} icon={<BadgeCheck size={15} />}
tone="warn" tone="warn"
path="/api/actions/set-verified" path="/api/actions/set-verified"

View file

@ -2,12 +2,14 @@ import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format"; import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics"; import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types"; import type { AccountListResponse } from "../types";
export function AccountsPage({ navigate }: { navigate: Navigate }) { export function AccountsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [limit, setLimit] = useState("50"); const [limit, setLimit] = useState("50");
const [data, setData] = useState<AccountListResponse | null>(null); const [data, setData] = useState<AccountListResponse | null>(null);
@ -47,37 +49,37 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
return ( return (
<PageFrame <PageFrame
title="账号" title={t("account.pageTitle")}
eyebrow={data?.listing === false ? "查询结果" : "最近活跃账号"} eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
actions={ actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}> <button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> 刷新 <RefreshCw size={15} /> {t("common.refresh")}
</button> </button>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label="当前页账号" value={String(data?.rows.length ?? 0)} /> <Metric label={t("account.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label="在线设备记录" value={String(metrics.devices)} /> <Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
<Metric label="会员" value={String(metrics.premium)} tone="good" /> <Metric label={t("account.premium")} value={String(metrics.premium)} tone="good" />
<Metric label="冻结" value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} /> <Metric label={t("account.frozen")} value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
</div> </div>
<QueryPanel> <QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="用户 ID / 手机号 / 用户名" /> <input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} />
</label> </label>
<label className="field-inline"> <label className="field-inline">
<span>条数</span> <span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label> </label>
<button className="btn primary icon-text" type="submit" disabled={busy}> <button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} 查询 {busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button> </button>
{data?.listing && data.has_more && ( {data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> 下一页 <ChevronRight size={15} /> {t("messages.nextPage")}
</button> </button>
)} )}
</form> </form>
@ -86,16 +88,16 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>用户 ID</th> <th>{t("account.userID")}</th>
<th>手机号</th> <th>{t("account.phone")}</th>
<th>用户名</th> <th>{t("common.username")}</th>
<th>姓名</th> <th>{t("common.name")}</th>
<th>设备</th> <th>{t("common.device")}</th>
<th>最近活跃</th> <th>{t("account.lastActive")}</th>
<th>会员</th> <th>{t("account.premium")}</th>
<th>认证</th> <th>{t("common.verified")}</th>
<th>冻结</th> <th>{t("account.frozen")}</th>
<th>更新时间</th> <th>{t("common.updatedAt")}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -108,11 +110,11 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<td>{displayName(row)}</td> <td>{displayName(row)}</td>
<td>{row.DeviceCount}</td> <td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td> <td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">会员 {formatUnix(row.PremiumUntil)}</Badge> : <Badge>无</Badge>}</td> <td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}</td> <td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.Frozen ? <Badge tone="danger">冻结</Badge> : <Badge>正常</Badge>}</td> <td>{row.Frozen ? <Badge tone="danger">{t("account.frozen")}</Badge> : <Badge>{t("common.normal")}</Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td> <td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>详情 <ChevronRight size={14} /></button></td> <td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
</tr> </tr>
))} ))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />} {(!data || data.rows.length === 0) && <EmptyRow colSpan={11} />}

View file

@ -3,11 +3,13 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui"; import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format"; import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types"; import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) { export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<ChannelDetail | null>(null); const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -28,15 +30,15 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label="加载频道详情" />; return <LoadingSurface label={t("channel.loadingDetail")} />;
} }
const ch = detail.Channel; const ch = detail.Channel;
return ( return (
<PageFrame <PageFrame
title={`${channelKind(ch)} #${ch.ID}`} title={`${channelKind(ch, t)} #${ch.ID}`}
eyebrow="频道档案" eyebrow={t("channel.detailProfile")}
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> 返回列表</button>} actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
> >
<SplitLayout <SplitLayout
main={ main={
@ -44,40 +46,40 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">{ch.Title || "-"}</div> <div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || "无用户名"} · 创建者 {ch.CreatorUserID}</div> <div className="entity-subtitle">{displayUsername(ch.Username) || t("account.noUsername")} · {t("channel.creator", { id: ch.CreatorUserID })}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
<Badge>{channelKind(ch)}</Badge> <Badge>{channelKind(ch, t)}</Badge>
{ch.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>} {ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
{ch.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>有效</Badge>} {ch.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>}
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label="频道 ID" value={String(ch.ID)} mono /> <Summary label={t("channel.channelID")} value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono /> <Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label="成员" value={`${ch.ParticipantsCount} / 管理员 ${ch.AdminsCount}`} /> <Summary label={t("common.members")} value={`${ch.ParticipantsCount} / ${t("common.admins")} ${ch.AdminsCount}`} />
<Summary label="治理状态" value={`封禁 ${ch.BannedCount} / 踢出 ${ch.KickedCount}`} /> <Summary label={t("channel.governance")} value={t("channel.governanceValue", { banned: ch.BannedCount, kicked: ch.KickedCount })} />
<Summary label="频道标记" value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} /> <Summary label={t("channel.flags")} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} /> <Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label="创建时间" value={formatUnix(ch.Date) || "-"} /> <Summary label={t("account.createdAt")} value={formatUnix(ch.Date) || "-"} />
<Summary label="更新时间" value={formatDate(ch.UpdatedAt) || "-"} /> <Summary label={t("common.updatedAt")} value={formatDate(ch.UpdatedAt) || "-"} />
</div> </div>
{ch.About && <p className="about-text">{ch.About}</p>} {ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block"> <section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" /> <SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<AuditTable rows={detail.AuditLogs} /> <AuditTable rows={detail.AuditLogs} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title="频道原始行" text="数据库只读快照" /> <SectionHead title={t("channel.rawRow")} text={t("channel.rawRowText")} />
<JsonBlock value={detail.ChannelJSON} /> <JsonBlock value={detail.ChannelJSON} />
</section> </section>
</div> </div>
} }
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">频道操作</div> <div className="dock-title">{t("channel.actionDock")}</div>
<ActionButton <ActionButton
label={ch.Verified ? "取消认证" : "设置认证"} label={ch.Verified ? t("channel.clearVerified") : t("channel.setVerified")}
icon={<BadgeCheck size={15} />} icon={<BadgeCheck size={15} />}
tone="warn" tone="warn"
path="/api/actions/set-channel-verified" path="/api/actions/set-channel-verified"

View file

@ -2,12 +2,14 @@ import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { channelKind, displayUsername, formatDate } from "../lib/format"; import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics"; import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types"; import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) { export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [limit, setLimit] = useState("50"); const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null); const [data, setData] = useState<ChannelListResponse | null>(null);
@ -47,37 +49,37 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
return ( return (
<PageFrame <PageFrame
title="超级群与频道" title={t("channel.pageTitle")}
eyebrow={data?.listing === false ? "查询结果" : "最近更新"} eyebrow={data?.listing === false ? t("account.queryResults") : t("channel.recentUpdated")}
actions={ actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}> <button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> 刷新 <RefreshCw size={15} /> {t("common.refresh")}
</button> </button>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label="当前页实体" value={String(data?.rows.length ?? 0)} /> <Metric label={t("channel.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label="超级群" value={String(metrics.megagroups)} /> <Metric label={t("channel.megagroups")} value={String(metrics.megagroups)} />
<Metric label="频道" value={String(metrics.broadcasts)} /> <Metric label={t("channel.broadcasts")} value={String(metrics.broadcasts)} />
<Metric label="已认证" value={String(metrics.verified)} tone="good" /> <Metric label={t("channel.verifiedCount")} value={String(metrics.verified)} tone="good" />
</div> </div>
<QueryPanel> <QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="频道 ID / 用户名 / 标题" /> <input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("channel.searchPlaceholder")} />
</label> </label>
<label className="field-inline"> <label className="field-inline">
<span>条数</span> <span>{t("common.limit")}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label> </label>
<button className="btn primary icon-text" type="submit" disabled={busy}> <button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} 查询 {busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
</button> </button>
{data?.listing && data.has_more && ( {data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> 下一页 <ChevronRight size={15} /> {t("messages.nextPage")}
</button> </button>
)} )}
</form> </form>
@ -86,15 +88,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>频道 ID</th> <th>{t("channel.channelID")}</th>
<th>类型</th> <th>{t("channel.kind")}</th>
<th>用户名</th> <th>{t("common.username")}</th>
<th>标题</th> <th>{t("channel.title")}</th>
<th>成员</th> <th>{t("common.members")}</th>
<th>管理员</th> <th>{t("common.admins")}</th>
<th>PTS</th> <th>PTS</th>
<th>认证</th> <th>{t("common.verified")}</th>
<th>更新时间</th> <th>{t("common.updatedAt")}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -102,15 +104,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
{data?.rows.map((row) => ( {data?.rows.map((row) => (
<tr key={row.ID}> <tr key={row.ID}>
<td className="mono">{row.ID}</td> <td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td> <td>{channelKind(row, t)}</td>
<td>{displayUsername(row.Username)}</td> <td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td> <td>{row.Title}</td>
<td>{row.ParticipantsCount}</td> <td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td> <td>{row.AdminsCount}</td>
<td>{row.PTS}</td> <td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}</td> <td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td> <td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>详情 <ChevronRight size={14} /></button></td> <td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td>
</tr> </tr>
))} ))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />} {(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}

View file

@ -2,32 +2,34 @@ import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareTe
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink"; import { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui"; import { StatusItem } from "../components/ui";
import { useI18n } from "../i18n";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) { export function Dashboard({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
return ( return (
<div className="dashboard-layout"> <div className="dashboard-layout">
<section className="overview-band"> <section className="overview-band">
<div> <div>
<div className="eyebrow">运行总览</div> <div className="eyebrow">{t("dashboard.eyebrow")}</div>
<h2>控制台总览</h2> <h2>{t("dashboard.title")}</h2>
</div> </div>
<div className="overview-metrics"> <div className="overview-metrics">
<StatusItem label="读路径" value="PG 只读" tone="neutral" /> <StatusItem label={t("dashboard.readPath")} value={t("dashboard.readPathValue")} tone="neutral" />
<StatusItem label="写路径" value="Admin API" tone="good" /> <StatusItem label={t("dashboard.writePath")} value="Admin API" tone="good" />
<StatusItem label="执行策略" value="先预演" tone="warn" /> <StatusItem label={t("dashboard.executionPolicy")} value={t("dashboard.dryRunFirst")} tone="warn" />
</div> </div>
</section> </section>
<div className="command-grid"> <div className="command-grid">
<Launcher icon={<Users />} title="账号管理" text="账号状态、会员、认证、会话。" href="/accounts" navigate={navigate} /> <Launcher icon={<Users />} title={t("route.accounts")} text={t("dashboard.accountsText")} href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title="超级群与频道" text="公开实体、成员计数、认证状态。" href="/channels" navigate={navigate} /> <Launcher icon={<ShieldCheck />} title={t("route.channels")} text={t("dashboard.channelsText")} href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title="消息审计" text="消息盒、update、outbox 状态。" href="/messages" navigate={navigate} /> <Launcher icon={<MessageSquareText />} title={t("route.messages")} text={t("dashboard.messagesText")} href="/messages" navigate={navigate} />
</div> </div>
<section className="work-strip"> <section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span>所有危险操作先预演</span></div> <div className="strip-item"><CheckCircle2 size={16} /><span>{t("dashboard.strip.dryRun")}</span></div>
<div className="strip-item"><KeyRound size={16} /><span>浏览器不持有内部 token</span></div> <div className="strip-item"><KeyRound size={16} /><span>{t("dashboard.strip.token")}</span></div>
<div className="strip-item"><Clock3 size={16} /><span>列表使用游标分页</span></div> <div className="strip-item"><Clock3 size={16} /><span>{t("dashboard.strip.pagination")}</span></div>
<div className="strip-item"><FileJson size={16} /><span>详情页保留原始状态快照</span></div> <div className="strip-item"><FileJson size={16} /><span>{t("dashboard.strip.snapshot")}</span></div>
</section> </section>
</div> </div>
); );

View file

@ -2,11 +2,13 @@ import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui"; import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { formatUnix } from "../lib/format"; import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { GroupMessageDetail } from "../types"; import type { GroupMessageDetail } from "../types";
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) { export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<GroupMessageDetail | null>(null); const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -27,48 +29,48 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label="加载群聊消息详情" />; return <LoadingSurface label={t("common.loading")} />;
} }
const msg = detail.Message; const msg = detail.Message;
return ( return (
<PageFrame <PageFrame
title={`群聊消息 #${msg.ID}`} title={t("messages.groupDetailTitle", { id: msg.ID })}
eyebrow="消息详情" eyebrow={t("messages.detailEyebrow")}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> 返回群聊消息</button>} actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {t("messages.backGroup")}</button>}
> >
<div className="stacked-sections"> <div className="stacked-sections">
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">频道/群 {msg.ChannelID}</div> <div className="entity-title">{t("messages.channelGroupTitle", { id: msg.ChannelID })}</div>
<div className="entity-subtitle">发送方 {msg.SenderUserID} · {formatUnix(msg.Date)}</div> <div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.SenderUserID, date: formatUnix(msg.Date) })}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>存活</Badge>} {msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}
{msg.Pinned && <Badge tone="warn">置顶</Badge>} {msg.Pinned && <Badge tone="warn">{t("messages.pinned")}</Badge>}
{msg.Post && <Badge>频道帖子</Badge>} {msg.Post && <Badge>{t("messages.channelPost")}</Badge>}
<Badge>pts {msg.PTS}</Badge> <Badge>pts {msg.PTS}</Badge>
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label="消息 ID" value={String(msg.ID)} mono /> <Summary label={t("common.messageId")} value={String(msg.ID)} mono />
<Summary label="频道 / 群" value={String(msg.ChannelID)} mono /> <Summary label={t("messages.channelGroup")} value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono /> <Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label="浏览" value={String(msg.ViewsCount)} /> <Summary label={t("common.views")} value={String(msg.ViewsCount)} />
</div> </div>
<section className="section-block"> <section className="section-block">
<SectionHead title="消息行" text="channel_messages 只读快照" /> <SectionHead title={t("messages.channelMessageRow")} text={t("messages.channelMessagesSnapshot")} />
<JsonBlock value={detail.MessageJSON} /> <JsonBlock value={detail.MessageJSON} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title="频道行" text="channels 只读快照" /> <SectionHead title={t("messages.channelRow")} text={t("messages.channelSnapshot")} />
<JsonBlock value={detail.ChannelJSON} /> <JsonBlock value={detail.ChannelJSON} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title="频道更新事件" text="durable channel_update_events" /> <SectionHead title={t("messages.channelUpdateEvents")} text={t("messages.channelEventsSource")} />
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>PTS</th><th>数量</th><th>类型</th><th>消息 ID</th><th>发送方</th><th>时间</th></tr></thead> <thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.messageId")}</th><th>{t("common.sender")}</th><th>{t("common.time")}</th></tr></thead>
<tbody> <tbody>
{detail.UpdateEvents.map((row) => ( {detail.UpdateEvents.map((row) => (
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}> <tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
@ -86,12 +88,12 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
</div> </div>
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title="事件 JSON" /> <SectionHead title={t("messages.eventJson")} />
<div className="raw-grid"> <div className="raw-grid">
{detail.UpdateEvents.map((row) => ( {detail.UpdateEvents.map((row) => (
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} /> <JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
))} ))}
{detail.UpdateEvents.length === 0 && <div className="empty-panel">无结果</div>} {detail.UpdateEvents.length === 0 && <div className="empty-panel">{t("common.noResults")}</div>}
</div> </div>
</section> </section>
</div> </div>

View file

@ -3,11 +3,13 @@ import { useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker"; import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { channelKind, formatUnix } from "../lib/format"; import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types"; import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) { export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [channel, setChannel] = useState<ChannelRow | null>(null); const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState(""); const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState(""); const [beforeID, setBeforeID] = useState("");
@ -18,7 +20,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
async function load(next = false) { async function load(next = false) {
setError(""); setError("");
if (!channel) { if (!channel) {
setError("请先搜索并选择超级群或频道"); setError(t("messages.selectChannel"));
return; return;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -52,38 +54,38 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const rows = data?.rows ?? []; const rows = data?.rows ?? [];
return ( return (
<PageFrame title="群聊消息" eyebrow="超级群 / 频道消息"> <PageFrame title={t("messages.groupTitle")} eyebrow={t("messages.groupEyebrow")}>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<QueryPanel> <QueryPanel>
<div className="message-selector-grid single"> <div className="message-selector-grid single">
<ChannelPicker label="超级群 / 频道" value={channel} onChange={changeChannel} /> <ChannelPicker label={t("messages.channelGroup")} value={channel} onChange={changeChannel} />
</div> </div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <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={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" /> <input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> 查询消息</button> <button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> 下一页</button> : null} {rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null}
</form> </form>
</QueryPanel> </QueryPanel>
<div className="metric-row"> <div className="metric-row">
<Metric label="当前页消息" value={String(rows.length)} /> <Metric label={t("messages.currentPage")} value={String(rows.length)} />
<Metric label="有媒体" value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} /> <Metric label={t("messages.mediaCount")} value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label="频道帖子" value={String(rows.filter((row) => row.Post).length)} /> <Metric label={t("messages.channelPosts")} value={String(rows.filter((row) => row.Post).length)} />
<Metric label="频道 / 群" value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} /> <Metric label={t("messages.channelGroup")} value={channel ? `${channel.Title || channelKind(channel, t)} (${channel.ID})` : "-"} />
</div> </div>
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>消息 ID</th> <th>{t("common.messageId")}</th>
<th>时间</th> <th>{t("common.time")}</th>
<th>发送方</th> <th>{t("common.sender")}</th>
<th>From Peer</th> <th>From Peer</th>
<th>PTS</th> <th>PTS</th>
<th>浏览</th> <th>{t("common.views")}</th>
<th>状态</th> <th>{t("common.status")}</th>
<th>正文</th> <th>{t("messages.body")}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -97,7 +99,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
<td>{row.PTS}</td> <td>{row.PTS}</td>
<td>{row.ViewsCount}</td> <td>{row.ViewsCount}</td>
<td> <td>
{row.Deleted ? <Badge tone="danger">已删除</Badge> : row.Pinned ? <Badge tone="warn">置顶</Badge> : <Badge>存活</Badge>} {row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : row.Pinned ? <Badge tone="warn">{t("messages.pinned")}</Badge> : <Badge>{t("common.survived")}</Badge>}
</td> </td>
<td className="truncate">{row.Body}</td> <td className="truncate">{row.Body}</td>
<td> <td>
@ -105,7 +107,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
className="row-link" className="row-link"
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)} onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
> >
详情 <ChevronRight size={14} /> {t("common.detail")} <ChevronRight size={14} />
</button> </button>
</td> </td>
</tr> </tr>

View file

@ -2,8 +2,10 @@ import type { FormEvent } from "react";
import { useState } from "react"; import { useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert } from "../components/ui"; import { Alert } from "../components/ui";
import { LanguageSwitch, useI18n } from "../i18n";
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) { export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
const { t } = useI18n();
const [secret, setSecret] = useState(""); const [secret, setSecret] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -30,19 +32,22 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
<span className="brand-mark">T</span> <span className="brand-mark">T</span>
<span> <span>
<strong>telesrv</strong> <strong>telesrv</strong>
<small>管理控制台</small> <small>{t("app.adminConsole")}</small>
</span> </span>
</div> </div>
<span className="login-chip">本地访问</span> <div className="login-head-actions">
<LanguageSwitch />
<span className="login-chip">{t("app.localAccess")}</span>
</div>
</div> </div>
<div className="login-copy"> <div className="login-copy">
<h1>运维后台</h1> <h1>{t("login.heading")}</h1>
<p>输入凭据后进入控制台。</p> <p>{t("login.body")}</p>
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}> <form className="form-stack" onSubmit={submit}>
<label> <label>
<span>管理员密码或 token</span> <span>{t("login.secret")}</span>
<input <input
autoFocus autoFocus
type="password" type="password"
@ -52,7 +57,7 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
/> />
</label> </label>
<button className="btn primary full" type="submit" disabled={busy}> <button className="btn primary full" type="submit" disabled={busy}>
{busy ? "登录中" : "登录"} {busy ? t("login.submitting") : t("login.submit")}
</button> </button>
</form> </form>
</section> </section>

View file

@ -3,11 +3,13 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui"; import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate, formatUnix } from "../lib/format"; import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { MessageDetail } from "../types"; import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) { export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<MessageDetail | null>(null); const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -28,55 +30,55 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label="加载消息详情" />; return <LoadingSurface label={t("common.loading")} />;
} }
const msg = detail.Message; const msg = detail.Message;
return ( return (
<PageFrame <PageFrame
title={`消息 #${msg.BoxID}`} title={t("messages.privateDetailTitle", { id: msg.BoxID })}
eyebrow="消息详情" eyebrow={t("messages.detailEyebrow")}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> 返回私聊消息</button>} actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {t("messages.backPrivate")}</button>}
> >
<SplitLayout <SplitLayout
main={ main={
<div className="stacked-sections"> <div className="stacked-sections">
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">所属 {msg.OwnerUserID} · 对端 {msg.PeerID}</div> <div className="entity-title">{t("messages.ownerPeerTitle", { owner: msg.OwnerUserID, peer: msg.PeerID })}</div>
<div className="entity-subtitle">发送方 {msg.FromUserID} · {formatUnix(msg.Date)}</div> <div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.FromUserID, date: formatUnix(msg.Date) })}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>存活</Badge>} {msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}
<Badge>pts {msg.PTS}</Badge> <Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? "发出" : "收到"}</Badge> <Badge>{msg.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</Badge>
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label="消息盒 ID" value={String(msg.BoxID)} mono /> <Summary label={t("messages.boxID")} value={String(msg.BoxID)} mono />
<Summary label="私聊消息 ID" value={String(msg.PrivateMessageID)} mono /> <Summary label={t("messages.privateMessageID")} value={String(msg.PrivateMessageID)} mono />
<Summary label="发送方" value={String(msg.MessageSenderID)} mono /> <Summary label={t("messages.messageSender")} value={String(msg.MessageSenderID)} mono />
<Summary label="时间" value={formatUnix(msg.Date)} /> <Summary label={t("common.time")} value={formatUnix(msg.Date)} />
</div> </div>
<section className="section-block"> <section className="section-block">
<SectionHead title="消息盒" text="message_boxes 只读快照" /> <SectionHead title={t("messages.messageBox")} text={t("messages.messageBoxesSnapshot")} />
<JsonBlock value={detail.MessageJSON} /> <JsonBlock value={detail.MessageJSON} />
</section> </section>
<div className="raw-grid"> <div className="raw-grid">
<section className="section-block"> <section className="section-block">
<SectionHead title="会话行" text="dialogs 只读快照" /> <SectionHead title={t("messages.dialogRow")} text={t("messages.dialogSnapshot")} />
<JsonBlock value={detail.DialogJSON} /> <JsonBlock value={detail.DialogJSON} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title="私聊消息行" text="private_messages 只读快照" /> <SectionHead title={t("messages.privateRow")} text={t("messages.privateSnapshot")} />
<JsonBlock value={detail.PrivateJSON} /> <JsonBlock value={detail.PrivateJSON} />
</section> </section>
</div> </div>
<section className="section-block"> <section className="section-block">
<SectionHead title="更新事件" text="durable user_update_events" /> <SectionHead title={t("messages.userUpdateEvents")} text={t("messages.userEventsSource")} />
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>PTS</th><th>数量</th><th>类型</th><th>时间</th></tr></thead> <thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.time")}</th></tr></thead>
<tbody> <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.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} />} {detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
@ -85,10 +87,10 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
</div> </div>
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title="分发队列" text="在线/离线 dispatch_outbox" /> <SectionHead title={t("messages.dispatchOutbox")} text={t("messages.outboxSource")} />
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>ID</th><th>用户</th><th>PTS</th><th>类型</th><th>状态</th><th>尝试</th><th>更新时间</th></tr></thead> <thead><tr><th>ID</th><th>{t("account.userID")}</th><th>PTS</th><th>{t("common.type")}</th><th>{t("common.status")}</th><th>{t("messages.attempts")}</th><th>{t("common.updatedAt")}</th></tr></thead>
<tbody> <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.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} />} {detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
@ -100,9 +102,9 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
} }
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">消息操作</div> <div className="dock-title">{t("common.operations")}</div>
<ActionButton <ActionButton
label="删除此消息" label={t("messages.deleteThis")}
icon={<Trash2 size={15} />} icon={<Trash2 size={15} />}
path="/api/actions/delete-messages" path="/api/actions/delete-messages"
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })} payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}

View file

@ -4,11 +4,13 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { UserPicker } from "../components/EntityPicker"; import { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format"; import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types"; import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) { export function MessagesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [owner, setOwner] = useState<AccountRow | null>(null); const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null); const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState(""); const [beforeDate, setBeforeDate] = useState("");
@ -25,7 +27,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
async function load(next = false) { async function load(next = false) {
setError(""); setError("");
if (!owner || !peer) { if (!owner || !peer) {
setError("请先搜索并选择所属用户和对端用户"); setError(t("messages.selectPrivatePeers"));
return; return;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -65,46 +67,46 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
} }
return ( return (
<PageFrame title="私聊消息" eyebrow="私聊消息盒"> <PageFrame title={t("messages.privateTitle")} eyebrow={t("messages.privateEyebrow")}>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<QueryPanel> <QueryPanel>
<div className="message-selector-grid"> <div className="message-selector-grid">
<UserPicker label="所属用户" value={owner} onChange={changeOwner} /> <UserPicker label={t("messages.ownerUser")} value={owner} onChange={changeOwner} />
<UserPicker label="对端用户" value={peer} onChange={changePeer} /> <UserPicker label={t("messages.peerUser")} value={peer} onChange={changePeer} />
</div> </div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <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={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" /> <input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> 查询消息</button> <button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> 下一页</button> : null} {data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null}
</form> </form>
</QueryPanel> </QueryPanel>
<div className="metric-row"> <div className="metric-row">
<Metric label="当前页消息" value={String(data?.rows.length ?? 0)} /> <Metric label={t("messages.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label="已删除" value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" /> <Metric label={t("messages.deleted")} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label="发出消息" value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} /> <Metric label={t("messages.outgoing")} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label="所属 / 对端" value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} /> <Metric label={t("messages.ownerPeer")} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div> </div>
<div className="operation-row"> <div className="operation-row">
<div className="operation-box"> <div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> 删除指定消息</div> <div className="operation-title"><Trash2 size={15} /> {t("messages.deleteSelected")}</div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder="消息 ID,逗号分隔" /> <input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder={t("messages.idsPlaceholder")} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> 同步撤回</label> <label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label>
<ActionButton path="/api/actions/delete-messages" label="预演删除" payload={() => ({ <ActionButton path="/api/actions/delete-messages" label={t("messages.previewDelete")} payload={() => ({
owner_user_id: owner?.ID ?? 0, owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0, peer_id: peer?.ID ?? 0,
ids: parseIDs(ids), ids: parseIDs(ids, t("messages.msgIDsInvalid")),
revoke revoke
})} /> })} />
</div> </div>
<div className="operation-box"> <div className="operation-box">
<div className="operation-title"><History size={15} /> 清空私聊历史</div> <div className="operation-title"><History size={15} /> {t("messages.clearHistory")}</div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder="max_id 截止消息" /> <input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={t("messages.maxIDPlaceholder")} />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder="max_batches 批次数" /> <input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={t("messages.maxBatchesPlaceholder")} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> 同步撤回</label> <label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> 仅清本侧</label> <label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {t("messages.justClear")}</label>
<ActionButton path="/api/actions/delete-history" label="预演清历史" payload={() => ({ <ActionButton path="/api/actions/delete-history" label={t("messages.previewClearHistory")} payload={() => ({
owner_user_id: owner?.ID ?? 0, owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0, peer_id: peer?.ID ?? 0,
max_id: toInt(maxID), max_id: toInt(maxID),
@ -118,13 +120,13 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>消息 ID</th> <th>{t("common.messageId")}</th>
<th>时间</th> <th>{t("common.time")}</th>
<th>发送方</th> <th>{t("common.sender")}</th>
<th>方向</th> <th>{t("messages.direction")}</th>
<th>PTS</th> <th>PTS</th>
<th>状态</th> <th>{t("common.status")}</th>
<th>正文</th> <th>{t("messages.body")}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -134,16 +136,16 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<td className="mono">{row.BoxID}</td> <td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</td> <td>{formatUnix(row.Date)}</td>
<td className="mono">{row.FromUserID}</td> <td className="mono">{row.FromUserID}</td>
<td>{row.Outgoing ? "发出" : "收到"}</td> <td>{row.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</td>
<td>{row.PTS}</td> <td>{row.PTS}</td>
<td>{row.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>存活</Badge>}</td> <td>{row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}</td>
<td className="truncate">{row.Body}</td> <td className="truncate">{row.Body}</td>
<td> <td>
<button <button
className="row-link" className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)} onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
> >
详情 <ChevronRight size={14} /> {t("common.detail")} <ChevronRight size={14} />
</button> </button>
</td> </td>
</tr> </tr>

View file

@ -1,3 +1,5 @@
import type { TFunction } from "./i18n";
export type Navigate = (href: string) => void; export type Navigate = (href: string) => void;
export type RouteState = { export type RouteState = {
@ -14,16 +16,16 @@ export function currentRoute(): RouteState {
}; };
} }
export function routeTitle(pathname: string): string { export function routeTitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return "账号管理"; if (pathname.startsWith("/accounts")) return t("route.accounts");
if (pathname.startsWith("/channels")) return "超级群与频道"; if (pathname.startsWith("/channels")) return t("route.channels");
if (pathname.startsWith("/messages")) return "消息审计"; if (pathname.startsWith("/messages")) return t("route.messages");
return "运维控制台"; return t("route.dashboard");
} }
export function routeSubtitle(pathname: string): string { export function routeSubtitle(pathname: string, t: TFunction): string {
if (pathname.startsWith("/accounts")) return "控制台 / 账号"; if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
if (pathname.startsWith("/channels")) return "控制台 / 频道"; if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
if (pathname.startsWith("/messages")) return "控制台 / 消息"; if (pathname.startsWith("/messages")) return t("route.messagesSubtitle");
return "控制台 / 总览"; return t("route.dashboardSubtitle");
} }

View file

@ -260,6 +260,38 @@ a {
gap: 8px; gap: 8px;
} }
.language-switch {
display: inline-flex;
min-height: 30px;
align-items: center;
padding: 2px;
background: var(--panel-subtle);
border: 1px solid var(--line);
border-radius: 999px;
}
.language-switch button {
min-width: 42px;
min-height: 24px;
padding: 0 9px;
color: var(--muted);
background: transparent;
border: 0;
border-radius: 999px;
cursor: pointer;
font-weight: 800;
}
.language-switch button.active {
color: #ffffff;
background: var(--brand);
}
.language-switch button:focus-visible {
outline: 2px solid var(--brand);
outline-offset: 2px;
}
.actor-pill { .actor-pill {
display: inline-flex; display: inline-flex;
min-height: 30px; min-height: 30px;

View file

@ -180,6 +180,14 @@
gap: 12px; gap: 12px;
} }
.login-head-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.login-chip { .login-chip {
display: inline-flex; display: inline-flex;
min-height: 24px; min-height: 24px;