feat: scaffold TASK-WP6-01 admin operations
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 10m45s

This commit is contained in:
suyx
2026-08-03 19:15:03 +08:00
parent f1bebab611
commit 19212cc1b6
17 changed files with 1760 additions and 17 deletions
+33
View File
@@ -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<AdminOverviewResponse>;
amap?: AmapAdapter;
assetReleases?: AssetReleaseReader;
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
@@ -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",
{
+1 -5
View File
@@ -147,11 +147,7 @@ export function AdminModelsPage() {
return (
<div className="admin-models-page">
<header className="admin-product-header">
<a href="/admin">DADA ADMIN</a>
<nav aria-label="后台导航"><a href="/admin/users"></a><a aria-current="page" href="/admin/models"></a><a href="/admin/audit"></a></nav>
</header>
<main>
<main id="admin-main">
<header className="admin-models-heading">
<div><p>MODEL OPERATIONS</p><h1></h1></div>
{configuration ? <strong> v{configuration.config_set_version}</strong> : null}
+523
View File
@@ -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;
}
}
+229
View File
@@ -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<AdminSession>();
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 (
<main className="admin-session-gate">
{failed ? (
<div role="alert">
<strong></strong>
<button onClick={() => setRevision((value) => value + 1)} type="button"></button>
</div>
) : <p aria-live="polite"></p>}
</main>
);
}
return (
<div className="admin-shell">
<a className="admin-skip-link" href="#admin-main"></a>
<aside className="admin-sidebar">
<a className="admin-wordmark" href="/admin" aria-label="Dada 后台总览">
<span>DADA</span>
<small>OPERATIONS</small>
</a>
<nav aria-label="后台主导航">
{adminNavigation.map((item) => (
<a aria-current={currentPath === item.href ? "page" : undefined} href={item.href} key={item.href}>
<span aria-hidden="true">{item.marker}</span>
{item.label}
</a>
))}
</nav>
<div className="admin-sidebar-foot">
<span>LOCAL P0-A</span>
<strong></strong>
</div>
</aside>
<div className="admin-shell-workspace">
<header className="admin-topbar">
<h1>{title}</h1>
<div className="admin-topbar-status">
<span><i aria-hidden="true" /></span>
<code>{session.admin.user_id.slice(0, 8)}</code>
</div>
</header>
<div className="admin-shell-content">{children}</div>
</div>
</div>
);
}
const serviceLabels: Record<AdminOverviewResponse["services"][number]["service_id"], string> = {
ai_gateway: "AI 网关",
amap: "高德",
asset_root: "素材根",
resend: "Resend",
worker: "Worker",
};
const stateLabels = {
available: "正常",
degraded: "有异常",
paused: "已暂停",
unavailable: "不可用",
} as const;
function formatTime(value: string | null) {
if (!value) return "未记录";
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", month: "2-digit", day: "2-digit" }).format(new Date(value));
}
export function AdminOverviewPage() {
const [summary, setSummary] = useState<AdminOverviewResponse>();
const [failed, setFailed] = useState(false);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
setFailed(false);
try {
const response = await fetch("/api/v1/admin/overview", { credentials: "same-origin" });
if (response.status === 401) {
window.dispatchEvent(new Event("dada:session-invalid"));
return;
}
if (!response.ok) throw new Error("admin_overview_unavailable");
setSummary(await response.json() as AdminOverviewResponse);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void load(); }, [load]);
const storagePercent = summary
? Math.min(100, (summary.storage.managed_content_bytes / summary.storage.limit_bytes) * 100)
: 0;
const hasServiceIssue = summary?.services.some((service) => service.status !== "available") ?? false;
return (
<main className="admin-overview" id="admin-main">
<header className="admin-page-heading">
<div><p>OPERATIONS / LIVE SUMMARY</p><h2></h2></div>
{summary ? <time dateTime={summary.generated_at}> {formatTime(summary.generated_at)}</time> : null}
</header>
{summary && summary.storage.status !== "normal" ? (
<a className={`admin-capacity-alert is-${summary.storage.status}`} href="/admin/services-storage">
<span></span>
<strong>{storagePercent.toFixed(1)}%</strong>
<span>{summary.storage.status === "critical" ? "接近上限" : summary.storage.status === "full" ? "已满" : "不可用"}</span>
</a>
) : null}
{loading && !summary ? (
<div aria-label="运营摘要加载中" className="admin-overview-loading"><span /><span /><span /><span /></div>
) : null}
{failed ? (
<div className="admin-overview-failure" role="alert">
<span>{summary ? `,当前保留 ${formatTime(summary.generated_at)} 的结果` : ""}</span>
<button onClick={() => void load()} type="button"></button>
</div>
) : null}
{summary ? (
<>
<section aria-label="关键运营指标" className="admin-metric-band">
<a href="/admin/users"><span></span><strong>{summary.user_slots.active_and_suspended} / {summary.user_slots.limit}</strong><small>active + suspended</small></a>
<a href="/admin/generations"><span></span><strong>{summary.generation_jobs.queued + summary.generation_jobs.running}</strong><small> {summary.generation_jobs.queued} · {summary.generation_jobs.running}</small></a>
<a href="/admin/generations"><span></span><strong> {summary.generation_jobs.pending_manual_review}</strong><small> {formatTime(summary.generation_jobs.pending_manual_review_oldest_at)}</small></a>
<a href="/admin/assets"><span></span><strong>{summary.asset_cleanup.pending_jobs}</strong><small></small></a>
</section>
<div className="admin-overview-columns">
<section className="admin-status-section" aria-labelledby="model-status-heading">
<header><div><p>MODEL STATE</p><h3 id="model-status-heading"></h3></div><a href="/admin/models"></a></header>
<dl>
<div><dt></dt><dd>{summary.models.configured_default_model_id ?? "无"}</dd></div>
<div><dt></dt><dd>{summary.models.runtime_available_count} / {summary.models.configured_model_count}</dd></div>
<div><dt></dt><dd>{summary.models.recommended_model_id ?? "无"}</dd></div>
</dl>
</section>
<section className="admin-status-section" aria-labelledby="service-status-heading">
<header><div><p>SERVICE STATE</p><h3 id="service-status-heading"></h3></div><a href="/admin/services-storage">{hasServiceIssue ? "有异常" : "全部正常"}</a></header>
<ul className="admin-service-list">
{summary.services.map((service) => <li key={service.service_id}><span>{serviceLabels[service.service_id]}</span><strong className={`is-${service.status}`}>{stateLabels[service.status]}</strong><time dateTime={service.checked_at ?? undefined}>{formatTime(service.checked_at)}</time></li>)}
</ul>
</section>
</div>
<section className="admin-operation-strip" aria-labelledby="recent-operation-heading">
<header><div><p>AUDIT SNAPSHOT</p><h3 id="recent-operation-heading"></h3></div><a href="/admin/audit"></a></header>
{summary.recent_operations.length === 0 ? <p></p> : (
<table><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{summary.recent_operations.map((operation) => <tr key={operation.operation_id}><td>{formatTime(operation.created_at)}</td><td>{operation.operation_type}</td><td><code>{operation.target_ref}</code></td><td>{operation.result}</td></tr>)}</tbody></table>
)}
</section>
</>
) : null}
</main>
);
}
export function AdminPlaceholderPage({ title }: { title: string }) {
return (
<main className="admin-placeholder" id="admin-main">
<header className="admin-page-heading"><div><p>OPERATIONS</p><h2>{title}</h2></div></header>
<section aria-label={`${title}安全摘要`}>
<div className="admin-placeholder-toolbar"><span></span><button disabled type="button"></button></div>
<p></p>
</section>
</main>
);
}
+1 -5
View File
@@ -100,11 +100,7 @@ export function AdminUsersPage() {
return (
<div className="admin-users-page">
<header className="admin-product-header">
<a href="/admin">DADA ADMIN</a>
<nav aria-label="后台导航"><a aria-current="page" href="/admin/users"></a><a href="/admin/models"></a><a href="/admin/audit"></a></nav>
</header>
<main>
<main id="admin-main">
<header className="admin-users-heading">
<div><p>USER OPERATIONS</p><h1></h1></div>
{balance ? <button onClick={openAdjustment} type="button"></button> : null}
+8 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminOverviewResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -87,6 +87,13 @@ export async function getAccountSettings(options: ClientOptions = {}): Promise<A
return response.json() as Promise<AccountSettingsResponse>;
}
export async function getAdminOverview(options: ClientOptions = {}): Promise<AdminOverviewResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin/overview`, { method: "GET", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<AdminOverviewResponse>;
}
export async function getAdminSession(options: ClientOptions = {}): Promise<AdminSessionResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/admin-auth/session`, { method: "GET", headers: options.headers ?? {} });
+41
View File
@@ -76,6 +76,47 @@ export type AdminLoginSendRequest = {
"email": string;
};
export type AdminOverviewResponse = {
"asset_cleanup": {
"pending_jobs": number;
};
"generated_at": string;
"generation_jobs": {
"pending_manual_review": number;
"pending_manual_review_oldest_at": string | null;
"queued": number;
"running": number;
};
"models": {
"configured_default_model_id": string | null;
"configured_model_count": number;
"recommended_model_id": string | null;
"runtime_available_count": number;
};
"recent_operations": Array<{
"created_at": string;
"operation_id": string;
"operation_type": string;
"result": "succeeded" | "rejected" | "failed";
"target_ref": string;
}>;
"services": Array<{
"checked_at": string | null;
"service_id": "resend" | "amap" | "ai_gateway" | "worker" | "asset_root";
"status": "available" | "degraded" | "paused" | "unavailable";
}>;
"storage": {
"last_measured_at": string | null;
"limit_bytes": number;
"managed_content_bytes": number;
"status": "normal" | "critical" | "full" | "unavailable";
};
"user_slots": {
"active_and_suspended": number;
"limit": number;
};
};
export type AdminSessionResponse = {
"acknowledged_private_content_notice_version": string | null;
"admin": AdminAuthenticatedUser;
+22 -4
View File
@@ -1,4 +1,4 @@
import { StrictMode } from "react";
import { StrictMode, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { registerPublicAssetServiceWorker } from "./public-asset-cache.js";
@@ -10,6 +10,7 @@ import { AdminModelsPage } from "./admin-models.js";
import { CreditsPage } from "./credits-page.js";
import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.js";
import { EditorPage } from "./editor-page.js";
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
const root = document.getElementById("root");
@@ -34,9 +35,26 @@ function renderAuthenticationEntry() {
else if (projectDetail?.[1]) authenticationPage = <ProjectDetailPage key={authRevision} projectId={projectDetail[1]} />;
else if (window.location.pathname === "/app/projects") authenticationPage = <ProjectsPage key={authRevision} />;
else if (window.location.pathname === "/app") authenticationPage = <WorkspacePage key={authRevision} />;
else if (window.location.pathname === "/admin/users") authenticationPage = <AdminUsersPage key={authRevision} />;
else if (window.location.pathname === "/admin/models") authenticationPage = <AdminModelsPage key={authRevision} />;
else if (window.location.pathname.startsWith("/admin")) authenticationPage = <AdminAuthPage key={authRevision} />;
else if (window.location.pathname === "/admin/login") authenticationPage = <AdminAuthPage key={authRevision} />;
else if (window.location.pathname.startsWith("/admin")) {
const adminPages: Record<string, { content: ReactNode; title: string }> = {
"/admin": { content: <AdminOverviewPage />, title: "运营总览" },
"/admin/assets": { content: <AdminPlaceholderPage title="素材" />, title: "素材" },
"/admin/audit": { content: <AdminPlaceholderPage title="审计" />, title: "审计" },
"/admin/generations": { content: <AdminPlaceholderPage title="生成记录" />, title: "生成记录" },
"/admin/invites": { content: <AdminPlaceholderPage title="邀请码" />, title: "邀请码" },
"/admin/models": { content: <AdminModelsPage />, title: "模型" },
"/admin/preview": { content: <AdminPlaceholderPage title="内部预览" />, title: "内部预览" },
"/admin/services-storage": { content: <AdminPlaceholderPage title="服务与存储" />, title: "服务与存储" },
"/admin/users": { content: <AdminUsersPage />, title: "用户与点数" },
};
const page = adminPages[window.location.pathname] ?? adminPages["/admin"]!;
authenticationPage = (
<AdminProtectedRoute currentPath={window.location.pathname} key={authRevision} title={page.title}>
{page.content}
</AdminProtectedRoute>
);
}
else authenticationPage = <UserAuthPage key={authRevision} />;
appRoot.render(
<StrictMode>