admin-ui: sync English localization
This commit is contained in:
parent
4570df5939
commit
44e8cde27f
27 changed files with 1016 additions and 290 deletions
8
cmd/telesrv-admin/web/dist/assets/index-DLK0oYaN.js
vendored
Normal file
8
cmd/telesrv-admin/web/dist/assets/index-DLK0oYaN.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-W1-UPxwc.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-W1-UPxwc.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
cmd/telesrv-admin/web/dist/index.html
vendored
6
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
637
cmd/telesrv-admin/web/src/i18n.tsx
Normal file
637
cmd/telesrv-admin/web/src/i18n.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue