diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 887501b..705d190 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -10,6 +10,7 @@ import { AccountProfileUpdateResponseSchema, AccountSettingsResponseSchema, AdminAuthenticatedUserSchema, + AdminOverviewResponseSchema, AdminCreditParamsSchema, AdminLoginCompleteRequestSchema, AdminLoginCompleteResponseSchema, @@ -110,6 +111,7 @@ import { type BootstrapResponse, type AdminLoginCompleteRequest, type AdminLoginSendRequest, + type AdminOverviewResponse, type AccountDeletionCompleteRequest, type AccountProfileUpdateRequest, type AdminCreditParams, @@ -192,6 +194,7 @@ const defaultBootstrap: BootstrapResponse = { }; export interface CreateAppOptions { + adminOverview?: () => AdminOverviewResponse | Promise; amap?: AmapAdapter; assetReleases?: AssetReleaseReader; bootstrap?: () => BootstrapResponse | Promise; @@ -688,6 +691,7 @@ export async function createApp(options: CreateAppOptions = {}) { AdminLoginCompleteRequestSchema, AdminLoginCompleteResponseSchema, AdminSessionResponseSchema, + AdminOverviewResponseSchema, CreditSummarySchema, CreditEntryTypeSchema, CreditEntryStatusSchema, @@ -1203,6 +1207,35 @@ export async function createApp(options: CreateAppOptions = {}) { }, ); + app.get( + "/api/v1/admin/overview", + { + schema: { + operationId: "getAdminOverview", + response: { + 200: Type.Ref(AdminOverviewResponseSchema), + 401: Type.Ref(ErrorEnvelopeSchema), + 503: Type.Null(), + }, + tags: ["Admin Operations"], + }, + }, + async (request, reply) => { + if (!options.registration) return reply.code(503).send(null); + const token = cookieValue(headerValue(request.headers.cookie), adminSessionCookieName); + const session = token ? options.registration.readAdminSession(token) : undefined; + if (!session) { + return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id })); + } + if (!options.adminOverview) return reply.code(503).send(null); + try { + return await options.adminOverview(); + } catch { + return reply.code(503).send(null); + } + }, + ); + app.post( "/api/v1/auth/login/send", { diff --git a/apps/web/src/admin-models.tsx b/apps/web/src/admin-models.tsx index f6259d0..655a19f 100644 --- a/apps/web/src/admin-models.tsx +++ b/apps/web/src/admin-models.tsx @@ -147,11 +147,7 @@ export function AdminModelsPage() { return (
-
- DADA ADMIN - -
-
+

MODEL OPERATIONS

模型配置

{configuration ? 配置集合 v{configuration.config_set_version} : null} diff --git a/apps/web/src/admin-shell.css b/apps/web/src/admin-shell.css new file mode 100644 index 0000000..e2c7470 --- /dev/null +++ b/apps/web/src/admin-shell.css @@ -0,0 +1,523 @@ +:root { + color-scheme: light; + font-family: "Segoe UI", "Microsoft YaHei UI", sans-serif; + background: #f3f3ef; +} + +* { + box-sizing: border-box; + letter-spacing: 0; +} + +body { + margin: 0; +} + +button, +a, +input, +textarea { + font: inherit; +} + +.admin-shell { + min-height: 100vh; + color: #171715; + background: #f3f3ef; +} + +.admin-skip-link { + position: fixed; + z-index: 100; + top: 8px; + left: 228px; + padding: 8px 12px; + color: #ffffff; + background: #171715; + transform: translateY(-160%); +} + +.admin-skip-link:focus { + transform: translateY(0); +} + +.admin-sidebar { + position: fixed; + z-index: 20; + inset: 0 auto 0 0; + display: grid; + width: 216px; + grid-template-rows: auto 1fr auto; + color: #ffffff; + background: #171715; +} + +.admin-wordmark { + display: grid; + min-height: 104px; + align-content: center; + padding: 20px 22px; + border-bottom: 1px solid #494944; + color: #ffffff; + text-decoration: none; +} + +.admin-wordmark span { + font-family: "Arial Black", "Segoe UI", sans-serif; + font-size: 30px; + line-height: 1; +} + +.admin-wordmark small { + margin-top: 6px; + color: #d9dc00; + font-family: Consolas, monospace; + font-size: 10px; +} + +.admin-sidebar nav { + display: grid; + align-content: start; + padding: 12px 0; +} + +.admin-sidebar nav a { + display: grid; + min-height: 48px; + grid-template-columns: 38px 1fr; + align-items: center; + padding: 0 18px; + border-left: 4px solid transparent; + color: #d5d5cf; + font-size: 13px; + font-weight: 700; + text-decoration: none; +} + +.admin-sidebar nav a > span { + color: #85857d; + font-family: Consolas, monospace; + font-size: 10px; +} + +.admin-sidebar nav a:hover, +.admin-sidebar nav a:focus-visible { + color: #ffffff; + background: #2c2c29; +} + +.admin-sidebar nav a[aria-current="page"] { + border-left-color: #e8eb00; + color: #171715; + background: #eef000; +} + +.admin-sidebar nav a[aria-current="page"] > span { + color: #4d4d00; +} + +.admin-sidebar-foot { + display: grid; + gap: 10px; + padding: 18px 22px; + border-top: 1px solid #494944; + font-family: Consolas, monospace; + font-size: 10px; +} + +.admin-sidebar-foot span { + color: #a5a59d; +} + +.admin-sidebar-foot strong { + color: #ffffff; + font-weight: 700; +} + +.admin-shell-workspace { + min-width: 0; + margin-left: 216px; + padding-top: 52px; +} + +.admin-topbar { + position: fixed; + z-index: 15; + top: 0; + right: 0; + left: 216px; + display: flex; + height: 52px; + align-items: center; + justify-content: space-between; + padding: 0 28px; + border-bottom: 1px solid #b7b7b0; + background: rgb(255 255 255 / 96%); +} + +.admin-topbar h1 { + margin: 0; + font-size: 15px; +} + +.admin-topbar-status { + display: flex; + align-items: center; + gap: 20px; + color: #62625c; + font-size: 11px; +} + +.admin-topbar-status span { + display: flex; + align-items: center; + gap: 7px; +} + +.admin-topbar-status i { + width: 8px; + height: 8px; + border-radius: 50%; + background: #777770; +} + +.admin-topbar-status code { + color: #171715; +} + +.admin-shell-content { + min-width: 0; +} + +.admin-session-gate { + display: grid; + min-height: 100vh; + place-items: center; + color: #171715; + background: #f3f3ef; +} + +.admin-session-gate p, +.admin-session-gate div { + padding: 22px; + border-left: 5px solid #171715; + background: #ffffff; +} + +.admin-session-gate div { + display: grid; + gap: 12px; +} + +.admin-session-gate button, +.admin-overview-failure button, +.admin-placeholder-toolbar button { + min-height: 40px; + padding: 8px 14px; + border: 1px solid #171715; + border-radius: 0; + color: #171715; + background: #eef000; + font-weight: 800; +} + +.admin-overview, +.admin-placeholder { + width: min(1320px, calc(100% - 64px)); + margin: 0 auto; + padding: 34px 0 72px; +} + +.admin-page-heading { + display: flex; + min-height: 74px; + align-items: end; + justify-content: space-between; + gap: 24px; + padding-bottom: 18px; + border-bottom: 1px solid #8c8c85; +} + +.admin-page-heading p, +.admin-status-section header p, +.admin-operation-strip header p { + margin: 0 0 5px; + font-family: Consolas, monospace; + font-size: 10px; + font-weight: 700; +} + +.admin-page-heading h2 { + margin: 0; + font-size: 32px; +} + +.admin-page-heading time { + color: #66665f; + font-size: 11px; +} + +.admin-capacity-alert { + display: grid; + min-height: 44px; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 18px; + padding: 9px 14px; + border-bottom: 1px solid #171715; + color: #171715; + background: #eef000; + font-size: 12px; + text-decoration: none; +} + +.admin-capacity-alert.is-full, +.admin-capacity-alert.is-unavailable { + color: #ffffff; + background: #b33a2f; +} + +.admin-overview-loading { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 22px; + border-block: 1px solid #b7b7b0; +} + +.admin-overview-loading span { + height: 130px; + border-right: 1px solid #c7c7c0; + background: #e2e2dd; +} + +.admin-overview-failure { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-top: 20px; + padding: 14px 16px; + border-left: 5px solid #b33a2f; + background: #fff0ed; +} + +.admin-metric-band { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin-top: 22px; + border-block: 1px solid #8c8c85; + background: #ffffff; +} + +.admin-metric-band a { + display: grid; + min-width: 0; + min-height: 132px; + align-content: center; + gap: 7px; + padding: 20px; + border-right: 1px solid #c3c3bc; + color: #171715; + text-decoration: none; +} + +.admin-metric-band a:last-child { + border-right: 0; +} + +.admin-metric-band span, +.admin-metric-band small { + color: #65655f; + font-size: 11px; +} + +.admin-metric-band strong { + overflow-wrap: anywhere; + font-size: 25px; +} + +.admin-overview-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 24px; +} + +.admin-status-section, +.admin-operation-strip, +.admin-placeholder > section { + border-top: 3px solid #171715; + border-bottom: 1px solid #8c8c85; + background: #ffffff; +} + +.admin-status-section > header, +.admin-operation-strip > header { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #c3c3bc; +} + +.admin-status-section h3, +.admin-operation-strip h3 { + margin: 0; + font-size: 17px; +} + +.admin-status-section header a, +.admin-operation-strip header a { + color: #171715; + font-size: 12px; + font-weight: 800; +} + +.admin-status-section dl { + margin: 0; +} + +.admin-status-section dl > div { + display: grid; + min-height: 52px; + grid-template-columns: 126px 1fr; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid #ddddD7; +} + +.admin-status-section dl > div:last-child { + border-bottom: 0; +} + +.admin-status-section dt { + color: #65655f; + font-size: 11px; +} + +.admin-status-section dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + font-family: Consolas, monospace; + font-size: 12px; + font-weight: 700; +} + +.admin-service-list { + margin: 0; + padding: 0; + list-style: none; +} + +.admin-service-list li { + display: grid; + min-height: 42px; + grid-template-columns: 1fr 84px 76px; + align-items: center; + padding: 0 16px; + border-bottom: 1px solid #ddddd7; + font-size: 11px; +} + +.admin-service-list li:last-child { + border-bottom: 0; +} + +.admin-service-list strong { + color: #1f6639; +} + +.admin-service-list strong.is-degraded, +.admin-service-list strong.is-paused { + color: #8b5608; +} + +.admin-service-list strong.is-unavailable { + color: #a52e24; +} + +.admin-service-list time { + color: #65655f; + text-align: right; +} + +.admin-operation-strip { + margin-top: 24px; +} + +.admin-operation-strip > p { + margin: 0; + padding: 22px 16px; + color: #65655f; +} + +.admin-operation-strip table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +.admin-operation-strip th, +.admin-operation-strip td { + padding: 12px 16px; + border-bottom: 1px solid #ddddd7; + overflow-wrap: anywhere; + text-align: left; + font-size: 11px; +} + +.admin-operation-strip th { + color: #65655f; + background: #efefeb; +} + +.admin-placeholder > section { + margin-top: 22px; +} + +.admin-placeholder-toolbar { + display: flex; + min-height: 58px; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + border-bottom: 1px solid #c3c3bc; + font-weight: 800; +} + +.admin-placeholder-toolbar button:disabled { + color: #777770; + background: #dfdfda; +} + +.admin-placeholder > section > p { + margin: 0; + padding: 44px 16px; + color: #65655f; +} + +:is(.admin-shell, .admin-session-gate) :focus-visible { + outline: 2px solid #225dd8; + outline-offset: 2px; +} + +@media (max-width: 1000px) { + .admin-overview, + .admin-placeholder { + width: calc(100% - 32px); + } + + .admin-metric-band { + grid-template-columns: 1fr 1fr; + } + + .admin-metric-band a:nth-child(2) { + border-right: 0; + } + + .admin-overview-columns { + grid-template-columns: 1fr; + } +} diff --git a/apps/web/src/admin-shell.tsx b/apps/web/src/admin-shell.tsx new file mode 100644 index 0000000..cee2c52 --- /dev/null +++ b/apps/web/src/admin-shell.tsx @@ -0,0 +1,229 @@ +import type { AdminOverviewResponse } from "@dada/shared-contracts"; +import { useCallback, useEffect, useState, type ReactNode } from "react"; + +import "./admin-shell.css"; + +interface AdminSession { + admin: { role: "super_admin"; status: "active"; user_id: string }; + audience: "admin"; + authenticated: true; + expires_at: string; +} + +interface AdminProtectedRouteProps { + children: ReactNode; + currentPath: string; + title: string; +} + +const adminNavigation = [ + { href: "/admin", label: "总览", marker: "01" }, + { href: "/admin/users", label: "用户与点数", marker: "02" }, + { href: "/admin/invites", label: "邀请码", marker: "03" }, + { href: "/admin/models", label: "模型", marker: "04" }, + { href: "/admin/assets", label: "素材", marker: "05" }, + { href: "/admin/preview", label: "内部预览", marker: "06" }, + { href: "/admin/generations", label: "生成记录", marker: "07" }, + { href: "/admin/services-storage", label: "服务与存储", marker: "08" }, + { href: "/admin/audit", label: "审计", marker: "09" }, +] as const; + +function redirectToAdminLogin() { + window.location.replace("/admin/login"); +} + +export function AdminProtectedRoute({ children, currentPath, title }: AdminProtectedRouteProps) { + const [session, setSession] = useState(); + const [failed, setFailed] = useState(false); + const [revision, setRevision] = useState(0); + + useEffect(() => { + const controller = new AbortController(); + setFailed(false); + void fetch("/api/v1/admin-auth/session", { credentials: "same-origin", signal: controller.signal }) + .then(async (response) => { + if (response.status === 401) { + redirectToAdminLogin(); + return; + } + if (!response.ok) throw new Error("admin_session_unavailable"); + const body = await response.json() as AdminSession; + if (body.audience !== "admin" || body.admin.role !== "super_admin" || body.admin.status !== "active") { + redirectToAdminLogin(); + return; + } + setSession(body); + }) + .catch((error: unknown) => { + if (!(error instanceof DOMException && error.name === "AbortError")) setFailed(true); + }); + return () => controller.abort(); + }, [revision]); + + if (!session) { + return ( +
+ {failed ? ( +
+ 管理员会话暂时无法确认 + +
+ ) :

正在确认管理员会话

} +
+ ); + } + + return ( +
+ 跳到主要内容 + +
+
+

{title}

+
+