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

View file

@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<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 { createPortal } from "react-dom";
import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui";
@ -25,6 +26,7 @@ export function ActionButton({
tone?: ActionTone;
onDone?: () => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null);
@ -39,7 +41,7 @@ export function ActionButton({
async function run(confirm: boolean) {
if (!reason.trim()) {
setError("请填写操作原因");
setError(t("action.reasonRequired"));
return;
}
setBusy(true);
@ -86,55 +88,55 @@ export function ActionButton({
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
<div className="modal-head">
<div>
<div className="eyebrow">操作流程</div>
<div className="eyebrow">{t("action.flow")}</div>
<h2>{label}</h2>
</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 className="command-body">
<div className="command-steps">
<div className={`command-step ${reason.trim() ? "done" : "active"}`}>
<span>1</span><strong>填写原因</strong>
<span>1</span><strong>{t("action.stepReason")}</strong>
</div>
<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 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>
<label className="form-field">
<span>操作原因</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder="说明本次操作原因" />
<span>{t("action.reason")}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={t("action.reasonPlaceholder")} />
</label>
<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)} />
</div>
{error && <Alert>{error}</Alert>}
{result && (
<div className="result-box">
<div className="result-title">
<div className="result-title">
{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 className="result-line"><span>命令 ID</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>状态</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.commandID")}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</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>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div>
)}
</div>
<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}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? "重新预演" : "先预演"}
{result ? t("action.runAgain") : t("action.runDry")}
</button>
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<CheckCircle2 size={15} />
确认执行
{t("action.confirm")}
</button>
</div>
</section>

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format";
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[] }) {
const { t } = useI18n();
return (
<div className="table-wrap">
<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>
{rows.map((row) => (
<tr key={row.ID}>
@ -103,7 +105,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
<td>{row.Action}</td>
<td>{row.Actor}</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>{formatDate(row.CreatedAt)}</td>
</tr>
@ -116,7 +118,8 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
}
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 }) {

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

View file

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

View file

@ -4,11 +4,13 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
@ -34,15 +36,15 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={busy ? "加载账号详情" : "等待数据"} />;
return <LoadingSurface label={busy ? t("account.loadingDetail") : t("account.waitingData")} />;
}
const account = detail.Account;
return (
<PageFrame
title={`账号 #${account.ID}`}
eyebrow="账号档案"
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> 返回列表</button>}
title={t("account.detailTitle", { id: account.ID })}
eyebrow={t("account.profile")}
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
>
<SplitLayout
main={
@ -50,49 +52,49 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head">
<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 className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">会员</Badge> : <Badge>非会员</Badge>}
{detail.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}
{account.Frozen ? <Badge tone="danger">发消息冻结</Badge> : <Badge>发送正常</Badge>}
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
{account.Frozen ? <Badge tone="danger">{t("account.sendFrozen")}</Badge> : <Badge>{t("account.sendNormal")}</Badge>}
</div>
</section>
<div className="summary-grid">
<Summary label="用户 ID" value={String(account.ID)} mono />
<Summary label="最后在线" value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label="会员到期" value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "无"} />
<Summary label="更新时间" value={formatDate(account.UpdatedAt) || "-"} />
<Summary label="授权设备" value={String(detail.Authorizations.length)} />
<Summary label="账号标记" value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label="限制状态" value={detail.HasRestriction ? detail.Restriction.Reason || "已限制" : "无"} />
<Summary label="创建时间" value={formatDate(account.CreatedAt) || "-"} />
<Summary label={t("account.userID")} value={String(account.ID)} mono />
<Summary label={t("account.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label={t("account.premiumUntil")} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : t("common.none")} />
<Summary label={t("common.updatedAt")} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} />
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label={t("account.restriction")} value={detail.HasRestriction ? detail.Restriction.Reason || t("account.restricted") : t("common.none")} />
<Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} />
</div>
{detail.About && <p className="about-text">{detail.About}</p>}
<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} />
</section>
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<AuditTable rows={detail.AuditLogs} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">账号操作</div>
<div className="dock-title">{t("account.actionDock")}</div>
<ActionButton
label={account.Frozen ? "解冻发消息" : "冻结发消息"}
label={account.Frozen ? t("account.unfreezeSend") : t("account.freezeSend")}
icon={<CircleAlert size={15} />}
path="/api/actions/freeze-send"
payload={() => ({ user_id: account.ID, frozen: !account.Frozen })}
onDone={load}
/>
<label className="duration-field">
<span>会员时长(月)</span>
<span>{t("account.premiumMonths")}</span>
<input
aria-label="设置会员时长,单位月"
aria-label={t("account.premiumMonthsAria")}
value={months}
onChange={(event) => setMonths(event.target.value)}
type="number"
@ -102,7 +104,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</label>
<div className="action-stack">
<ActionButton
label="设置会员"
label={t("account.setPremium")}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
@ -110,7 +112,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
<ActionButton
label="取消会员"
label={t("account.clearPremium")}
icon={<Sparkles size={15} />}
tone="warn"
path="/api/actions/grant-premium"
@ -118,7 +120,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load}
/>
<ActionButton
label={detail.Verified ? "取消认证" : "设置认证"}
label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")}
icon={<BadgeCheck size={15} />}
tone="warn"
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 { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { AccountListResponse } from "../types";
export function AccountsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<AccountListResponse | null>(null);
@ -47,37 +49,37 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
return (
<PageFrame
title="账号"
eyebrow={data?.listing === false ? "查询结果" : "最近活跃账号"}
title={t("account.pageTitle")}
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> 刷新
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页账号" value={String(data?.rows.length ?? 0)} />
<Metric label="在线设备记录" value={String(metrics.devices)} />
<Metric label="会员" value={String(metrics.premium)} tone="good" />
<Metric label="冻结" value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
<Metric label={t("account.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} />
<Metric label={t("account.premium")} value={String(metrics.premium)} tone="good" />
<Metric label={t("account.frozen")} value={String(metrics.frozen)} tone={metrics.frozen > 0 ? "danger" : "neutral"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="用户 ID / 手机号 / 用户名" />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} />
</label>
<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" />
</label>
<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>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> 下一页
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
</form>
@ -86,16 +88,16 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th>用户 ID</th>
<th>手机号</th>
<th>用户名</th>
<th>姓名</th>
<th>设备</th>
<th>最近活跃</th>
<th>会员</th>
<th>认证</th>
<th>冻结</th>
<th>更新时间</th>
<th>{t("account.userID")}</th>
<th>{t("account.phone")}</th>
<th>{t("common.username")}</th>
<th>{t("common.name")}</th>
<th>{t("common.device")}</th>
<th>{t("account.lastActive")}</th>
<th>{t("account.premium")}</th>
<th>{t("common.verified")}</th>
<th>{t("account.frozen")}</th>
<th>{t("common.updatedAt")}</th>
<th></th>
</tr>
</thead>
@ -108,11 +110,11 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<td>{displayName(row)}</td>
<td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">会员 {formatUnix(row.PremiumUntil)}</Badge> : <Badge>无</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}</td>
<td>{row.Frozen ? <Badge tone="danger">冻结</Badge> : <Badge>正常</Badge>}</td>
<td>{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">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</td>
<td>{row.Frozen ? <Badge tone="danger">{t("account.frozen")}</Badge> : <Badge>{t("common.normal")}</Badge>}</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>
))}
{(!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 { ActionButton } from "../components/ActionButton";
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 type { Navigate } from "../routing";
import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState("");
@ -28,15 +30,15 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载频道详情" />;
return <LoadingSurface label={t("channel.loadingDetail")} />;
}
const ch = detail.Channel;
return (
<PageFrame
title={`${channelKind(ch)} #${ch.ID}`}
eyebrow="频道档案"
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> 返回列表</button>}
title={`${channelKind(ch, t)} #${ch.ID}`}
eyebrow={t("channel.detailProfile")}
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {t("common.backToList")}</button>}
>
<SplitLayout
main={
@ -44,40 +46,40 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head">
<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 className="entity-badges">
<Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}
{ch.Deleted ? <Badge tone="danger">已删除</Badge> : <Badge>有效</Badge>}
<Badge>{channelKind(ch, t)}</Badge>
{ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}
{ch.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>}
</div>
</section>
<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="成员" value={`${ch.ParticipantsCount} / 管理员 ${ch.AdminsCount}`} />
<Summary label="治理状态" value={`封禁 ${ch.BannedCount} / 踢出 ${ch.KickedCount}`} />
<Summary label="频道标记" value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label={t("common.members")} value={`${ch.ParticipantsCount} / ${t("common.admins")} ${ch.AdminsCount}`} />
<Summary label={t("channel.governance")} value={t("channel.governanceValue", { banned: ch.BannedCount, kicked: ch.KickedCount })} />
<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="创建时间" value={formatUnix(ch.Date) || "-"} />
<Summary label="更新时间" value={formatDate(ch.UpdatedAt) || "-"} />
<Summary label={t("account.createdAt")} value={formatUnix(ch.Date) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(ch.UpdatedAt) || "-"} />
</div>
{ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block">
<SectionHead title="最近后台操作" text="最近 30 条审计" />
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} />
<AuditTable rows={detail.AuditLogs} />
</section>
<section className="section-block">
<SectionHead title="频道原始行" text="数据库只读快照" />
<SectionHead title={t("channel.rawRow")} text={t("channel.rawRowText")} />
<JsonBlock value={detail.ChannelJSON} />
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title">频道操作</div>
<div className="dock-title">{t("channel.actionDock")}</div>
<ActionButton
label={ch.Verified ? "取消认证" : "设置认证"}
label={ch.Verified ? t("channel.clearVerified") : t("channel.setVerified")}
icon={<BadgeCheck size={15} />}
tone="warn"
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 { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState("");
const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null);
@ -47,37 +49,37 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
return (
<PageFrame
title="超级群与频道"
eyebrow={data?.listing === false ? "查询结果" : "最近更新"}
title={t("channel.pageTitle")}
eyebrow={data?.listing === false ? t("account.queryResults") : t("channel.recentUpdated")}
actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> 刷新
<RefreshCw size={15} /> {t("common.refresh")}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label="当前页实体" value={String(data?.rows.length ?? 0)} />
<Metric label="超级群" value={String(metrics.megagroups)} />
<Metric label="频道" value={String(metrics.broadcasts)} />
<Metric label="已认证" value={String(metrics.verified)} tone="good" />
<Metric label={t("channel.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label={t("channel.megagroups")} value={String(metrics.megagroups)} />
<Metric label={t("channel.broadcasts")} value={String(metrics.broadcasts)} />
<Metric label={t("channel.verifiedCount")} value={String(metrics.verified)} tone="good" />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox">
<Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder="频道 ID / 用户名 / 标题" />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("channel.searchPlaceholder")} />
</label>
<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" />
</label>
<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>
{data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> 下一页
<ChevronRight size={15} /> {t("messages.nextPage")}
</button>
)}
</form>
@ -86,15 +88,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th>频道 ID</th>
<th>类型</th>
<th>用户名</th>
<th>标题</th>
<th>成员</th>
<th>管理员</th>
<th>{t("channel.channelID")}</th>
<th>{t("channel.kind")}</th>
<th>{t("common.username")}</th>
<th>{t("channel.title")}</th>
<th>{t("common.members")}</th>
<th>{t("common.admins")}</th>
<th>PTS</th>
<th>认证</th>
<th>更新时间</th>
<th>{t("common.verified")}</th>
<th>{t("common.updatedAt")}</th>
<th></th>
</tr>
</thead>
@ -102,15 +104,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
{data?.rows.map((row) => (
<tr key={row.ID}>
<td className="mono">{row.ID}</td>
<td>{channelKind(row)}</td>
<td>{channelKind(row, t)}</td>
<td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td>
<td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td>
<td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good">已认证</Badge> : <Badge>未认证</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>}</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>
))}
{(!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 { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui";
import { useI18n } from "../i18n";
import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
return (
<div className="dashboard-layout">
<section className="overview-band">
<div>
<div className="eyebrow">运行总览</div>
<h2>控制台总览</h2>
<div className="eyebrow">{t("dashboard.eyebrow")}</div>
<h2>{t("dashboard.title")}</h2>
</div>
<div className="overview-metrics">
<StatusItem label="读路径" value="PG 只读" tone="neutral" />
<StatusItem label="写路径" value="Admin API" tone="good" />
<StatusItem label="执行策略" value="先预演" tone="warn" />
<StatusItem label={t("dashboard.readPath")} value={t("dashboard.readPathValue")} tone="neutral" />
<StatusItem label={t("dashboard.writePath")} value="Admin API" tone="good" />
<StatusItem label={t("dashboard.executionPolicy")} value={t("dashboard.dryRunFirst")} tone="warn" />
</div>
</section>
<div className="command-grid">
<Launcher icon={<Users />} title="账号管理" text="账号状态、会员、认证、会话。" href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title="超级群与频道" text="公开实体、成员计数、认证状态。" href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title="消息审计" text="消息盒、update、outbox 状态。" href="/messages" navigate={navigate} />
<Launcher icon={<Users />} title={t("route.accounts")} text={t("dashboard.accountsText")} href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title={t("route.channels")} text={t("dashboard.channelsText")} href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title={t("route.messages")} text={t("dashboard.messagesText")} href="/messages" navigate={navigate} />
</div>
<section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span>所有危险操作先预演</span></div>
<div className="strip-item"><KeyRound size={16} /><span>浏览器不持有内部 token</span></div>
<div className="strip-item"><Clock3 size={16} /><span>列表使用游标分页</span></div>
<div className="strip-item"><FileJson size={16} /><span>详情页保留原始状态快照</span></div>
<div className="strip-item"><CheckCircle2 size={16} /><span>{t("dashboard.strip.dryRun")}</span></div>
<div className="strip-item"><KeyRound size={16} /><span>{t("dashboard.strip.token")}</span></div>
<div className="strip-item"><Clock3 size={16} /><span>{t("dashboard.strip.pagination")}</span></div>
<div className="strip-item"><FileJson size={16} /><span>{t("dashboard.strip.snapshot")}</span></div>
</section>
</div>
);

View file

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

View file

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

View file

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

View file

@ -3,11 +3,13 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing";
import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState("");
@ -28,55 +30,55 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label="加载消息详情" />;
return <LoadingSurface label={t("common.loading")} />;
}
const msg = detail.Message;
return (
<PageFrame
title={`消息 #${msg.BoxID}`}
eyebrow="消息详情"
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> 返回私聊消息</button>}
title={t("messages.privateDetailTitle", { id: msg.BoxID })}
eyebrow={t("messages.detailEyebrow")}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {t("messages.backPrivate")}</button>}
>
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">所属 {msg.OwnerUserID} · 对端 {msg.PeerID}</div>
<div className="entity-subtitle">发送方 {msg.FromUserID} · {formatUnix(msg.Date)}</div>
<div className="entity-title">{t("messages.ownerPeerTitle", { owner: msg.OwnerUserID, peer: msg.PeerID })}</div>
<div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.FromUserID, date: formatUnix(msg.Date) })}</div>
</div>
<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>{msg.Outgoing ? "发出" : "收到"}</Badge>
<Badge>{msg.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</Badge>
</div>
</section>
<div className="summary-grid">
<Summary label="消息盒 ID" value={String(msg.BoxID)} mono />
<Summary label="私聊消息 ID" value={String(msg.PrivateMessageID)} mono />
<Summary label="发送方" value={String(msg.MessageSenderID)} mono />
<Summary label="时间" value={formatUnix(msg.Date)} />
<Summary label={t("messages.boxID")} value={String(msg.BoxID)} mono />
<Summary label={t("messages.privateMessageID")} value={String(msg.PrivateMessageID)} mono />
<Summary label={t("messages.messageSender")} value={String(msg.MessageSenderID)} mono />
<Summary label={t("common.time")} value={formatUnix(msg.Date)} />
</div>
<section className="section-block">
<SectionHead title="消息盒" text="message_boxes 只读快照" />
<SectionHead title={t("messages.messageBox")} text={t("messages.messageBoxesSnapshot")} />
<JsonBlock value={detail.MessageJSON} />
</section>
<div className="raw-grid">
<section className="section-block">
<SectionHead title="会话行" text="dialogs 只读快照" />
<SectionHead title={t("messages.dialogRow")} text={t("messages.dialogSnapshot")} />
<JsonBlock value={detail.DialogJSON} />
</section>
<section className="section-block">
<SectionHead title="私聊消息行" text="private_messages 只读快照" />
<SectionHead title={t("messages.privateRow")} text={t("messages.privateSnapshot")} />
<JsonBlock value={detail.PrivateJSON} />
</section>
</div>
<section className="section-block">
<SectionHead title="更新事件" text="durable user_update_events" />
<SectionHead title={t("messages.userUpdateEvents")} text={t("messages.userEventsSource")} />
<div className="table-wrap">
<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>
{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} />}
@ -85,10 +87,10 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
</div>
</section>
<section className="section-block">
<SectionHead title="分发队列" text="在线/离线 dispatch_outbox" />
<SectionHead title={t("messages.dispatchOutbox")} text={t("messages.outboxSource")} />
<div className="table-wrap">
<table className="data-table">
<thead><tr><th>ID</th><th>用户</th><th>PTS</th><th>类型</th><th>状态</th><th>尝试</th><th>更新时间</th></tr></thead>
<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>
{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} />}
@ -100,9 +102,9 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
}
side={
<section className="action-dock">
<div className="dock-title">消息操作</div>
<div className="dock-title">{t("common.operations")}</div>
<ActionButton
label="删除此消息"
label={t("messages.deleteThis")}
icon={<Trash2 size={15} />}
path="/api/actions/delete-messages"
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 { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState("");
@ -25,7 +27,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
async function load(next = false) {
setError("");
if (!owner || !peer) {
setError("请先搜索并选择所属用户和对端用户");
setError(t("messages.selectPrivatePeers"));
return;
}
const params = new URLSearchParams({
@ -65,46 +67,46 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
}
return (
<PageFrame title="私聊消息" eyebrow="私聊消息盒">
<PageFrame title={t("messages.privateTitle")} eyebrow={t("messages.privateEyebrow")}>
{error && <Alert>{error}</Alert>}
<QueryPanel>
<div className="message-selector-grid">
<UserPicker label="所属用户" value={owner} onChange={changeOwner} />
<UserPicker label="对端用户" value={peer} onChange={changePeer} />
<UserPicker label={t("messages.ownerUser")} value={owner} onChange={changeOwner} />
<UserPicker label={t("messages.peerUser")} value={peer} onChange={changePeer} />
</div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder="before_date 游标" />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder="before_msg_id 游标" />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder="条数 <= 100" />
<button className="btn primary icon-text" type="submit"><Search size={15} /> 查询消息</button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> 下一页</button> : null}
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} />
<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={t("messages.limitPlaceholder")} />
<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} /> {t("messages.nextPage")}</button> : null}
</form>
</QueryPanel>
<div className="metric-row">
<Metric label="当前页消息" value={String(data?.rows.length ?? 0)} />
<Metric label="已删除" value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label="发出消息" value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label="所属 / 对端" value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
<Metric label={t("messages.currentPage")} value={String(data?.rows.length ?? 0)} />
<Metric label={t("messages.deleted")} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label={t("messages.outgoing")} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label={t("messages.ownerPeer")} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div>
<div className="operation-row">
<div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> 删除指定消息</div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder="消息 ID,逗号分隔" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> 同步撤回</label>
<ActionButton path="/api/actions/delete-messages" label="预演删除" payload={() => ({
<div className="operation-title"><Trash2 size={15} /> {t("messages.deleteSelected")}</div>
<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)} /> {t("messages.revoke")}</label>
<ActionButton path="/api/actions/delete-messages" label={t("messages.previewDelete")} payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
ids: parseIDs(ids),
ids: parseIDs(ids, t("messages.msgIDsInvalid")),
revoke
})} />
</div>
<div className="operation-box">
<div className="operation-title"><History size={15} /> 清空私聊历史</div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder="max_id 截止消息" />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder="max_batches 批次数" />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> 同步撤回</label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> 仅清本侧</label>
<ActionButton path="/api/actions/delete-history" label="预演清历史" payload={() => ({
<div className="operation-title"><History size={15} /> {t("messages.clearHistory")}</div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={t("messages.maxIDPlaceholder")} />
<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)} /> {t("messages.revoke")}</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={t("messages.previewClearHistory")} payload={() => ({
owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0,
max_id: toInt(maxID),
@ -118,13 +120,13 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<table className="data-table">
<thead>
<tr>
<th>消息 ID</th>
<th>时间</th>
<th>发送方</th>
<th>方向</th>
<th>{t("common.messageId")}</th>
<th>{t("common.time")}</th>
<th>{t("common.sender")}</th>
<th>{t("messages.direction")}</th>
<th>PTS</th>
<th>状态</th>
<th>正文</th>
<th>{t("common.status")}</th>
<th>{t("messages.body")}</th>
<th></th>
</tr>
</thead>
@ -134,16 +136,16 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</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.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>
<button
className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
>
详情 <ChevronRight size={14} />
{t("common.detail")} <ChevronRight size={14} />
</button>
</td>
</tr>

View file

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

View file

@ -260,6 +260,38 @@ a {
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 {
display: inline-flex;
min-height: 30px;

View file

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