Refactor web UI: feature-first structure, tokenized styles, modern blue theme.

Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
  333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
  types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
  (2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.

Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
  pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
  off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
  unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
  without tree indent, soft selected state instead of left accent bar.

Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-12 03:17:51 +08:00
parent 3b45d6abe3
commit 0d894efe5e
196 changed files with 26947 additions and 28091 deletions

View file

@ -0,0 +1,169 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { HelpHint } from "../../components/HelpHint";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { apiDelete, apiGet, apiPost } from "../../services/api";
import { formatSystemTime } from "../../utils/time";
type SessionRow = {
id: string;
created_at: string | null;
expires_at: string | null;
refresh_expires_at: string | null;
last_seen_at: string | null;
client_ip: string;
user_agent: string;
current: boolean;
};
/** Short browser/OS label so same NAT IP can still be told apart. */
function summarizeUa(ua: string): string {
const s = String(ua || "").trim();
if (!s) return "—";
const browser = /Edg\//.test(s)
? "Edge"
: /Chrome\//.test(s)
? "Chrome"
: /Firefox\//.test(s)
? "Firefox"
: /Safari\//.test(s) && !/Chrome\//.test(s)
? "Safari"
: /curl\//i.test(s)
? "curl"
: /python/i.test(s)
? "python"
: "Client";
const os = /Windows NT/i.test(s)
? "Windows"
: /Android/i.test(s)
? "Android"
: /iPhone|iPad/i.test(s)
? "iOS"
: /Mac OS X|Macintosh/i.test(s)
? "macOS"
: /Linux/i.test(s)
? "Linux"
: "";
return os ? `${browser} · ${os}` : browser;
}
export function SessionsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const qc = useQueryClient();
const sessionsQuery = useQuery({
queryKey: ["auth-sessions"],
queryFn: () => apiGet<{ items: SessionRow[]; total: number }>("/v1/auth/sessions"),
});
const revokeMut = useMutation({
mutationFn: (id: string) => apiDelete(`/v1/auth/sessions/${encodeURIComponent(id)}`),
onSuccess: () => {
showOk(t("auth.sessionRevoked"));
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
},
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
});
const revokeOthersMut = useMutation({
mutationFn: () => apiPost<{ revoked: number }>("/v1/auth/sessions/revoke-others", {}),
onSuccess: (data) => {
showOk(t("auth.sessionsRevokedOthers", { count: data.revoked ?? 0 }));
void qc.invalidateQueries({ queryKey: ["auth-sessions"] });
},
onError: (err) => showError(String(err instanceof Error ? err.message : err)),
});
const items = sessionsQuery.data?.items || [];
return (
<div className="page-stack system-page">
<section className="panel">
<div className="panel__toolbar">
<h2 className="card-title-with-hint">
{t("auth.sessionsTitle")}
<HelpHint text={t("auth.sessionsHelp")} ariaLabel={t("common.help")} />
</h2>
</div>
<div className="pt-list">
<div className="filter-inline">
<Button
size="sm"
variant="secondary"
isDisabled={revokeOthersMut.isPending || items.filter((s) => !s.current).length === 0}
onPress={() => revokeOthersMut.mutate()}
>
{t("auth.revokeOtherSessions")}
</Button>
<Button
size="sm"
variant="secondary"
onPress={() => void sessionsQuery.refetch()}
isDisabled={sessionsQuery.isFetching}
>
{t("common.refresh")}
</Button>
</div>
{sessionsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{!items.length && !sessionsQuery.isLoading ? (
<div className="pt-list-empty">
<p className="muted">{t("auth.sessionsEmpty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("auth.colSession")}</th>
<th>{t("auth.colIp")}</th>
<th>{t("auth.colClient")}</th>
<th>{t("auth.colLastSeen")}</th>
<th>{t("auth.colCreated")}</th>
<th>{t("auth.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={row.id}>
<td>
<code title={row.id}>{row.id.slice(0, 10)}…</code>
{row.current ? (
<span className="pt-list-status pt-list-status--ok" style={{ marginLeft: 8 }}>
{t("auth.sessionCurrent")}
</span>
) : null}
</td>
<td>
<code>{row.client_ip || "—"}</code>
</td>
<td title={row.user_agent || undefined}>{summarizeUa(row.user_agent)}</td>
<td>{row.last_seen_at ? formatSystemTime(row.last_seen_at) : "—"}</td>
<td>{row.created_at ? formatSystemTime(row.created_at) : "—"}</td>
<td>
<Button
size="sm"
variant="secondary"
isDisabled={revokeMut.isPending}
onPress={() => {
if (row.current && !window.confirm(t("auth.revokeCurrentConfirm"))) return;
revokeMut.mutate(row.id);
}}
>
{t("auth.revokeSession")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
</div>
);
}