diff --git a/CHANGELOG.md b/CHANGELOG.md
index 312564b..699077d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,15 @@
## Unreleased
+- 控制台:导航「系统」改为「设置」(`/settings`,`/deploy` 重定向);2FA 文案与页面实际能力一致;修改密码展示至少 8 位规则
+- 控制台:节点、服务、流量、审计空状态统一为图标 + 一句说明 + 可选操作;流量图无数据时居中「暂无流量」
+- 控制台:审计加载中不再闪「没有匹配的记录」;空库与筛选无结果分开;流量/审计筛选空状态可一键清除
+- 控制台:改为公开访问需二次确认,取消则保持原访问方式
+- Console: nav “System” is now “Settings” (`/settings`, `/deploy` redirects); 2FA copy matches what the page can do; password change shows the 8-character rule
+- Console: nodes, services, traffic, and audit share one empty-state pattern; empty charts show a centered “No traffic yet”
+- Console: audit loading no longer flashes “no matching records”; empty database vs filtered no-match are distinct; traffic/audit filter empties can be cleared in one click
+- Console: switching to public access requires a second confirm; cancel keeps the previous mode
+
- 控制台:服务列表展示访问方式标记,可用访问方式筛选,空状态点名凭证访问 / 临时放行 / 公开访问
- 控制台:凭证访问的关闭公网业务端口改为中性文案,不再与“需处理”并列成故障
- 控制台:登记节点在签发前需确认;取消不会写入节点;成功页说明节点已在列表中,并保留一次性凭证提示
diff --git a/scripts/umbra-ui-smoke.mjs b/scripts/umbra-ui-smoke.mjs
index 22b51cc..79a913d 100644
--- a/scripts/umbra-ui-smoke.mjs
+++ b/scripts/umbra-ui-smoke.mjs
@@ -21,7 +21,8 @@ try {
["/mappings", "全部服务"],
["/traffic", "观测"],
["/audit", "观测"],
- ["/deploy", "系统"],
+ ["/settings", "设置"],
+ ["/deploy", "设置"],
]) {
await page.goto(base + path, { waitUntil: "domcontentloaded" });
await page.getByRole("heading", { name: title, exact: true, level: 1 }).waitFor();
diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx
index 1d59b0e..5d46b96 100644
--- a/src/components/app-shell.tsx
+++ b/src/components/app-shell.tsx
@@ -16,7 +16,7 @@ const nav = [
{ to: "/nodes", labelKey: "nav.nodes", icon: Radio, paths: ["/", "/nodes"] },
{ to: "/mappings", labelKey: "nav.services", icon: Layers, paths: ["/mappings"] },
{ to: "/traffic", labelKey: "nav.observe", icon: Activity, paths: ["/traffic", "/audit"] },
- { to: "/deploy", labelKey: "nav.system", icon: Settings2, paths: ["/deploy"] },
+ { to: "/settings", labelKey: "nav.settings", icon: Settings2, paths: ["/settings", "/deploy"] },
] as const;
export function AppShell({
diff --git a/src/components/empty-state.tsx b/src/components/empty-state.tsx
new file mode 100644
index 0000000..3995542
--- /dev/null
+++ b/src/components/empty-state.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import type { LucideIcon } from "lucide-react";
+import type { ReactNode } from "react";
+
+export function EmptyState({
+ icon: Icon,
+ title,
+ action,
+}: {
+ icon: LucideIcon;
+ title: string;
+ action?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {action ? (
+
{action}
+ ) : null}
+
+ );
+}
diff --git a/src/components/pages/audit-page.tsx b/src/components/pages/audit-page.tsx
index d945e9a..0917296 100644
--- a/src/components/pages/audit-page.tsx
+++ b/src/components/pages/audit-page.tsx
@@ -2,9 +2,12 @@
import { useEffect, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { ScrollText } from "lucide-react";
import { ObservabilityNav } from "@/components/observability-nav";
import { AppShell } from "@/components/app-shell";
+import { EmptyState } from "@/components/empty-state";
import { SelectField } from "@/components/field";
+import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pager } from "@/components/ui/pager";
import { useI18n } from "@/lib/i18n";
@@ -32,7 +35,13 @@ export function AuditPage() {
});
const pageData = audit.data ?? emptyPage(page);
const list = pageData.items;
- const empty = !audit.isLoading && pageData.total === 0 && !q && action === "all";
+ const loaded = audit.isSuccess || Boolean(audit.data);
+ const empty = loaded && pageData.total === 0 && !q && action === "all";
+ const noMatch = loaded && pageData.total === 0 && Boolean(q || action !== "all");
+ const clearFilters = () => {
+ setQ("");
+ setAction("all");
+ };
const labels = actionLabel();
const actionOptions = [
{ value: "all", label: t("audit.all") },
@@ -51,10 +60,12 @@ export function AuditPage() {
return (
- {empty ? (
-
- {t("audit.empty")}
+ {audit.isPending ? (
+
+ {t("audit.loading")}
+ ) : empty ? (
+
) : (
<>
@@ -73,10 +84,16 @@ export function AuditPage() {
options={actionOptions}
/>
- {pageData.total === 0 ? (
-
- {t("audit.none")}
-
+ {noMatch ? (
+
+ {t("audit.clear")}
+
+ }
+ />
) : (
<>
diff --git a/src/components/pages/auth-panel.tsx b/src/components/pages/auth-panel.tsx
index 0acc430..196e9c0 100644
--- a/src/components/pages/auth-panel.tsx
+++ b/src/components/pages/auth-panel.tsx
@@ -29,9 +29,15 @@ export function AuthPanel() {
return (
{t("auth.kicker")}
- {t("auth.title")}
+
+ {s.twoFactorRequired && s.twoFactorConfigured ? t("auth.title") : t("auth.passwordTitle")}
+
- {s.twoFactorRequired ? t("auth.required") : t("auth.optional")}
+ {s.twoFactorRequired && s.twoFactorConfigured
+ ? t("auth.required")
+ : s.twoFactorRequired
+ ? t("auth.twoFactorPending")
+ : t("auth.passwordOnly")}
{s.twoFactorConfigured ? (
@@ -78,6 +84,7 @@ function PasswordForm({ needSecond, onDone }: { needSecond: boolean; onDone: ()
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
+ if (next.trim().length < 8 || mut.isPending) return;
mut.mutate();
}}
>
@@ -101,7 +108,17 @@ function PasswordForm({ needSecond, onDone }: { needSecond: boolean; onDone: ()
value={next}
onChange={(e) => setNext(e.target.value)}
required
+ aria-describedby="password-rule"
+ aria-invalid={next.length > 0 && next.length < 8}
/>
+
+ {t("auth.passwordRule")}
+
+ {next.length > 0 && next.length < 8 ? (
+
+ {t("login.errors.shortPassword")}
+
+ ) : null}
{needSecond ? (
) : null}
-
+ }
+ />
) : !error || all.length ? (
<>
@@ -428,27 +415,28 @@ export function MappingsPage({ nodeId }: { nodeId?: string } = {}) {
) : null}
{!rows.length ? (
-
-
- {q || filterCount
+
-
- {q || filterCount
- ? t("services.noneMatchHint")
: scopedNode?.status === "revoked"
? t("services.revokedHint")
- : t("services.firstHint")}
-
- {q || filterCount ? (
-
- {t("services.clear")}
-
- ) : null}
-
+ : t("services.emptyFirst")
+ }
+ action={
+ q || filterCount ? (
+
+ {t("services.clear")}
+
+ ) : hasNode && scopedNode?.status !== "revoked" ? (
+ setEditor({ mode: "create", nodeId: search.node })}>
+
+ {t("services.add")}
+
+ ) : null
+ }
+ />
) : (
{pageData.total === 0 ? (
-
- {t("nodes.emptyMatch")}
-
+ {
+ setQ("");
+ setStatus("all");
+ }}
+ >
+ {t("nodes.clear")}
+
+ }
+ />
) : (
<>
@@ -369,22 +383,20 @@ function NodeFleetBar({
function EmptyNodes({ onCreate, onImport }: { onCreate: () => void; onImport: () => void }) {
const { t } = useI18n();
return (
-
-
-
- {t("nodes.emptyTitle")}
-
-
{t("nodes.emptyBody")}
-
-
-
- {t("nodes.enroll")}
-
-
- {t("transfer.import")}
-
-
-
+
+
+ {t("nodes.enroll")}
+
+
+ {t("transfer.import")}
+
+ >
+ }
+ />
);
}
diff --git a/src/components/pages/system-page.tsx b/src/components/pages/system-page.tsx
index 5fe1b5a..5817ffb 100644
--- a/src/components/pages/system-page.tsx
+++ b/src/components/pages/system-page.tsx
@@ -11,10 +11,7 @@ export function SystemPage() {
return (
-
-
{t("system.heading")}
-
{t("system.intro")}
-
+
{t("system.intro")}
diff --git a/src/components/pages/traffic-page.tsx b/src/components/pages/traffic-page.tsx
index 1d9ab28..2b04862 100644
--- a/src/components/pages/traffic-page.tsx
+++ b/src/components/pages/traffic-page.tsx
@@ -3,7 +3,8 @@
import { useQuery } from "@tanstack/react-query";
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
import { useEffect, useState } from "react";
-import { ArrowRight } from "lucide-react";
+import { Activity, ArrowRight, Layers, Radio } from "lucide-react";
+import { EmptyState } from "@/components/empty-state";
import { ObservabilityNav } from "@/components/observability-nav";
import { AppShell } from "@/components/app-shell";
import { ObservationScope } from "@/components/observation-scope";
@@ -104,7 +105,11 @@ export function TrafficPage() {
: undefined);
return (
-
+
-