Initial WallMuse project
This commit is contained in:
12
apps/admin/index.html
Executable file
12
apps/admin/index.html
Executable file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WallMuse Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
apps/admin/package.json
Executable file
25
apps/admin/package.json
Executable file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wallmuse/admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@wallmuse/api-client": "workspace:*",
|
||||
"vite": "^6.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"lucide-react": "^0.468.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3"
|
||||
}
|
||||
}
|
||||
727
apps/admin/src/main.tsx
Executable file
727
apps/admin/src/main.tsx
Executable file
@@ -0,0 +1,727 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
Activity,
|
||||
Aperture,
|
||||
Bell,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Database,
|
||||
Gauge,
|
||||
Image,
|
||||
Lock,
|
||||
LogOut,
|
||||
Moon,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Sun,
|
||||
Users,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import {
|
||||
WallMuseApiClient,
|
||||
type AdminDashboard,
|
||||
type AdminModel,
|
||||
type AdminProvider,
|
||||
type AdminTask,
|
||||
type AdminUser,
|
||||
type GalleryReviewItem,
|
||||
type PublicUser,
|
||||
type SystemSettings
|
||||
} from "@wallmuse/api-client";
|
||||
import "./styles.css";
|
||||
|
||||
type ViewKey = "dashboard" | "users" | "providers" | "models" | "tasks" | "gallery" | "settings";
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_WALLMUSE_API_BASE_URL ?? "";
|
||||
|
||||
const navItems: Array<{ key: ViewKey; label: string; icon: React.ComponentType<{ size?: number }> }> = [
|
||||
{ key: "dashboard", label: "仪表盘", icon: Gauge },
|
||||
{ key: "users", label: "用户管理", icon: Users },
|
||||
{ key: "providers", label: "供应商管理", icon: Database },
|
||||
{ key: "models", label: "模型管理", icon: Aperture },
|
||||
{ key: "tasks", label: "任务管理", icon: Activity },
|
||||
{ key: "gallery", label: "图库审核", icon: Image },
|
||||
{ key: "settings", label: "系统设置", icon: Settings }
|
||||
];
|
||||
|
||||
const createClient = (token?: string) =>
|
||||
new WallMuseApiClient({
|
||||
baseUrl: apiBaseUrl,
|
||||
accessToken: token,
|
||||
mockFallback: true
|
||||
});
|
||||
|
||||
function App() {
|
||||
const [token, setToken] = useState(() => localStorage.getItem("wallmuse_admin_token") ?? "");
|
||||
const [currentUser, setCurrentUser] = useState<PublicUser | null>(null);
|
||||
const [view, setView] = useState<ViewKey>("dashboard");
|
||||
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem("wallmuse_admin_theme") as Theme) || "light");
|
||||
const [loadingUser, setLoadingUser] = useState(Boolean(token));
|
||||
|
||||
const client = useMemo(() => createClient(token), [token]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem("wallmuse_admin_theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoadingUser(false);
|
||||
return;
|
||||
}
|
||||
client
|
||||
.getCurrentUser()
|
||||
.then(setCurrentUser)
|
||||
.catch(() => {
|
||||
localStorage.removeItem("wallmuse_admin_token");
|
||||
setToken("");
|
||||
})
|
||||
.finally(() => setLoadingUser(false));
|
||||
}, [client, token]);
|
||||
|
||||
const onAuthenticated = (nextToken: string, user: PublicUser) => {
|
||||
localStorage.setItem("wallmuse_admin_token", nextToken);
|
||||
setToken(nextToken);
|
||||
setCurrentUser(user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("wallmuse_admin_token");
|
||||
setToken("");
|
||||
setCurrentUser(null);
|
||||
};
|
||||
|
||||
if (loadingUser) {
|
||||
return <FullPageStatus label="正在验证管理会话" />;
|
||||
}
|
||||
|
||||
if (!token || !currentUser) {
|
||||
return <LoginPage onAuthenticated={onAuthenticated} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
client={client}
|
||||
currentUser={currentUser}
|
||||
theme={theme}
|
||||
view={view}
|
||||
onChangeView={setView}
|
||||
onToggleTheme={() => setTheme(theme === "light" ? "dark" : "light")}
|
||||
onLogout={logout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage({ onAuthenticated }: { onAuthenticated: (token: string, user: PublicUser) => void }) {
|
||||
const [email, setEmail] = useState("admin@wallmuse.local");
|
||||
const [password, setPassword] = useState("password123");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const auth = await createClient().login(email, password);
|
||||
if (!auth.user.roles.some((role) => role === "admin" || role === "super_admin")) {
|
||||
setError("该账号没有后台管理权限");
|
||||
return;
|
||||
}
|
||||
onAuthenticated(auth.token, auth.user);
|
||||
} catch {
|
||||
setError("登录失败,请确认 API 已启动并存在管理员账号");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-shell glass-panel">
|
||||
<div className="brand-block">
|
||||
<LogoMark />
|
||||
<div>
|
||||
<p className="eyebrow">WallMuse Admin</p>
|
||||
<h1>运营后台</h1>
|
||||
</div>
|
||||
</div>
|
||||
<form className="login-card" onSubmit={submit}>
|
||||
<label>
|
||||
管理员邮箱
|
||||
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<button className="primary-button" type="submit" disabled={submitting}>
|
||||
<Lock size={16} />
|
||||
{submitting ? "登录中" : "进入后台"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminShell(props: {
|
||||
client: WallMuseApiClient;
|
||||
currentUser: PublicUser;
|
||||
theme: Theme;
|
||||
view: ViewKey;
|
||||
onChangeView: (view: ViewKey) => void;
|
||||
onToggleTheme: () => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const active = navItems.find((item) => item.key === props.view) ?? navItems[0]!;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-shell glass-panel">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<LogoMark />
|
||||
<div>
|
||||
<strong>WallMuse</strong>
|
||||
<span>Control Center</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="nav-list">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
className={item.key === props.view ? "active" : ""}
|
||||
onClick={() => props.onChangeView(item.key)}
|
||||
type="button"
|
||||
>
|
||||
<Icon size={18} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="sidebar-status">
|
||||
<span className="status-dot" />
|
||||
API client routed
|
||||
</div>
|
||||
</aside>
|
||||
<section className="workspace">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">管理后台</p>
|
||||
<h1>{active.label}</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<label className="search-box">
|
||||
<Search size={16} />
|
||||
<input placeholder="搜索用户、任务、模型" />
|
||||
</label>
|
||||
<button className="icon-button" onClick={props.onToggleTheme} type="button" title="切换主题">
|
||||
{props.theme === "light" ? <Moon size={17} /> : <Sun size={17} />}
|
||||
</button>
|
||||
<button className="icon-button" type="button" title="通知">
|
||||
<Bell size={17} />
|
||||
</button>
|
||||
<div className="user-chip">
|
||||
<span>{props.currentUser.name.slice(0, 1).toUpperCase()}</span>
|
||||
<div>
|
||||
<strong>{props.currentUser.name}</strong>
|
||||
<small>{props.currentUser.roles.join(" / ")}</small>
|
||||
</div>
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
<button className="icon-button danger" onClick={props.onLogout} type="button" title="退出">
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="content">{renderView(props.view, props.client)}</main>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderView(view: ViewKey, client: WallMuseApiClient) {
|
||||
if (view === "dashboard") return <DashboardView client={client} />;
|
||||
if (view === "users") return <UsersView client={client} />;
|
||||
if (view === "providers") return <ProvidersView client={client} />;
|
||||
if (view === "models") return <ModelsView client={client} />;
|
||||
if (view === "tasks") return <TasksView client={client} />;
|
||||
if (view === "gallery") return <GalleryView client={client} />;
|
||||
return <SettingsView client={client} />;
|
||||
}
|
||||
|
||||
function DashboardView({ client }: { client: WallMuseApiClient }) {
|
||||
const { data, loading, reload } = useApiData(() => client.getDashboard(), [client]);
|
||||
|
||||
if (loading || !data) return <SectionSkeleton title="仪表盘数据加载中" />;
|
||||
|
||||
return (
|
||||
<div className="view-stack">
|
||||
<Toolbar title="核心指标" description="生成、成本、队列和内容审核概览" onRefresh={reload} />
|
||||
<div className="kpi-grid">
|
||||
<KpiCard label="今日生成" value={data.kpis.todayGenerations.toLocaleString()} delta="+18%" tone="accent" />
|
||||
<KpiCard label="成功率" value={`${data.kpis.successRate}%`} delta="-0.7%" tone="success" />
|
||||
<KpiCard label="失败率" value={`${data.kpis.failedRate}%`} delta="+0.4%" tone="danger" />
|
||||
<KpiCard label="消耗积分" value={data.kpis.costCredits.toLocaleString()} delta="+9%" tone="warning" />
|
||||
<KpiCard label="活跃用户" value={data.kpis.activeUsers.toLocaleString()} delta="+6%" tone="success" />
|
||||
<KpiCard label="待审图库" value={data.kpis.galleryPending.toLocaleString()} delta="需处理" tone="warning" />
|
||||
</div>
|
||||
<div className="dashboard-grid">
|
||||
<TrendPanel data={data} />
|
||||
<QueuePanel data={data} />
|
||||
</div>
|
||||
<div className="split-grid">
|
||||
<ProviderHealth providers={data.providers} />
|
||||
<TaskTable tasks={data.recentTasks} compact />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersView({ client }: { client: WallMuseApiClient }) {
|
||||
const { data } = useApiData(() => client.getUsers(), [client]);
|
||||
return (
|
||||
<DataPanel title="用户管理" description="查询、禁用、额度和 API Key 风险概览">
|
||||
<table className="dense-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>状态</th>
|
||||
<th>套餐</th>
|
||||
<th>积分</th>
|
||||
<th>API Key</th>
|
||||
<th>生成数</th>
|
||||
<th>最近活跃</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<strong>{user.name}</strong>
|
||||
<small>{user.email}</small>
|
||||
</td>
|
||||
<td><StatusPill status={user.status} /></td>
|
||||
<td>{user.plan}</td>
|
||||
<td>{user.credits.toLocaleString()}</td>
|
||||
<td>{user.apiKeyCount}</td>
|
||||
<td>{user.generationCount}</td>
|
||||
<td>{formatDate(user.lastActiveAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function ProvidersView({ client }: { client: WallMuseApiClient }) {
|
||||
const { data } = useApiData(() => client.getProviders(), [client]);
|
||||
return (
|
||||
<DataPanel title="供应商管理" description="Base URL、鉴权方式、限流和健康检查集中配置">
|
||||
<table className="dense-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应商</th>
|
||||
<th>状态</th>
|
||||
<th>Base URL</th>
|
||||
<th>Key 模式</th>
|
||||
<th>模型</th>
|
||||
<th>日限额</th>
|
||||
<th>成功率</th>
|
||||
<th>延迟</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((provider) => (
|
||||
<tr key={provider.id}>
|
||||
<td>
|
||||
<strong>{provider.name}</strong>
|
||||
<small>{provider.code}</small>
|
||||
</td>
|
||||
<td><StatusPill status={provider.status} /></td>
|
||||
<td className="mono">{provider.baseUrl}</td>
|
||||
<td>{provider.keyMode}</td>
|
||||
<td>{provider.modelCount}</td>
|
||||
<td>{provider.dailyLimit.toLocaleString()}</td>
|
||||
<td>{provider.successRate}%</td>
|
||||
<td>{provider.avgLatencyMs}ms</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsView({ client }: { client: WallMuseApiClient }) {
|
||||
const { data } = useApiData(() => client.getModels(), [client]);
|
||||
return (
|
||||
<DataPanel title="模型管理" description="模型能力、尺寸映射、价格和启停状态">
|
||||
<div className="model-grid">
|
||||
{(data ?? []).map((model) => (
|
||||
<article className="model-card" key={model.id}>
|
||||
<div className="model-head">
|
||||
<div>
|
||||
<strong>{model.name}</strong>
|
||||
<small className="mono">{model.modelId}</small>
|
||||
</div>
|
||||
<StatusPill status={model.status} />
|
||||
</div>
|
||||
<div className="tag-row">{model.modes.map((item) => <span key={item}>{item}</span>)}</div>
|
||||
<dl className="metric-list">
|
||||
<div><dt>分辨率</dt><dd>{model.resolutions.join(" / ")}</dd></div>
|
||||
<div><dt>比例</dt><dd>{model.ratios.join(" / ")}</dd></div>
|
||||
<div><dt>原生 4K</dt><dd>{model.native4k ? "支持" : "超分"}</dd></div>
|
||||
<div><dt>价格</dt><dd>{model.priceCredits} credits</dd></div>
|
||||
<div><dt>批量</dt><dd>{model.maxBatchSize}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksView({ client }: { client: WallMuseApiClient }) {
|
||||
const { data, reload } = useApiData(() => client.getTasks(), [client]);
|
||||
return (
|
||||
<DataPanel title="任务管理" description="队列状态、重试、错误详情和手动取消入口" onRefresh={reload}>
|
||||
<TaskTable tasks={data ?? []} />
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryView({ client }: { client: WallMuseApiClient }) {
|
||||
const { data } = useApiData(() => client.getGalleryReviewItems(), [client]);
|
||||
return (
|
||||
<DataPanel title="图库审核" description="公开图库入库审核、推荐、标签和风险等级">
|
||||
<div className="gallery-review-grid">
|
||||
{(data ?? []).map((item) => (
|
||||
<article className="review-card" key={item.id}>
|
||||
<img src={item.imageUrl} alt={item.title} />
|
||||
<div className="review-body">
|
||||
<div className="model-head">
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.authorEmail}</small>
|
||||
</div>
|
||||
<StatusPill status={item.status} />
|
||||
</div>
|
||||
<p>{item.prompt}</p>
|
||||
<div className="tag-row">
|
||||
<span>{item.ratio}</span>
|
||||
<span>{item.resolution}</span>
|
||||
<span>risk: {item.riskLevel}</span>
|
||||
{item.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||
</div>
|
||||
<div className="review-actions">
|
||||
<button type="button"><CheckCircle2 size={15} />通过</button>
|
||||
<button type="button"><Sparkles size={15} />推荐</button>
|
||||
<button className="danger" type="button"><XCircle size={15} />拒绝</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsView({ client }: { client: WallMuseApiClient }) {
|
||||
const { data } = useApiData(() => client.getSystemSettings(), [client]);
|
||||
const settings = data as SystemSettings | undefined;
|
||||
return (
|
||||
<DataPanel title="系统设置" description="站点、图库、内容安全、存储、CDN 与下载策略">
|
||||
{settings ? (
|
||||
<div className="settings-grid">
|
||||
<SettingsField label="站点名称" value={settings.siteName} />
|
||||
<SettingsField label="支持邮箱" value={settings.supportEmail} />
|
||||
<SettingsField label="默认分辨率" value={settings.defaultResolution} />
|
||||
<SettingsField label="对象存储" value={settings.storageProvider} />
|
||||
<SettingsField label="CDN Base URL" value={settings.cdnBaseUrl} wide />
|
||||
<ToggleRow label="公开图库" active={settings.publicGalleryEnabled} />
|
||||
<ToggleRow label="注册入口" active={settings.registrationEnabled} />
|
||||
<ToggleRow label="Prompt 审核" active={settings.promptModerationEnabled} />
|
||||
<ToggleRow label="下载水印" active={settings.watermarkEnabled} />
|
||||
</div>
|
||||
) : (
|
||||
<SectionSkeleton title="系统设置加载中" />
|
||||
)}
|
||||
</DataPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskTable({ tasks, compact = false }: { tasks: AdminTask[]; compact?: boolean }) {
|
||||
return (
|
||||
<table className={`dense-table ${compact ? "compact-table" : ""}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>状态</th>
|
||||
<th>用户</th>
|
||||
<th>输出</th>
|
||||
<th>分辨率</th>
|
||||
<th>进度</th>
|
||||
<th>成本</th>
|
||||
<th>更新时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id}>
|
||||
<td>
|
||||
<strong>{task.id}</strong>
|
||||
<small>{task.prompt}</small>
|
||||
</td>
|
||||
<td><StatusPill status={task.status} /></td>
|
||||
<td>{task.userEmail}</td>
|
||||
<td>{task.outputs.join(" + ")}</td>
|
||||
<td>{task.resolutionTier}</td>
|
||||
<td><Progress value={task.progress} /></td>
|
||||
<td>{task.costCredits}</td>
|
||||
<td>{formatDate(task.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendPanel({ data }: { data: AdminDashboard }) {
|
||||
const max = Math.max(...data.generationTrend.map((item) => item.value));
|
||||
return (
|
||||
<section className="panel chart-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>七日生成趋势</h2>
|
||||
<p>按任务组统计,失败量叠加显示</p>
|
||||
</div>
|
||||
<SlidersHorizontal size={18} />
|
||||
</div>
|
||||
<div className="bar-chart">
|
||||
{data.generationTrend.map((item) => (
|
||||
<div className="bar-item" key={item.label}>
|
||||
<div className="bar-track">
|
||||
<span style={{ height: `${(item.value / max) * 100}%` }} />
|
||||
<i style={{ height: `${Math.max(8, (item.failed / max) * 100)}%` }} />
|
||||
</div>
|
||||
<small>{item.label}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function QueuePanel({ data }: { data: AdminDashboard }) {
|
||||
return (
|
||||
<section className="panel queue-panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>队列状态</h2>
|
||||
<p>Worker 消费与失败积压</p>
|
||||
</div>
|
||||
<Activity size={18} />
|
||||
</div>
|
||||
<dl className="queue-metrics">
|
||||
<div><dt>等待</dt><dd>{data.queue.waiting}</dd></div>
|
||||
<div><dt>运行</dt><dd>{data.queue.running}</dd></div>
|
||||
<div><dt>失败</dt><dd>{data.queue.failed}</dd></div>
|
||||
<div><dt>P95 延迟</dt><dd>{data.queue.p95LatencyMs}ms</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderHealth({ providers }: { providers: AdminProvider[] }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-head">
|
||||
<div>
|
||||
<h2>供应商健康</h2>
|
||||
<p>成功率、延迟和限额</p>
|
||||
</div>
|
||||
<ShieldCheck size={18} />
|
||||
</div>
|
||||
<div className="provider-list">
|
||||
{providers.map((provider) => (
|
||||
<div className="provider-row" key={provider.id}>
|
||||
<div>
|
||||
<strong>{provider.name}</strong>
|
||||
<small>{provider.avgLatencyMs}ms avg</small>
|
||||
</div>
|
||||
<Progress value={provider.successRate} />
|
||||
<StatusPill status={provider.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DataPanel(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
onRefresh?: (() => void) | undefined;
|
||||
}) {
|
||||
return (
|
||||
<section className="panel data-panel">
|
||||
<Toolbar title={props.title} description={props.description} onRefresh={props.onRefresh} />
|
||||
{props.children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
title,
|
||||
description,
|
||||
onRefresh
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
onRefresh?: (() => void) | undefined;
|
||||
}) {
|
||||
return (
|
||||
<div className="panel-head toolbar">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<button type="button"><Search size={15} />筛选</button>
|
||||
<button type="button" onClick={onRefresh}><RefreshCcw size={15} />刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KpiCard({ label, value, delta, tone }: { label: string; value: string; delta: string; tone: string }) {
|
||||
return (
|
||||
<article className={`kpi-card tone-${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{delta}</small>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
return <span className={`status-pill status-${status.replace("_", "-")}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function Progress({ value }: { value: number }) {
|
||||
return (
|
||||
<div className="progress">
|
||||
<span style={{ width: `${Math.min(Math.max(value, 0), 100)}%` }} />
|
||||
<small>{Math.round(value)}%</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsField({ label, value, wide = false }: { label: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<label className={wide ? "wide" : ""}>
|
||||
{label}
|
||||
<input value={value} readOnly />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<div className="toggle-row">
|
||||
<span>{label}</span>
|
||||
<button className={active ? "toggle active" : "toggle"} type="button" aria-pressed={active}>
|
||||
<span />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogoMark() {
|
||||
return (
|
||||
<div className="logo-mark" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionSkeleton({ title }: { title: string }) {
|
||||
return (
|
||||
<section className="panel skeleton">
|
||||
<Sparkles size={18} />
|
||||
<span>{title}</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FullPageStatus({ label }: { label: string }) {
|
||||
return (
|
||||
<main className="login-page">
|
||||
<section className="login-shell glass-panel skeleton">
|
||||
<LogoMark />
|
||||
<span>{label}</span>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function useApiData<T>(loader: () => Promise<T>, deps: React.DependencyList) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
loader()
|
||||
.then((next) => {
|
||||
if (active) setData(next);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [...deps, version]);
|
||||
|
||||
return { data, loading, reload: () => setVersion((value) => value + 1) };
|
||||
}
|
||||
|
||||
const formatDate = (value: string) =>
|
||||
new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(new Date(value));
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
803
apps/admin/src/styles.css
Executable file
803
apps/admin/src/styles.css
Executable file
@@ -0,0 +1,803 @@
|
||||
:root {
|
||||
--color-bg-page: #edf6ff;
|
||||
--color-bg-surface: rgba(255, 255, 255, 0.62);
|
||||
--color-bg-surface-strong: rgba(255, 255, 255, 0.82);
|
||||
--color-bg-control: rgba(255, 255, 255, 0.56);
|
||||
--color-bg-control-active: rgba(230, 240, 255, 0.86);
|
||||
--color-border-soft: rgba(120, 150, 190, 0.2);
|
||||
--color-border-strong: rgba(120, 160, 220, 0.36);
|
||||
--color-text-primary: #172235;
|
||||
--color-text-secondary: #617087;
|
||||
--color-text-muted: #97a4b7;
|
||||
--color-accent: #2f86ff;
|
||||
--color-accent-soft: #dceaff;
|
||||
--color-success: #20c997;
|
||||
--color-warning: #f7b955;
|
||||
--color-danger: #ff5c7a;
|
||||
--font-xs: 12px;
|
||||
--font-sm: 13px;
|
||||
--font-md: 14px;
|
||||
--font-lg: 16px;
|
||||
--font-xl: 20px;
|
||||
--font-2xl: 28px;
|
||||
--radius-xs: 8px;
|
||||
--radius-sm: 12px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 24px;
|
||||
--radius-xl: 36px;
|
||||
--radius-full: 999px;
|
||||
--shadow-shell: 0 28px 80px rgba(80, 130, 190, 0.18);
|
||||
--shadow-card: 0 14px 34px rgba(37, 60, 90, 0.16);
|
||||
--shadow-floating: 0 18px 42px rgba(40, 80, 130, 0.22);
|
||||
--glass-blur: blur(22px) saturate(140%);
|
||||
color: var(--color-text-primary);
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
font-size: var(--font-md);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--color-bg-page: #07111f;
|
||||
--color-bg-surface: rgba(15, 27, 45, 0.68);
|
||||
--color-bg-surface-strong: rgba(18, 31, 52, 0.88);
|
||||
--color-bg-control: rgba(23, 38, 62, 0.72);
|
||||
--color-bg-control-active: rgba(35, 78, 135, 0.76);
|
||||
--color-border-soft: rgba(170, 205, 255, 0.14);
|
||||
--color-border-strong: rgba(95, 165, 255, 0.52);
|
||||
--color-text-primary: #eef6ff;
|
||||
--color-text-secondary: #a7b6ca;
|
||||
--color-text-muted: #687a92;
|
||||
--color-accent: #66aaff;
|
||||
--color-accent-soft: rgba(102, 170, 255, 0.18);
|
||||
--color-success: #35d8a6;
|
||||
--color-warning: #ffd166;
|
||||
--color-danger: #ff6b8b;
|
||||
--shadow-shell: 0 28px 80px rgba(0, 0, 0, 0.42);
|
||||
--shadow-card: 0 16px 38px rgba(0, 0, 0, 0.34);
|
||||
--shadow-floating: 0 20px 48px rgba(0, 0, 0, 0.46);
|
||||
--glass-blur: blur(24px) saturate(130%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1180px;
|
||||
background:
|
||||
linear-gradient(145deg, var(--color-bg-page), rgba(210, 232, 255, 0.66) 48%, var(--color-bg-page)),
|
||||
var(--color-bg-page);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-page,
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.glass-panel,
|
||||
.panel,
|
||||
.kpi-card,
|
||||
.model-card,
|
||||
.review-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
max-width: 1800px;
|
||||
height: calc(100vh - 56px);
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-shell);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 28px 18px;
|
||||
border-right: 1px solid var(--color-border-soft);
|
||||
background: rgba(255, 255, 255, 0.26);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .sidebar {
|
||||
background: rgba(10, 20, 35, 0.26);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.brand-block,
|
||||
.user-chip,
|
||||
.topbar-actions,
|
||||
.panel-head,
|
||||
.toolbar-actions,
|
||||
.review-actions,
|
||||
.model-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.brand-block {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-size: var(--font-lg);
|
||||
}
|
||||
|
||||
.brand span,
|
||||
small,
|
||||
.panel-head p,
|
||||
.eyebrow {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.logo-mark {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 5px);
|
||||
align-items: end;
|
||||
gap: 3px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-control-active);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
}
|
||||
|
||||
.logo-mark span {
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, #8fc8ff, var(--color-accent));
|
||||
}
|
||||
|
||||
.logo-mark span:nth-child(1) { height: 14px; }
|
||||
.logo-mark span:nth-child(2) { height: 22px; }
|
||||
.logo-mark span:nth-child(3) { height: 18px; }
|
||||
.logo-mark span:nth-child(4) { height: 27px; }
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.nav-list button,
|
||||
.toolbar-actions button,
|
||||
.review-actions button,
|
||||
.icon-button,
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--color-border-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-control);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.nav-list button {
|
||||
justify-content: flex-start;
|
||||
padding: 0 13px;
|
||||
}
|
||||
|
||||
.nav-list button.active,
|
||||
.primary-button {
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, var(--color-accent), #6fb5ff);
|
||||
border-color: transparent;
|
||||
box-shadow: 0 12px 24px rgba(47, 134, 255, 0.22);
|
||||
}
|
||||
|
||||
.sidebar-status {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-control);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: 88px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px 28px;
|
||||
border-bottom: 1px solid var(--color-border-soft);
|
||||
background: var(--color-bg-surface-strong);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-2xl);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-xl);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--font-xs);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 280px;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
background: var(--color-bg-control);
|
||||
}
|
||||
|
||||
.search-box input,
|
||||
.login-card input,
|
||||
.settings-grid input {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--color-text-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.icon-button.danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
gap: 10px;
|
||||
min-width: 190px;
|
||||
padding: 6px 10px 6px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-control);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
}
|
||||
|
||||
.user-chip > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.user-chip strong,
|
||||
.user-chip small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.user-chip small {
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 24px 28px 32px;
|
||||
}
|
||||
|
||||
.view-stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: var(--radius-md);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-actions button,
|
||||
.review-actions button {
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 118px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.kpi-card span,
|
||||
.kpi-card small {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.kpi-card strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.tone-accent { border-color: rgba(47, 134, 255, 0.3); }
|
||||
.tone-success { border-color: rgba(32, 201, 151, 0.3); }
|
||||
.tone-danger { border-color: rgba(255, 92, 122, 0.3); }
|
||||
.tone-warning { border-color: rgba(247, 185, 85, 0.34); }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 0.7fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 0.9fr) minmax(0, 1.4fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.bar-chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
height: 250px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
height: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bar-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-control);
|
||||
}
|
||||
|
||||
.bar-track span,
|
||||
.bar-track i {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 42%;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
|
||||
.bar-track span {
|
||||
background: linear-gradient(180deg, #83c2ff, var(--color-accent));
|
||||
}
|
||||
|
||||
.bar-track i {
|
||||
right: 12%;
|
||||
width: 16%;
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.queue-metrics,
|
||||
.metric-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.queue-metrics {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.queue-metrics div,
|
||||
.metric-list div {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-control);
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 4px 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.provider-row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.progress > span {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-bg-control-active);
|
||||
}
|
||||
|
||||
.progress > span::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: inherit;
|
||||
border-radius: inherit;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.progress small {
|
||||
min-width: 34px;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.dense-table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
border-collapse: separate;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.dense-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-surface-strong);
|
||||
}
|
||||
|
||||
.dense-table th,
|
||||
.dense-table td {
|
||||
padding: 11px 12px;
|
||||
border-bottom: 1px solid var(--color-border-soft);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dense-table td strong,
|
||||
.dense-table td small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dense-table td small {
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compact-table td small {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "JetBrains Mono", SFMono-Regular, monospace;
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 0 9px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 700;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-control-active);
|
||||
}
|
||||
|
||||
.status-active,
|
||||
.status-succeeded,
|
||||
.status-approved,
|
||||
.status-featured {
|
||||
color: #077457;
|
||||
background: rgba(32, 201, 151, 0.16);
|
||||
}
|
||||
|
||||
.status-running,
|
||||
.status-degraded,
|
||||
.status-pending,
|
||||
.status-queued,
|
||||
.status-risk,
|
||||
.status-draft {
|
||||
color: #966a09;
|
||||
background: rgba(247, 185, 85, 0.2);
|
||||
}
|
||||
|
||||
.status-failed,
|
||||
.status-disabled,
|
||||
.status-error,
|
||||
.status-rejected,
|
||||
.status-canceled {
|
||||
color: #b72645;
|
||||
background: rgba(255, 92, 122, 0.15);
|
||||
}
|
||||
|
||||
.model-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.model-head {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-row span {
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-control);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
font-size: var(--font-xs);
|
||||
}
|
||||
|
||||
.gallery-review-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.review-card img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.review-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.review-body p {
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.review-actions .danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-grid label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.settings-grid label.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.settings-grid input {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
background: var(--color-bg-control);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-control);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.toggle span {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle.active span {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
width: min(920px, calc(100vw - 48px));
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: 32px;
|
||||
padding: 38px;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.brand-block h1 {
|
||||
margin-top: 10px;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-surface-strong);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
}
|
||||
|
||||
.login-card label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border-soft);
|
||||
background: var(--color-bg-control);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 180px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.split-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
12
apps/admin/tsconfig.json
Executable file
12
apps/admin/tsconfig.json
Executable file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts"],
|
||||
"references": [{ "path": "../../packages/api-client" }]
|
||||
}
|
||||
15
apps/admin/vite.config.ts
Executable file
15
apps/admin/vite.config.ts
Executable file
@@ -0,0 +1,15 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3200,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.WALLMUSE_API_BASE_URL ?? "http://127.0.0.1:4000",
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user