admin-ui: sync English localization

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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