diff --git a/deploy/tksea-portal/admin-common.css b/deploy/tksea-portal/admin-common.css
new file mode 100644
index 00000000..98f292f8
--- /dev/null
+++ b/deploy/tksea-portal/admin-common.css
@@ -0,0 +1,136 @@
+/* =============================================================
+ * admin-common.css — Legacy compatibility shim
+ * -------------------------------------------------------------
+ * Pages link BOTH:
+ * /portal/portal.css → modern design system (primary)
+ * /portal/admin-common.css → this file (legacy aliases)
+ *
+ * This file ONLY provides aliases so old inline class names
+ * (`.primary` / `.secondary` / `.ghost` / `.danger` / etc.)
+ * keep working after pages are refactored to the new system.
+ * New pages should use the modern `.btn` / `.card` / `.stat-card`
+ * classes from portal.css directly.
+ * ============================================================= */
+
+.topnav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-bottom: 18px;
+}
+
+.topnav a {
+ text-decoration: none;
+ padding: 10px 14px;
+ border-radius: 999px;
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-elev-2);
+ color: var(--text-muted);
+ font-size: 13px;
+ font-weight: 700;
+ transition: transform 120ms ease, background 120ms ease;
+}
+
+.topnav a:hover {
+ transform: translateY(-1px);
+ background: var(--bg-elev-3);
+ color: var(--text-default);
+}
+
+.topnav a.is-current {
+ background: linear-gradient(135deg, var(--teal-500), var(--teal-600));
+ border-color: transparent;
+ color: var(--text-on-primary);
+ box-shadow: 0 4px 12px rgba(20,184,166,0.3);
+}
+
+.statusbar {
+ margin-top: 16px;
+ min-height: 54px;
+ padding: 14px 16px;
+ border-radius: 14px;
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-elev-2);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-muted);
+ font-size: 14px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+}
+
+.statusbar[data-tone="success"] {
+ background: var(--color-success-soft);
+ color: var(--color-success);
+ border-color: rgba(18, 107, 67, 0.2);
+}
+
+.statusbar[data-tone="warning"] {
+ background: var(--color-warning-soft);
+ color: var(--color-warning);
+ border-color: rgba(155, 98, 21, 0.2);
+}
+
+.statusbar[data-tone="danger"] {
+ background: var(--color-danger-soft);
+ color: var(--color-danger);
+ border-color: rgba(178, 49, 49, 0.2);
+}
+
+.statusbar[data-tone="info"] {
+ background: var(--color-info-soft);
+ color: var(--color-info);
+ border-color: rgba(56, 189, 248, 0.2);
+}
+
+/* ---- Legacy button aliases (kept so old pages work) ---- */
+button.primary, .primary {
+ background: linear-gradient(135deg, var(--teal-500), var(--teal-600));
+ color: var(--text-on-primary);
+ border: 0;
+ border-radius: 999px;
+ padding: 12px 18px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform 120ms ease, box-shadow 120ms ease;
+ box-shadow: 0 6px 16px rgba(20,184,166,0.22);
+}
+button.primary:hover, .primary:hover { transform: translateY(-1px); box-shadow: 0 10px 22px rgba(20,184,166,0.32); }
+button.primary:disabled, .primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
+
+button.secondary, .secondary {
+ background: var(--color-primary-soft);
+ color: var(--color-primary);
+ border: 1px solid rgba(20,184,166,0.2);
+ border-radius: 999px;
+ padding: 12px 18px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform 120ms ease, background 120ms ease;
+}
+button.secondary:hover, .secondary:hover { transform: translateY(-1px); background: rgba(20,184,166,0.18); }
+
+button.ghost, .ghost {
+ background: transparent;
+ border: 1px solid var(--border-subtle);
+ color: var(--text-muted);
+ border-radius: 999px;
+ padding: 12px 18px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform 120ms ease, background 120ms ease, color 120ms ease;
+}
+button.ghost:hover, .ghost:hover { transform: translateY(-1px); background: var(--bg-elev-3); color: var(--text-default); }
+
+button.danger, .danger {
+ background: var(--color-danger-soft);
+ color: var(--color-danger);
+ border: 1px solid rgba(239,68,68,0.2);
+ border-radius: 999px;
+ padding: 12px 18px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: transform 120ms ease, background 120ms ease;
+}
+button.danger:hover, .danger:hover { transform: translateY(-1px); background: rgba(239,68,68,0.18); }
diff --git a/deploy/tksea-portal/portal.css b/deploy/tksea-portal/portal.css
new file mode 100644
index 00000000..bcdadaca
--- /dev/null
+++ b/deploy/tksea-portal/portal.css
@@ -0,0 +1,776 @@
+/* =============================================================
+ * Sub2API CN Relay Manager · Portal Design System
+ * -------------------------------------------------------------
+ * 1:1 mapped to sub2api host (Vue/Tailwind) tokens.
+ * All admin pages should link this file and consume these tokens
+ * instead of declaring inline styles.
+ *
+ * Design DNA (from host sub2api-official-fresh frontend):
+ * - Primary: Teal (#14b8a6 → #0f766e)
+ * - Accent: Slate (#1e293b → #94a3b8)
+ * - Dark mode default; light mode alternate via [data-theme=light]
+ * - Glass cards + soft teal glow
+ * - system-ui first font (NOT IBM Plex Sans which was never loaded)
+ * ============================================================= */
+
+/* ---------- 1. Tokens ---------- */
+:root,
+[data-theme="dark"] {
+ /* Brand · teal scale */
+ --teal-50: #f0fdfa;
+ --teal-100: #ccfbf1;
+ --teal-200: #99f6e4;
+ --teal-300: #5eead4;
+ --teal-400: #2dd4bf;
+ --teal-500: #14b8a6;
+ --teal-600: #0d9488;
+ --teal-700: #0f766e;
+ --teal-800: #115e59;
+ --teal-900: #134e4a;
+ --teal-950: #042f2e;
+
+ /* Slate · dark surface */
+ --slate-50: #f8fafc;
+ --slate-100: #f1f5f9;
+ --slate-200: #e2e8f0;
+ --slate-300: #cbd5e1;
+ --slate-400: #94a3b8;
+ --slate-500: #64748b;
+ --slate-600: #475569;
+ --slate-700: #334155;
+ --slate-800: #1e293b;
+ --slate-900: #0f172a;
+ --slate-950: #020617;
+
+ /* Semantic aliases */
+ --color-primary: var(--teal-500);
+ --color-primary-strong: var(--teal-600);
+ --color-primary-soft: rgba(20, 184, 166, 0.12);
+ --color-primary-glow: rgba(20, 184, 166, 0.25);
+
+ --color-success: #22c55e;
+ --color-success-soft: rgba(34, 197, 94, 0.12);
+ --color-warning: #f59e0b;
+ --color-warning-soft: rgba(245, 158, 11, 0.12);
+ --color-danger: #ef4444;
+ --color-danger-soft: rgba(239, 68, 68, 0.12);
+ --color-info: #38bdf8;
+ --color-info-soft: rgba(56, 189, 248, 0.12);
+ --color-neutral: var(--slate-500);
+ --color-neutral-soft: rgba(100, 116, 139, 0.12);
+
+ /* Surface */
+ --bg-page: var(--slate-950);
+ --bg-page-2: var(--slate-900);
+ --bg-elev-1: rgba(30, 41, 59, 0.7);
+ --bg-elev-2: rgba(30, 41, 59, 0.9);
+ --bg-elev-3: rgba(51, 65, 85, 0.5);
+ --border-subtle: rgba(148, 163, 184, 0.16);
+ --border-strong: rgba(148, 163, 184, 0.28);
+
+ --text-strong: #f8fafc;
+ --text-default: #e2e8f0;
+ --text-muted: #94a3b8;
+ --text-subtle: #64748b;
+ --text-on-primary:#042f2e;
+
+ /* Type */
+ --font-sans: system-ui, -apple-system, "Segoe UI", "PingFang SC",
+ "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
+ --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo,
+ Monaco, Consolas, monospace;
+
+ /* Radius */
+ --r-xs: 6px;
+ --r-sm: 10px;
+ --r-md: 14px;
+ --r-lg: 18px;
+ --r-xl: 24px;
+ --r-2xl: 32px;
+ --r-full: 9999px;
+
+ /* Spacing (4-pt scale) */
+ --s-1: 4px;
+ --s-2: 8px;
+ --s-3: 12px;
+ --s-4: 16px;
+ --s-5: 20px;
+ --s-6: 24px;
+ --s-8: 32px;
+ --s-10: 40px;
+ --s-12: 48px;
+ --s-16: 64px;
+
+ /* Shadow · glass + glow */
+ --shadow-xs: 0 1px 2px rgba(2, 6, 23, 0.4);
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.18), 0 1px 2px rgba(0,0,0,0.12);
+ --shadow-md: 0 4px 16px rgba(0, 0, 0, 0.32);
+ --shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.36);
+ --shadow-glass: 0 4px 16px rgba(0, 0, 0, 0.4), 0 1px 0 rgba(255,255,255,0.04) inset;
+ --shadow-glow: 0 0 0 1px rgba(20,184,166,0.35), 0 0 24px rgba(20,184,166,0.25);
+ --shadow-glow-lg: 0 0 0 1px rgba(20,184,166,0.5), 0 0 40px rgba(20,184,166,0.4);
+
+ /* Motion */
+ --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+ --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
+ --dur-fast: 120ms;
+ --dur-base: 200ms;
+ --dur-slow: 320ms;
+}
+
+/* Light mode · opt-in */
+[data-theme="light"] {
+ --bg-page: #f8fafc;
+ --bg-page-2: #f1f5f9;
+ --bg-elev-1: rgba(255, 255, 255, 0.85);
+ --bg-elev-2: #ffffff;
+ --bg-elev-3: #f8fafc;
+ --border-subtle: rgba(15, 23, 42, 0.08);
+ --border-strong: rgba(15, 23, 42, 0.16);
+
+ --text-strong: #0f172a;
+ --text-default: #1e293b;
+ --text-muted: #475569;
+ --text-subtle: #64748b;
+ --text-on-primary:#ffffff;
+
+ --shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.04);
+ --shadow-sm: 0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.06);
+ --shadow-md: 0 4px 16px rgba(15, 23, 42, 0.06);
+ --shadow-lg: 0 8px 32px rgba(15, 23, 42, 0.08);
+ --shadow-glass: 0 4px 16px rgba(0, 0, 0, 0.04), 0 1px 0 rgba(255,255,255,0.6) inset;
+}
+
+/* ---------- 2. Reset + base ---------- */
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; padding: 0; }
+html { -webkit-text-size-adjust: 100%; }
+body {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-default);
+ background: var(--bg-page);
+ min-height: 100vh;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ background-image:
+ radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.10) 0px, transparent 50%),
+ radial-gradient(at 80% 0%, rgba(6, 182, 212, 0.06) 0px, transparent 50%),
+ radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.06) 0px, transparent 50%);
+ background-attachment: fixed;
+}
+[data-theme="light"] body {
+ background-image:
+ radial-gradient(at 40% 20%, rgba(20, 184, 166, 0.08) 0px, transparent 50%),
+ radial-gradient(at 80% 0%, rgba(6, 182, 212, 0.05) 0px, transparent 50%),
+ radial-gradient(at 0% 50%, rgba(20, 184, 166, 0.04) 0px, transparent 50%);
+}
+a { color: inherit; text-decoration: none; }
+button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
+input, select, textarea { font: inherit; color: inherit; }
+code, pre, kbd { font-family: var(--font-mono); font-size: 0.875em; }
+img, svg { display: block; max-width: 100%; }
+hr { border: 0; border-top: 1px solid var(--border-subtle); margin: var(--s-6) 0; }
+::selection { background: var(--color-primary-soft); color: var(--text-strong); }
+
+/* ---------- 3. Layout primitives ---------- */
+.shell {
+ max-width: 1440px;
+ margin: 0 auto;
+ padding: var(--s-8) var(--s-5) var(--s-16);
+}
+.shell-narrow { max-width: 1080px; }
+.stack { display: flex; flex-direction: column; gap: var(--s-4); }
+.stack-lg { display: flex; flex-direction: column; gap: var(--s-6); }
+.row { display: flex; align-items: center; gap: var(--s-3); }
+.row-wrap { display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap; }
+.row-between { display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); }
+.grid { display: grid; gap: var(--s-5); }
+.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+@media (max-width: 1100px) { .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 800px) { .grid-3, .grid-2 { grid-template-columns: 1fr; } }
+@media (max-width: 700px) { .grid-4 { grid-template-columns: 1fr; } }
+
+/* Page hero · consistent header across all admin pages */
+.page-hero {
+ position: relative;
+ overflow: hidden;
+ display: grid;
+ grid-template-columns: 1.4fr 1fr;
+ gap: var(--s-6);
+ padding: var(--s-8);
+ border-radius: var(--r-2xl);
+ background:
+ linear-gradient(135deg, rgba(20, 184, 166, 0.10), rgba(56, 189, 248, 0.06)),
+ var(--bg-elev-2);
+ border: 1px solid var(--border-subtle);
+ box-shadow: var(--shadow-glass);
+ margin-bottom: var(--s-6);
+}
+.page-hero::after {
+ content: "";
+ position: absolute;
+ right: -10rem;
+ bottom: -10rem;
+ width: 22rem;
+ height: 22rem;
+ border-radius: 50%;
+ background: radial-gradient(circle, var(--color-primary-soft), transparent 70%);
+ pointer-events: none;
+}
+@media (max-width: 900px) { .page-hero { grid-template-columns: 1fr; } }
+
+.page-hero h1 {
+ font-size: clamp(28px, 4vw, 40px);
+ font-weight: 800;
+ line-height: 1.1;
+ letter-spacing: -0.02em;
+ margin: var(--s-3) 0 var(--s-2);
+ color: var(--text-strong);
+}
+.page-hero p { color: var(--text-muted); max-width: 56ch; line-height: 1.65; }
+.page-hero__eyebrow {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: 4px 10px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ border-radius: var(--r-full);
+ background: var(--color-primary-soft);
+ color: var(--color-primary);
+ border: 1px solid rgba(20, 184, 166, 0.24);
+}
+
+/* ---------- 4. Card ---------- */
+.card {
+ background: var(--bg-elev-1);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-xl);
+ box-shadow: var(--shadow-glass);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ overflow: hidden;
+}
+.card-body { padding: var(--s-6); }
+.card-body-tight { padding: var(--s-4); }
+.card-hover {
+ transition: transform var(--dur-base) var(--ease-out),
+ border-color var(--dur-base) var(--ease-out),
+ box-shadow var(--dur-base) var(--ease-out);
+}
+.card-hover:hover {
+ transform: translateY(-2px);
+ border-color: rgba(20, 184, 166, 0.32);
+ box-shadow: var(--shadow-glass), var(--shadow-glow);
+}
+
+/* ---------- 5. Stat card (host's signature) ---------- */
+.stat-card {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--s-3);
+ padding: var(--s-5);
+ background: var(--bg-elev-1);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-xl);
+ box-shadow: var(--shadow-glass);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ transition: transform var(--dur-base) var(--ease-out), border-color var(--dur-base) var(--ease-out);
+}
+.stat-card:hover { transform: translateY(-1px); border-color: rgba(20,184,166,0.24); }
+.stat-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--r-md);
+ flex-shrink: 0;
+}
+.stat-icon svg { width: 22px; height: 22px; }
+.stat-icon-primary { background: var(--color-primary-soft); color: var(--color-primary); }
+.stat-icon-success { background: var(--color-success-soft); color: var(--color-success); }
+.stat-icon-warning { background: var(--color-warning-soft); color: var(--color-warning); }
+.stat-icon-danger { background: var(--color-danger-soft); color: var(--color-danger); }
+.stat-icon-info { background: var(--color-info-soft); color: var(--color-info); }
+.stat-icon-neutral { background: var(--color-neutral-soft); color: var(--color-neutral); }
+.stat-label { font-size: 12px; font-weight: 500; color: var(--text-muted); margin: 0 0 4px; }
+.stat-value { font-size: 22px; font-weight: 700; color: var(--text-strong); letter-spacing: -0.02em; line-height: 1.2; }
+.stat-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
+.stat-sub .up { color: var(--color-success); }
+.stat-sub .down { color: var(--color-danger); }
+
+/* ---------- 6. Status badge (host's signature) ---------- */
+.status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 10px;
+ border-radius: var(--r-full);
+ font-size: 12px;
+ font-weight: 600;
+ line-height: 1.4;
+ background: var(--color-neutral-soft);
+ color: var(--text-default);
+ border: 1px solid transparent;
+}
+.status::before {
+ content: "";
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ flex-shrink: 0;
+}
+.status-success { background: var(--color-success-soft); color: var(--color-success); }
+.status-warning { background: var(--color-warning-soft); color: var(--color-warning); }
+.status-danger { background: var(--color-danger-soft); color: var(--color-danger); }
+.status-info { background: var(--color-info-soft); color: var(--color-info); }
+.status-primary { background: var(--color-primary-soft); color: var(--color-primary); }
+.status-neutral { background: var(--color-neutral-soft); color: var(--text-muted); }
+
+/* Plain dot · for compact table rows */
+.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; }
+.dot-success { background: var(--color-success); }
+.dot-warning { background: var(--color-warning); }
+.dot-danger { background: var(--color-danger); }
+.dot-info { background: var(--color-info); }
+.dot-neutral { background: var(--color-neutral); }
+.dot-primary { background: var(--color-primary); }
+
+/* ---------- 7. Pill / chip / tag ---------- */
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ font-size: 12px;
+ font-weight: 600;
+ border-radius: var(--r-full);
+ background: var(--bg-elev-3);
+ color: var(--text-muted);
+ border: 1px solid var(--border-subtle);
+}
+.pill-primary { background: var(--color-primary-soft); color: var(--color-primary); border-color: rgba(20,184,166,0.24); }
+.pill-success { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.24); }
+.pill-warning { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.24); }
+.pill-danger { background: var(--color-danger-soft); color: var(--color-danger); border-color: rgba(239,68,68,0.24); }
+
+/* ---------- 8. Button ---------- */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--s-2);
+ height: 38px;
+ padding: 0 var(--s-4);
+ border-radius: var(--r-md);
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0.01em;
+ border: 1px solid var(--border-subtle);
+ background: var(--bg-elev-2);
+ color: var(--text-default);
+ transition: transform var(--dur-fast) var(--ease-out),
+ background var(--dur-fast) var(--ease-out),
+ border-color var(--dur-fast) var(--ease-out),
+ box-shadow var(--dur-fast) var(--ease-out);
+ white-space: nowrap;
+ text-decoration: none;
+}
+.btn:hover { transform: translateY(-1px); border-color: var(--border-strong); }
+.btn:active { transform: translateY(0); }
+.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
+.btn-sm { height: 30px; padding: 0 var(--s-3); font-size: 12px; }
+.btn-lg { height: 44px; padding: 0 var(--s-5); font-size: 14px; }
+.btn-block { width: 100%; }
+.btn svg { width: 16px; height: 16px; }
+
+.btn-primary {
+ background: linear-gradient(135deg, var(--teal-500), var(--teal-600));
+ color: var(--text-on-primary);
+ border-color: transparent;
+ box-shadow: 0 1px 0 rgba(255,255,255,0.18) inset, 0 6px 16px rgba(20,184,166,0.25);
+}
+.btn-primary:hover {
+ background: linear-gradient(135deg, var(--teal-400), var(--teal-500));
+ box-shadow: 0 1px 0 rgba(255,255,255,0.22) inset, 0 10px 24px rgba(20,184,166,0.35);
+}
+.btn-ghost {
+ background: transparent;
+ border-color: transparent;
+ color: var(--text-muted);
+}
+.btn-ghost:hover { background: var(--bg-elev-3); color: var(--text-default); }
+.btn-danger {
+ background: var(--color-danger-soft);
+ color: var(--color-danger);
+ border-color: rgba(239,68,68,0.3);
+}
+.btn-danger:hover { background: rgba(239,68,68,0.18); }
+
+/* ---------- 9. Form ---------- */
+.input, .select, .textarea {
+ width: 100%;
+ height: 38px;
+ padding: 0 var(--s-3);
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-md);
+ color: var(--text-default);
+ font-size: 13px;
+ transition: border-color var(--dur-fast), box-shadow var(--dur-fast);
+}
+.textarea { height: auto; min-height: 96px; padding: var(--s-3); resize: vertical; line-height: 1.5; }
+.input:focus, .select:focus, .textarea:focus {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 3px var(--color-primary-soft);
+}
+.input::placeholder, .textarea::placeholder { color: var(--text-subtle); }
+.label {
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-muted);
+ margin-bottom: 6px;
+ letter-spacing: 0.02em;
+}
+.field { display: flex; flex-direction: column; }
+.field-help { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
+.field-error { font-size: 12px; color: var(--color-danger); margin-top: 4px; }
+
+/* ---------- 10. Table ---------- */
+.table-wrap {
+ background: var(--bg-elev-1);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-xl);
+ box-shadow: var(--shadow-glass);
+ overflow: hidden;
+}
+table.data {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+}
+.data thead th {
+ text-align: left;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+ padding: var(--s-3) var(--s-4);
+ background: rgba(15, 23, 42, 0.35);
+ border-bottom: 1px solid var(--border-subtle);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+[data-theme="light"] .data thead th { background: rgba(241, 245, 249, 0.7); }
+.data tbody td {
+ padding: var(--s-3) var(--s-4);
+ border-bottom: 1px solid var(--border-subtle);
+ color: var(--text-default);
+ vertical-align: middle;
+}
+.data tbody tr:last-child td { border-bottom: 0; }
+.data tbody tr:hover td { background: rgba(20, 184, 166, 0.04); }
+.data .num, .data .mono { font-family: var(--font-mono); font-size: 12.5px; }
+.data .num { text-align: right; font-variant-numeric: tabular-nums; }
+
+/* ---------- 11. Empty state (host's signature) ---------- */
+.empty {
+ text-align: center;
+ padding: var(--s-12) var(--s-6);
+ color: var(--text-muted);
+}
+.empty-icon {
+ width: 80px;
+ height: 80px;
+ margin: 0 auto var(--s-5);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--r-xl);
+ background: var(--bg-elev-3);
+ color: var(--text-muted);
+}
+.empty-icon svg { width: 40px; height: 40px; }
+.empty h3 { font-size: 16px; font-weight: 700; color: var(--text-strong); margin: 0 0 var(--s-2); }
+.empty p { font-size: 13px; max-width: 36ch; margin: 0 auto var(--s-5); line-height: 1.6; }
+
+/* ---------- 12. Loading / skeleton ---------- */
+.spinner {
+ width: 18px;
+ height: 18px;
+ border: 2px solid var(--border-subtle);
+ border-top-color: var(--color-primary);
+ border-radius: 50%;
+ animation: spin 0.7s linear infinite;
+ display: inline-block;
+}
+.spinner-lg { width: 32px; height: 32px; border-width: 3px; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.skeleton {
+ background: linear-gradient(90deg,
+ var(--bg-elev-3) 25%,
+ rgba(148,163,184,0.18) 37%,
+ var(--bg-elev-3) 63%);
+ background-size: 400% 100%;
+ animation: shimmer 1.6s ease infinite;
+ border-radius: var(--r-sm);
+}
+@keyframes shimmer { to { background-position: -200% 0; } }
+
+/* ---------- 13. Topnav (admin) ---------- */
+.topnav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--s-2);
+ margin-bottom: var(--s-6);
+ padding: var(--s-2);
+ background: var(--bg-elev-1);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-full);
+ box-shadow: var(--shadow-glass);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ width: fit-content;
+ max-width: 100%;
+}
+.topnav a {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 14px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-muted);
+ border-radius: var(--r-full);
+ transition: background var(--dur-fast), color var(--dur-fast);
+}
+.topnav a:hover { color: var(--text-default); background: var(--bg-elev-3); }
+.topnav a.is-current {
+ color: var(--text-on-primary);
+ background: linear-gradient(135deg, var(--teal-500), var(--teal-600));
+ box-shadow: 0 4px 12px rgba(20,184,166,0.3);
+}
+.topnav a svg { width: 14px; height: 14px; }
+
+/* ---------- 14. Toast ---------- */
+.toast-host {
+ position: fixed;
+ top: var(--s-5);
+ right: var(--s-5);
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: var(--s-2);
+ pointer-events: none;
+}
+.toast {
+ pointer-events: auto;
+ min-width: 240px;
+ max-width: 360px;
+ padding: var(--s-3) var(--s-4);
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-md);
+ box-shadow: var(--shadow-lg);
+ display: flex;
+ align-items: center;
+ gap: var(--s-3);
+ font-size: 13px;
+ color: var(--text-default);
+ animation: toast-in var(--dur-slow) var(--ease-out);
+}
+.toast.leaving { animation: toast-out var(--dur-base) var(--ease-in-out) forwards; }
+.toast-success { border-color: rgba(34,197,94,0.32); }
+.toast-success .toast-icon { color: var(--color-success); }
+.toast-warning { border-color: rgba(245,158,11,0.32); }
+.toast-warning .toast-icon { color: var(--color-warning); }
+.toast-danger { border-color: rgba(239,68,68,0.32); }
+.toast-danger .toast-icon { color: var(--color-danger); }
+.toast-info { border-color: rgba(56,189,248,0.32); }
+.toast-info .toast-icon { color: var(--color-info); }
+.toast-icon { display: inline-flex; flex-shrink: 0; }
+.toast-icon svg { width: 18px; height: 18px; }
+@keyframes toast-in {
+ from { opacity: 0; transform: translateX(20px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+@keyframes toast-out {
+ to { opacity: 0; transform: translateX(20px); }
+}
+
+/* ---------- 15. Statusbar (admin feedback area, replaces old one) ---------- */
+.statusbar {
+ display: flex;
+ align-items: center;
+ gap: var(--s-2);
+ padding: var(--s-3) var(--s-4);
+ background: var(--bg-elev-2);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-md);
+ color: var(--text-muted);
+ font-size: 13px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ min-height: 44px;
+}
+.statusbar[data-tone="success"] { background: var(--color-success-soft); color: var(--color-success); border-color: rgba(34,197,94,0.24); }
+.statusbar[data-tone="warning"] { background: var(--color-warning-soft); color: var(--color-warning); border-color: rgba(245,158,11,0.24); }
+.statusbar[data-tone="danger"] { background: var(--color-danger-soft); color: var(--color-danger); border-color: rgba(239,68,68,0.24); }
+.statusbar[data-tone="info"] { background: var(--color-info-soft); color: var(--color-info); border-color: rgba(56,189,248,0.24); }
+
+/* ---------- 16. Page entry animations ---------- */
+.fade-in { animation: fade-in var(--dur-slow) var(--ease-out); }
+.slide-up { animation: slide-up var(--dur-slow) var(--ease-out); }
+.scale-in { animation: scale-in var(--dur-base) var(--ease-out); }
+@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
+@keyframes slide-up { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
+@keyframes scale-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } }
+
+/* ---------- 17. Code block ---------- */
+pre, .code {
+ font-family: var(--font-mono);
+ font-size: 12.5px;
+ line-height: 1.6;
+ padding: var(--s-3) var(--s-4);
+ background: rgba(2, 6, 23, 0.6);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-md);
+ color: var(--text-default);
+ overflow-x: auto;
+}
+[data-theme="light"] pre, [data-theme="light"] .code {
+ background: var(--slate-900);
+ color: var(--slate-100);
+}
+code { color: var(--color-primary); }
+[data-theme="light"] code { color: var(--teal-700); }
+pre code { color: inherit; }
+
+/* ---------- 18. Section header ---------- */
+.section-head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: var(--s-3);
+ flex-wrap: wrap;
+ margin-bottom: var(--s-4);
+}
+.section-head h2 {
+ font-size: 18px;
+ font-weight: 700;
+ margin: 0;
+ letter-spacing: -0.01em;
+ color: var(--text-strong);
+}
+.section-head p {
+ font-size: 13px;
+ color: var(--text-muted);
+ margin: 4px 0 0;
+ max-width: 60ch;
+ line-height: 1.55;
+}
+.section-head .actions { display: flex; gap: var(--s-2); flex-wrap: wrap; }
+
+/* ---------- 19. Drawer (right-side panel) ---------- */
+.drawer-mask {
+ position: fixed; inset: 0; z-index: 200;
+ background: rgba(2,6,23,0.6);
+ backdrop-filter: blur(2px);
+ animation: fade-in var(--dur-base) var(--ease-out);
+}
+.drawer {
+ position: fixed; top: 0; right: 0; bottom: 0; z-index: 201;
+ width: min(540px, 100vw);
+ background: var(--bg-elev-2);
+ border-left: 1px solid var(--border-subtle);
+ box-shadow: var(--shadow-lg);
+ display: flex; flex-direction: column;
+ animation: drawer-in var(--dur-slow) var(--ease-out);
+}
+@keyframes drawer-in { from { transform: translateX(20px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
+.drawer-head { padding: var(--s-5); border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; justify-content: space-between; gap: var(--s-3); }
+.drawer-body { padding: var(--s-5); overflow-y: auto; flex: 1; }
+.drawer-foot { padding: var(--s-4) var(--s-5); border-top: 1px solid var(--border-subtle); display: flex; gap: var(--s-2); justify-content: flex-end; }
+
+/* ---------- 20. Utilities ---------- */
+.text-muted { color: var(--text-muted); }
+.text-subtle { color: var(--text-subtle); }
+.text-strong { color: var(--text-strong); }
+.text-success { color: var(--color-success); }
+.text-warning { color: var(--color-warning); }
+.text-danger { color: var(--color-danger); }
+.text-primary { color: var(--color-primary); }
+.mono { font-family: var(--font-mono); }
+.tabular { font-variant-numeric: tabular-nums; }
+.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.hidden { display: none; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }
+.divider { height: 1px; background: var(--border-subtle); margin: var(--s-4) 0; }
+.mt-1 { margin-top: var(--s-1); } .mt-2 { margin-top: var(--s-2); } .mt-3 { margin-top: var(--s-3); }
+.mt-4 { margin-top: var(--s-4); } .mt-6 { margin-top: var(--s-6); } .mt-8 { margin-top: var(--s-8); }
+.mb-1 { margin-bottom: var(--s-1); } .mb-2 { margin-bottom: var(--s-2); } .mb-3 { margin-bottom: var(--s-3); }
+.mb-4 { margin-bottom: var(--s-4); } .mb-6 { margin-bottom: var(--s-6); }
+.p-4 { padding: var(--s-4); } .p-5 { padding: var(--s-5); } .p-6 { padding: var(--s-6); }
+
+/* ---------- 21. Tabs ---------- */
+.tabs {
+ display: flex;
+ gap: 2px;
+ padding: 4px;
+ background: var(--bg-elev-3);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--r-md);
+ width: fit-content;
+}
+.tabs button {
+ padding: 6px 14px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-muted);
+ border-radius: 8px;
+ transition: background var(--dur-fast), color var(--dur-fast);
+}
+.tabs button:hover { color: var(--text-default); }
+.tabs button.is-current {
+ background: var(--bg-elev-2);
+ color: var(--text-strong);
+ box-shadow: var(--shadow-xs);
+}
+
+/* ---------- 22. Misc host-aligned elements ---------- */
+.kbd {
+ display: inline-block;
+ padding: 1px 6px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ border-radius: 4px;
+ background: var(--bg-elev-3);
+ border: 1px solid var(--border-subtle);
+ color: var(--text-muted);
+}
+.glow-ring {
+ position: relative;
+}
+.glow-ring::after {
+ content: "";
+ position: absolute; inset: -1px;
+ border-radius: inherit;
+ background: linear-gradient(135deg, var(--teal-500), transparent 40%, transparent 60%, var(--teal-700));
+ opacity: 0.15;
+ z-index: -1;
+ filter: blur(8px);
+}
diff --git a/deploy/tksea-portal/portal.js b/deploy/tksea-portal/portal.js
new file mode 100644
index 00000000..853fb90c
--- /dev/null
+++ b/deploy/tksea-portal/portal.js
@@ -0,0 +1,330 @@
+/* =============================================================
+ * Sub2API CN Relay Manager · Modern Portal Shared Layer
+ * -------------------------------------------------------------
+ * Sits on top of admin-common.js. Provides:
+ * - Toast (non-blocking notifications)
+ * - Copy-to-clipboard helper
+ * - Theme switch (auto / dark / light)
+ * - SVG icon registry
+ * - Loading state + empty state helpers
+ * - Drawer open/close
+ * ============================================================= */
+(function initPortalModern(global) {
+ "use strict";
+
+ // ---- 1. SVG icon registry (lucide-style, 1.5px stroke) ----
+ const ICONS = {
+ home: '${escapeText(title || "")}
+
+
${escapeText(description)}
` : ""} + ${actionLabel ? `` : ""} + `; + if (actionLabel && typeof onAction === "function") { + root.querySelector("button").addEventListener("click", onAction); + } + return root; + } + + function skeleton(width = "100%", height = "12px") { + const el = global.document.createElement("span"); + el.className = "skeleton"; + el.style.display = "inline-block"; + el.style.width = width; + el.style.height = height; + return el; + } + + // ---- 7. Text escape (HTML-safe) ---- + function escapeText(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + } + + // ---- 8. Date / time formatting (zh-CN) ---- + function formatDateTime(input) { + if (!input) return "—"; + const d = input instanceof Date ? input : new Date(input); + if (Number.isNaN(d.getTime())) return String(input); + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; + } + + // ---- 9. Nav rendering w/ icons (overrides admin-common if Sub2ApiAdminCommon missing) ---- + const ICON_BY_KEY = { + home: "home", + "logical-groups": "group", + "route-health": "health", + accounts: "account", + providers: "provider", + "batch-import": "import", + }; + function renderModernAdminNav(container, currentKey) { + if (!container) return; + if ( + global.Sub2ApiAdminCommon && + typeof global.Sub2ApiAdminCommon.renderAdminNav === "function" + ) { + global.Sub2ApiAdminCommon.renderAdminNav(container, currentKey); + } + container.querySelectorAll("a").forEach((a) => { + const href = a.getAttribute("href") || ""; + const key = Object.keys(ICON_BY_KEY).find((k) => + href.endsWith( + ICON_BY_KEY[k] === "group" + ? "logical-groups.html" + : href.endsWith(ICON_BY_KEY[k] + ".html") + ? ICON_BY_KEY[k] + ".html" + : href.endsWith("/portal/admin/") && k === "home" + ? "/portal/admin/" + : "", + ), + ); + const matchKey = + key || + (href === "/portal/admin/" || href.endsWith("/portal/admin/") + ? "home" + : null); + if (matchKey && ICON_BY_KEY[matchKey] && !a.querySelector("svg")) { + a.insertAdjacentHTML( + "afterbegin", + svg(ICON_BY_KEY[matchKey], 14) + " ", + ); + } + }); + } + + // ---- Public API ---- + global.Sub2ApiPortal = { + svg, + toast, + copyToClipboard, + openDrawer, + emptyState, + skeleton, + formatDateTime, + applyTheme, + toggleTheme, + detectInitialTheme, + escapeText, + renderModernAdminNav, + }; +})(window);