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: '', + group: + '', + route: + '', + health: '', + account: + '', + provider: + '', + import: + '', + check: '', + x: '', + alert: + '', + info: '', + copy: '', + edit: '', + trash: + '', + plus: '', + refresh: + '', + download: + '', + upload: + '', + eye: '', + eyeoff: + '', + play: '', + pause: + '', + external: + '', + chevron: '', + zap: '', + shield: '', + search: + '', + server: + '', + activity: '', + key: '', + file: '', + package: + '', + user: '', + users: + '', + send: '', + filter: '', + sparkle: + '', + sun: '', + moon: '', + }; + + function svg(name, size = 16) { + const body = ICONS[name]; + if (!body) return ""; + return ``; + } + + // ---- 2. Theme switch ---- + const THEME_KEY = "sub2api-portal-theme"; + function detectInitialTheme() { + const stored = global.localStorage.getItem(THEME_KEY); + if (stored === "dark" || stored === "light") return stored; + return global.matchMedia && + global.matchMedia("(prefers-color-scheme: light)").matches + ? "light" + : "dark"; + } + function applyTheme(theme) { + global.document.documentElement.setAttribute("data-theme", theme); + global.localStorage.setItem(THEME_KEY, theme); + global.document.dispatchEvent( + new CustomEvent("themechange", { detail: { theme } }), + ); + } + function toggleTheme() { + const current = + global.document.documentElement.getAttribute("data-theme") || "dark"; + applyTheme(current === "dark" ? "light" : "dark"); + } + applyTheme(detectInitialTheme()); + + // ---- 3. Toast (host's signature) ---- + function ensureToastHost() { + let host = global.document.querySelector(".toast-host"); + if (!host) { + host = global.document.createElement("div"); + host.className = "toast-host"; + global.document.body.appendChild(host); + } + return host; + } + function toast(message, tone = "info", durationMs = 3200) { + const host = ensureToastHost(); + const el = global.document.createElement("div"); + el.className = `toast toast-${tone}`; + const iconName = + tone === "success" + ? "check" + : tone === "warning" + ? "alert" + : tone === "danger" + ? "x" + : "info"; + el.innerHTML = `${svg(iconName, 18)}${escapeText(message)}`; + host.appendChild(el); + const timer = setTimeout(() => dismiss(), durationMs); + el.addEventListener("click", () => { + clearTimeout(timer); + dismiss(); + }); + function dismiss() { + if (!el.isConnected) return; + el.classList.add("leaving"); + setTimeout(() => el.remove(), 220); + } + return dismiss; + } + + // ---- 4. Copy to clipboard ---- + async function copyToClipboard(text) { + const value = String(text ?? ""); + try { + if ( + global.navigator && + global.navigator.clipboard && + global.navigator.clipboard.writeText + ) { + await global.navigator.clipboard.writeText(value); + } else { + const ta = global.document.createElement("textarea"); + ta.value = value; + ta.style.position = "fixed"; + ta.style.opacity = "0"; + global.document.body.appendChild(ta); + ta.select(); + global.document.execCommand("copy"); + ta.remove(); + } + toast("已复制到剪贴板", "success", 1800); + return true; + } catch (error) { + toast(`复制失败:${error.message || error}`, "danger"); + return false; + } + } + + // ---- 5. Drawer ---- + function openDrawer({ title, body, footer, onMount } = {}) { + const mask = global.document.createElement("div"); + mask.className = "drawer-mask"; + const drawer = global.document.createElement("aside"); + drawer.className = "drawer"; + drawer.setAttribute("role", "dialog"); + drawer.setAttribute("aria-modal", "true"); + drawer.innerHTML = ` +
+

${escapeText(title || "")}

+ +
+
${typeof body === "string" ? body : ""}
+ ${footer ? `
${footer}
` : ""} + `; + global.document.body.appendChild(mask); + global.document.body.appendChild(drawer); + const bodyEl = drawer.querySelector(".drawer-body"); + if (typeof body !== "string" && body instanceof global.Node) { + bodyEl.innerHTML = ""; + bodyEl.appendChild(body); + } + if (typeof onMount === "function") { + onMount({ drawer, mask, body: bodyEl, close }); + } + function close() { + mask.style.opacity = "0"; + drawer.style.animation = "none"; + drawer.style.transform = "translateX(20px)"; + drawer.style.opacity = "0"; + drawer.style.transition = "all 200ms ease"; + setTimeout(() => { + mask.remove(); + drawer.remove(); + }, 200); + } + mask.addEventListener("click", close); + drawer + .querySelector("[data-drawer-close]") + .addEventListener("click", close); + global.document.addEventListener("keydown", function esc(e) { + if (e.key === "Escape") { + close(); + global.document.removeEventListener("keydown", esc); + } + }); + return { close, drawer, body: bodyEl }; + } + + // ---- 6. Empty state + skeleton helpers ---- + function emptyState({ + icon = "package", + title = "暂无数据", + description = "", + actionLabel, + onAction, + } = {}) { + const root = global.document.createElement("div"); + root.className = "empty"; + root.innerHTML = ` +
${svg(icon, 36)}
+

${escapeText(title)}

+ ${description ? `

${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);