Initial WallMuse project

This commit is contained in:
fenglee
2026-05-09 09:12:41 +00:00
commit 3ea7d29827
91 changed files with 13136 additions and 0 deletions

12
apps/admin/index.html Executable file
View 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
View 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
View 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
View 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
View 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
View 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
}
}
}
});