first commit

This commit is contained in:
npc0-hue
2026-07-11 14:56:10 +08:00
commit 7e05d0a4e7
660 changed files with 78119 additions and 0 deletions
@@ -0,0 +1,28 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AiProvidersPage } from "./AiProvidersPage";
describe("AiProvidersPage", () => {
it("renders the AI provider management workflow", () => {
const html = renderToStaticMarkup(<AiProvidersPage />);
expect(html).toContain("AI 提供商管理");
expect(html).toContain("OpenAI Relay");
expect(html).toContain("Local Ollama");
expect(html).toContain("新增");
expect(html).toContain("保存");
expect(html).toContain("secret://providers/openai");
});
it("does not render raw key field names", () => {
const html = renderToStaticMarkup(<AiProvidersPage />);
expect(html).not.toContain("apiKey=");
expect(html).not.toContain("rawApiKey");
expect(html).not.toContain('value="sk-');
expect(html).not.toContain(">sk-");
expect(html).not.toContain("api_key=");
expect(html).not.toContain("Bearer ");
});
});
+370
View File
@@ -0,0 +1,370 @@
import { Candy, CheckCircle2, FlaskConical, Power, Sparkles, WandSparkles } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
import { EmptyState } from "../components/StateViews";
import {
aiProviderToForm,
emptyAiProviderForm,
type AiProviderActionState,
type AiProviderFilter,
type AiProviderFormState,
summarizeAiProviders,
type AiProviderViewState
} from "../contracts/aiProviders";
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
import { cx } from "../utils/classes";
const seedProviders: AiProviderResponse[] = [
{
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
timeoutMs: 30000,
status: "active",
redactionPolicy: "default"
},
{
id: "ai.local",
name: "Local Ollama",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "env://OLLAMA_API_KEY",
models: ["llama3.1", "qwen2.5-coder"],
defaultModel: "qwen2.5-coder",
relayMode: "local",
timeoutMs: 20000,
status: "disabled",
redactionPolicy: "local"
}
];
export function AiProvidersPage() {
const [providers, setProviders] = useState<AiProviderResponse[]>(seedProviders);
const [selectedId, setSelectedId] = useState(seedProviders[0]?.id ?? "");
const [filter, setFilter] = useState<AiProviderFilter>("all");
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(seedProviders[0]));
const [viewState, setViewState] = useState<AiProviderViewState>("local");
const [action, setAction] = useState<AiProviderActionState | null>(null);
useEffect(() => {
let cancelled = false;
platformApiClient
.listAiProviders()
.then((response) => {
if (cancelled || response.items.length === 0) {
return;
}
setProviders(response.items);
setSelectedId(response.items[0].id);
setForm(aiProviderToForm(response.items[0]));
setViewState("api");
})
.catch(() => {
if (!cancelled) {
setViewState("local");
}
});
return () => {
cancelled = true;
};
}, []);
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
const filteredProviders = useMemo(
() => providers.filter((provider) => filter === "all" || provider.status === filter),
[filter, providers]
);
const selectedProvider = providers.find((provider) => provider.id === selectedId);
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
setForm((current) => ({ ...current, [key]: value }));
}
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
}
function selectProvider(provider: AiProviderResponse) {
setSelectedId(provider.id);
setForm(aiProviderToForm(provider));
setAction(null);
}
function startCreate() {
setSelectedId("");
setForm(emptyAiProviderForm());
setAction(null);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setViewState("saving");
const existing = providers.some((provider) => provider.id === form.id.trim());
try {
const saved = existing
? await platformApiClient.updateAiProvider(form.id.trim(), aiProviderUpdateRequestFromForm(form))
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(form));
upsertProvider(saved);
setSelectedId(saved.id);
setForm(aiProviderToForm(saved));
setViewState("api");
setAction({ providerId: saved.id, label: "save", success: true, message: "saved" });
} catch {
const local = localProviderFromForm(form, existing ? selectedProvider?.status ?? "active" : "active");
upsertProvider(local);
setSelectedId(local.id);
setForm(aiProviderToForm(local));
setViewState("local");
setAction({ providerId: local.id, label: "save", success: true, message: "saved locally" });
}
}
async function handleStatus(provider: AiProviderResponse) {
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
try {
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status });
} catch {
const updated = { ...provider, status: nextStatus };
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: nextStatus });
}
}
async function handleTest(provider: AiProviderResponse) {
try {
const result = await platformApiClient.testAiProvider(provider.id);
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
} catch {
const success = provider.status === "active";
setAction({ providerId: provider.id, label: "test", success, message: success ? "metadata validation passed" : "provider must be active" });
}
}
async function handleModels(provider: AiProviderResponse) {
try {
const result = await platformApiClient.listAiProviderModels(provider.id);
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length}` });
} catch {
setAction({ providerId: provider.id, label: "models", success: true, message: `${provider.models.length}` });
}
}
function upsertProvider(provider: AiProviderResponse) {
setProviders((current) => {
const exists = current.some((item) => item.id === provider.id);
return exists ? current.map((item) => (item.id === provider.id ? provider : item)) : [...current, provider];
});
}
return (
<section className="ai-providers-page" aria-labelledby="ai-provider-title">
<header className="page-header ai-provider-header">
<div>
<p className="page-kicker"></p>
<h1 id="ai-provider-title" className="page-title">
AI
</h1>
</div>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewState === "api" ? "已连接" : viewState === "saving" ? "保存中" : "本地视图"}</span>
</header>
<div className="metric-grid ai-provider-metrics">
<article className="metric-card metric-tone-neutral">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.total}</strong>
</article>
<article className="metric-card metric-tone-success">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.active}</strong>
</article>
<article className="metric-card metric-tone-warning">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.models}</strong>
</article>
</div>
<div className="ai-provider-toolbar" aria-label="provider filters">
{(["all", "active", "disabled", "error"] as AiProviderFilter[]).map((item) => (
<button key={item} type="button" className={cx("segmented-button", filter === item && "segmented-button-active")} onClick={() => setFilter(item)}>
{filterLabel(item)}
</button>
))}
<button type="button" className="icon-command" title="新增提供商" onClick={startCreate}>
<Candy size={16} />
<span></span>
</button>
</div>
<div className="ai-provider-workspace">
<div className="provider-table-wrap">
<table className="provider-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{filteredProviders.map((provider) => (
<tr key={provider.id} className={cx(provider.id === selectedId && "provider-row-active")}>
<td>
<button type="button" className="table-link-button" onClick={() => selectProvider(provider)}>
{provider.name}
</button>
<span className="provider-id">{provider.id}</span>
</td>
<td>
<span className={cx("status-pill", `status-${provider.status}`)}>{statusLabel(provider.status)}</span>
</td>
<td>{provider.kind}</td>
<td>{provider.relayMode}</td>
<td>{provider.models.length}</td>
<td>
<code className="secret-ref">{provider.apiKeyRef}</code>
</td>
<td>
<div className="row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" title="启用或禁用" aria-label={`${provider.name} 启用或禁用`} onClick={() => void handleStatus(provider)}>
<Power size={15} />
</button>
<button type="button" title="测试配置" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<FlaskConical size={15} />
</button>
<button type="button" title="刷新模型" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<Sparkles size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredProviders.length === 0 && (
<EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />
)}
</div>
<form className="provider-form" onSubmit={(event) => void handleSubmit(event)}>
<div className="form-header">
<h2>{selectedProvider ? "编辑提供商" : "新增提供商"}</h2>
{action && (
<span className={cx("action-result", action.success ? "action-result-success" : "action-result-failed")}>
<CheckCircle2 size={14} />
{action.message}
</span>
)}
</div>
<label>
ID
<input name="id" value={form.id} onChange={handleInput} disabled={Boolean(selectedProvider)} />
</label>
<label>
<input name="name" value={form.name} onChange={handleInput} />
</label>
<div className="form-grid">
<label>
<select name="kind" value={form.kind} onChange={handleInput}>
<option value="openai-compatible">OpenAI Compatible</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="ollama">Ollama</option>
<option value="custom">Custom</option>
</select>
</label>
<label>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
<option value="local">Local</option>
</select>
</label>
</div>
<label>
Base URL
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
</label>
<label>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
</label>
<label>
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
</label>
<div className="form-grid">
<label>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
</label>
<label>
ms
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
</label>
</div>
<label>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
</label>
<button type="submit" className="primary-command" title="保存提供商">
<WandSparkles size={16} />
<span></span>
</button>
</form>
</div>
</section>
);
}
function localProviderFromForm(form: AiProviderFormState, status: AiProviderStatus): AiProviderResponse {
const request = aiProviderCreateRequestFromForm(form);
return {
...request,
defaultModel: request.defaultModel,
status
};
}
function filterLabel(filter: AiProviderFilter): string {
switch (filter) {
case "active":
return "启用";
case "disabled":
return "停用";
case "error":
return "错误";
default:
return "全部";
}
}
function statusLabel(status: AiProviderStatus): string {
switch (status) {
case "active":
return "启用";
case "disabled":
return "停用";
default:
return "错误";
}
}
+104
View File
@@ -0,0 +1,104 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { HomePage } from "./HomePage";
import { PluginsPage } from "./PluginsPage";
import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
const adminUser: CurrentUserView = {
id: "user-admin",
displayName: "Operator",
status: "active",
roles: ["platformAdmin"],
capabilities: capabilitiesForRoles(["platformAdmin"]),
profile: {},
source: "local"
};
const noopOperations: OperationTracker = {
operations: [],
begin: () => "op-test",
update: () => undefined,
succeed: () => undefined,
fail: () => undefined,
isPending: () => false
};
function pageProps(params: PageComponentProps["params"] = {}): PageComponentProps {
return {
session: adminUser,
params,
operations: noopOperations,
onNavigate: () => undefined,
onLogout: async () => undefined,
onProfileSave: async () => adminUser,
onThemePreferenceSave: async () => ({ userId: adminUser.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-07-03T00:00:00Z" })
};
}
describe("first-party console pages", () => {
it("renders the platform overview with first-screen health modules", () => {
const html = renderToStaticMarkup(<HomePage {...pageProps()} />);
expect(html).toContain("平台概览");
expect(html).toContain("资源负载");
expect(html).toContain("游戏类型分布");
expect(html).toContain("最近运营信号");
expect(html).not.toContain("sk-");
});
it("renders the server list workspace with search and status filters", () => {
const html = renderToStaticMarkup(<ServersPage {...pageProps()} />);
expect(html).toContain("服务器管理");
expect(html).toContain("搜索服务器");
expect(html).toContain("在线");
expect(html).toContain("离线");
expect(html).not.toContain("run socket");
expect(html).not.toContain("/Users/");
});
it("renders server detail sections for daily operations", () => {
const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />);
expect(html).toContain("返回列表");
expect(html).toContain("概览");
expect(html).toContain("日志");
expect(html).toContain("配置");
expect(html).toContain("插件控制");
expect(html).toContain("AI 助手");
expect(html).toContain("操作历史");
});
it("renders plugin catalog bridge readiness", () => {
const html = renderToStaticMarkup(<PluginsPage />);
expect(html).toContain("插件市场");
expect(html).toContain("平台 API");
expect(html).toContain("正在加载插件市场");
expect(html).not.toContain("billing");
});
it("renders user access review context", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} />);
expect(html).toContain("用户管理");
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("needs approval");
});
it("renders profile settings as a full page", () => {
const html = renderToStaticMarkup(<ProfileSettingsPage {...pageProps()} />);
expect(html).toContain("个人设置");
expect(html).toContain("个人资料");
expect(html).toContain("界面偏好");
expect(html).not.toContain("role=\"dialog\"");
});
});
+321
View File
@@ -0,0 +1,321 @@
import { Activity, AlertTriangle, CakeSlice, Candy, Info, MoonStar, Sparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
AiProviderResponse,
AuditEventResponse,
JobResponse,
PlatformResourceUsageResponse,
RunEndpointResponse,
ServerInstanceResponse,
ServerMetricsResponse
} from "../api/types";
import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { GameTypeDistributionEntry, PlatformOverviewSignal } from "../contracts/workspace";
import { serverIsOnline } from "../contracts/workspace";
import { cx } from "../utils/classes";
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
interface OverviewData {
instances: ServerInstanceResponse[];
endpoints: RunEndpointResponse[];
jobs: JobResponse[];
}
export function HomePage({ onNavigate }: PageComponentProps) {
const [core, setCore] = useState<ModuleState<OverviewData>>({ status: "loading" });
const [metrics, setMetrics] = useState<ModuleState<ServerMetricsResponse[]>>({ status: "loading" });
const [usage, setUsage] = useState<ModuleState<PlatformResourceUsageResponse>>({ status: "loading" });
const [providers, setProviders] = useState<ModuleState<AiProviderResponse[]>>({ status: "loading" });
const [signals, setSignals] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
const refreshCore = useCallback(async () => {
setCore({ status: "loading" });
try {
const [instances, endpoints, jobs] = await Promise.all([
platformApiClient.listServerInstances(),
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs()
]);
setCore({ status: "ready", data: { instances: instances.items, endpoints: endpoints.items, jobs: jobs.items } });
} catch (error) {
setCore({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshMetrics = useCallback(async () => {
setMetrics({ status: "loading" });
try {
const response = await platformApiClient.listServerMetrics();
setMetrics({ status: "ready", data: response.items });
} catch {
setMetrics({ status: "ready", data: [] });
}
}, []);
const refreshUsage = useCallback(async () => {
setUsage({ status: "loading" });
try {
const response = await platformApiClient.getPlatformResourceUsage();
setUsage({ status: "ready", data: response });
} catch {
setUsage({ status: "error", reason: "平台资源指标接口尚未提供" });
}
}, []);
const refreshProviders = useCallback(async () => {
setProviders({ status: "loading" });
try {
const response = await platformApiClient.listAiProviders();
setProviders({ status: "ready", data: response.items });
} catch (error) {
setProviders({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshSignals = useCallback(async () => {
setSignals({ status: "loading" });
try {
const response = await platformApiClient.listAuditEvents();
setSignals({ status: "ready", data: response.items });
} catch {
setSignals({ status: "ready", data: [] });
}
}, []);
useEffect(() => {
void refreshCore();
void refreshMetrics();
void refreshUsage();
void refreshProviders();
void refreshSignals();
}, [refreshCore, refreshMetrics, refreshUsage, refreshProviders, refreshSignals]);
const distribution = useMemo<GameTypeDistributionEntry[]>(() => {
if (core.status !== "ready") {
return [];
}
const counts = new Map<string, number>();
for (const instance of core.data.instances) {
counts.set(instance.pluginId, (counts.get(instance.pluginId) ?? 0) + 1);
}
return [...counts.entries()].map(([serverType, count]) => ({ serverType, label: serverType, count }));
}, [core]);
const overviewSignals = useMemo<PlatformOverviewSignal[]>(() => {
const collected: PlatformOverviewSignal[] = [];
if (core.status === "ready") {
for (const instance of core.data.instances.filter((item) => item.state === "failed")) {
collected.push({
id: `fault-${instance.id}`,
kind: "fault",
summary: `服务器 ${instance.name} 处于异常状态`,
detail: `实例 ${instance.id} 状态为 failed,需要排查`,
targetPage: "servers",
targetId: instance.id,
tone: "error",
at: instance.updatedAt
});
}
for (const job of core.data.jobs.filter((item) => item.state === "failed").slice(0, 5)) {
collected.push({
id: `job-${job.id}`,
kind: "job",
summary: `任务 ${job.capability} 失败`,
detail: `任务 ${job.id}${job.serverInstanceId ? `(服务器 ${job.serverInstanceId}` : ""} 执行失败`,
targetPage: "servers",
targetId: job.serverInstanceId,
tone: "error",
at: job.updatedAt
});
}
}
if (providers.status === "ready") {
for (const provider of providers.data.filter((item) => item.status === "error")) {
collected.push({
id: `ai-${provider.id}`,
kind: "aiProvider",
summary: `AI 提供商 ${provider.name} 连接异常`,
detail: `提供商 ${provider.id} 状态为 error,LLM 辅助可能不可用`,
targetPage: "aiProviders",
targetId: provider.id,
tone: "warning",
at: ""
});
}
}
if (signals.status === "ready") {
for (const event of signals.data.slice(0, 5)) {
collected.push({
id: `audit-${event.id}`,
kind: "log",
summary: event.summary || `${event.action} ${event.resourceKind}`,
detail: `${event.actorId}${event.resourceKind}/${event.resourceId} 执行 ${event.action}${event.result}`,
targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance",
targetId: event.resourceId,
tone: event.result === "failure" ? "warning" : "info",
at: event.createdAt
});
}
}
return collected.slice(0, 8);
}, [core, providers, signals]);
const onlineCount = core.status === "ready" ? core.data.instances.filter((item) => serverIsOnline(item.state)).length : 0;
const offlineCount = core.status === "ready" ? core.data.instances.length - onlineCount : 0;
const activeProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "active").length : 0;
const errorProviders = providers.status === "ready" ? providers.data.filter((item) => item.status === "error").length : 0;
const metricAverages = useMemo(() => {
if (usage.status === "ready") {
return { cpu: usage.data.cpuPercent, memory: usage.data.memoryPercent, disk: usage.data.diskPercent, source: "平台指标" };
}
if (metrics.status === "ready" && metrics.data.length > 0) {
const average = (values: Array<number | undefined>) => {
const known = values.filter((value): value is number => typeof value === "number");
return known.length > 0 ? known.reduce((sum, value) => sum + value, 0) / known.length : undefined;
};
return {
cpu: average(metrics.data.map((item) => item.cpuPercent)),
memory: average(metrics.data.map((item) => item.memoryPercent)),
disk: average(metrics.data.map((item) => item.diskPercent)),
source: "按服务器均值"
};
}
return { cpu: undefined, memory: undefined, disk: undefined, source: "暂无数据" };
}, [usage, metrics]);
return (
<div className="console-page">
<header className="page-header">
<div>
<p className="page-kicker"></p>
<h1 className="page-title"></h1>
</div>
<span className={cx("page-status", core.status === "ready" && "page-status-ready")}>
{core.status === "ready" ? "数据已加载" : core.status === "loading" ? "加载中" : "部分模块加载失败"}
</span>
</header>
{core.status === "loading" && <LoadingState label="正在加载服务器与任务概况…" />}
{core.status === "error" && <ErrorState title="平台概况加载失败" reason={core.reason} diagnosticId="overview-core" onRetry={() => void refreshCore()} />}
{core.status === "ready" && core.data.instances.length === 0 && (
<EmptyState
icon={<CakeSlice size={26} />}
title="还没有服务器实例"
description="平台尚未创建任何服务器。前往服务器管理创建第一个实例,或检查运行节点是否在线。"
actionLabel="前往服务器管理"
onAction={() => onNavigate("servers")}
/>
)}
{core.status === "ready" && core.data.instances.length > 0 && (
<section className="console-grid" aria-label="platform health">
<article className="overview-card metric-tone-success">
<span className="metric-label">线</span>
<strong className="metric-value">{onlineCount}</strong>
<p> {core.data.instances.length} </p>
</article>
<article className={cx("overview-card", offlineCount > 0 ? "metric-tone-warning" : "metric-tone-neutral")}>
<span className="metric-label">线 / </span>
<strong className="metric-value">{offlineCount}</strong>
<p>{core.data.instances.filter((item) => item.state === "failed").length} </p>
</article>
<article className="overview-card metric-tone-neutral">
<span className="metric-label"></span>
<strong className="metric-value">{core.data.endpoints.filter((item) => item.status === "online").length}</strong>
<p> {core.data.endpoints.length} </p>
</article>
<article className={cx("overview-card", errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
<span className="metric-label">LLM </span>
<strong className="metric-value">
{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "…" : "未知"}
</strong>
<p>{errorProviders > 0 ? `${errorProviders} 个提供商异常` : "提供商状态正常"}</p>
</article>
</section>
)}
<section className="overview-two-col">
<article className="console-panel" aria-label="resource usage">
<div className="panel-header">
<h2>
<Activity size={16} style={{ verticalAlign: "-2px" }} />
</h2>
<span className="page-status">{metricAverages.source}</span>
</div>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metricAverages.cpu} />
<UsageMeter label="内存" percent={metricAverages.memory} />
<UsageMeter label="磁盘" percent={metricAverages.disk} />
</div>
</article>
<article className="console-panel" aria-label="game type distribution">
<div className="panel-header">
<h2>
<Candy size={16} style={{ verticalAlign: "-2px" }} />
</h2>
</div>
{core.status === "loading" ? (
<LoadingState label="统计中…" compact />
) : distribution.length === 0 ? (
<EmptyState title="暂无分布数据" description="创建服务器后这里会显示各游戏类型的实例数量。" />
) : (
<div className="action-list">
{distribution.map((entry) => (
<span key={entry.serverType}>
<strong>{entry.label}</strong>{entry.count}
</span>
))}
</div>
)}
</article>
</section>
<section className="console-panel" aria-label="recent signals">
<div className="panel-header">
<h2>
<MoonStar size={16} style={{ verticalAlign: "-2px" }} />
</h2>
<button type="button" className="icon-command" onClick={() => void refreshSignals()}>
<Sparkles size={14} />
</button>
</div>
{signals.status === "loading" && core.status === "loading" ? (
<LoadingState label="正在收集信号…" compact />
) : overviewSignals.length === 0 ? (
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或需要关注的审计事件。" />
) : (
<div className="signal-list">
{overviewSignals.map((signal) => (
<button
key={signal.id}
type="button"
className={cx("signal-item", `signal-tone-${signal.tone}`)}
onClick={() =>
onNavigate(
signal.targetPage === "servers" && signal.targetId ? "serverDetail" : signal.targetPage,
signal.targetPage === "servers" && signal.targetId ? { serverId: signal.targetId } : undefined
)
}
>
{signal.tone === "error" ? <AlertTriangle size={18} /> : signal.tone === "warning" ? <AlertTriangle size={18} /> : <Info size={18} />}
<span>
<strong>{signal.summary}</strong>
<p>{signal.detail}</p>
</span>
<span className="provider-id">{signal.at ? new Date(signal.at).toLocaleString() : ""}</span>
</button>
))}
</div>
)}
</section>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { Sparkles, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AuditEventResponse, RunEndpointResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import { cx } from "../utils/classes";
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
export function MaintenancePage() {
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
const refreshEndpoints = useCallback(async () => {
setEndpoints({ status: "loading" });
try {
const response = await platformApiClient.listRunEndpoints();
setEndpoints({ status: "ready", data: response.items });
} catch (error) {
setEndpoints({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshEvents = useCallback(async () => {
setEvents({ status: "loading" });
try {
const response = await platformApiClient.listAuditEvents();
setEvents({ status: "ready", data: response.items });
} catch (error) {
setEvents({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
useEffect(() => {
void refreshEndpoints();
void refreshEvents();
}, [refreshEndpoints, refreshEvents]);
return (
<div className="maintenance-page">
<header className="page-header">
<div>
<p className="page-kicker"></p>
<h1 className="page-title">
<WandSparkles size={22} style={{ verticalAlign: "-3px" }} />
</h1>
</div>
<button
type="button"
className="icon-command"
onClick={() => {
void refreshEndpoints();
void refreshEvents();
}}
>
<Sparkles size={16} />
<span></span>
</button>
</header>
<section className="console-panel" aria-label="run endpoints">
<div className="panel-header">
<h2></h2>
</div>
{endpoints.status === "loading" && <LoadingState label="正在加载运行节点…" compact />}
{endpoints.status === "error" && (
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} compact />
)}
{endpoints.status === "ready" && endpoints.data.length === 0 && (
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
)}
{endpoints.status === "ready" && endpoints.data.length > 0 && (
<div className="resource-list">
{endpoints.data.map((endpoint) => (
<article key={endpoint.id} className="resource-list-item">
<div>
<strong>{endpoint.displayName}</strong>
<span className="provider-id">{endpoint.id}</span>
</div>
<span className={cx("status-pill", endpoint.status === "online" ? "status-active" : endpoint.status === "offline" ? "status-error" : "status-disabled")}>
{endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status}
</span>
<span>
{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} {endpoint.capacity.queuedJobs}
</span>
<span> {new Date(endpoint.lastHeartbeatAt).toLocaleString()}</span>
</article>
))}
</div>
)}
</section>
<section className="console-panel" aria-label="audit events">
<div className="panel-header">
<h2></h2>
</div>
{events.status === "loading" && <LoadingState label="正在加载审计事件…" compact />}
{events.status === "error" && (
<ErrorState title="审计事件加载失败" reason={events.reason} diagnosticId="maintenance-audit" onRetry={() => void refreshEvents()} compact />
)}
{events.status === "ready" && events.data.length === 0 && (
<EmptyState title="暂无审计事件" description="平台还没有记录任何审计事件。" actionLabel="刷新" onAction={() => void refreshEvents()} />
)}
{events.status === "ready" && events.data.length > 0 && (
<div className="operation-list">
{events.data.slice(0, 30).map((event) => (
<div key={event.id} className="operation-item">
<div className="operation-item-head">
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
<span className={cx("status-pill", event.result === "success" ? "status-active" : "status-error")}>{event.result}</span>
</div>
<div className="operation-meta">
<span>
<code>{event.id}</code>
</span>
<span> {event.actorId}</span>
<span>
{event.resourceKind}/{event.resourceId}
</span>
<span>{new Date(event.createdAt).toLocaleString()}</span>
</div>
</div>
))}
</div>
)}
</section>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PluginsPage } from "./PluginsPage";
import type { MarketplacePluginResponse } from "../api/types";
const marketplacePlugin: MarketplacePluginResponse = {
id: "game.example",
name: "Example Server",
description: "Development plugin",
version: "0.1.0",
serverType: "example",
serverDisplayName: "Example Server",
supportedOs: ["linux", "darwin"],
manifestRef: "artifact://manifests/game.example/0.1.0",
createFormSchemaRef: "schemas/create-form.schema.json",
capabilities: ["process.install", "process.start", "logs.read"],
declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"],
permissions: { ai: true, logs: true, files: false, jobs: true, artifacts: false },
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"],
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
status: "installed",
source: "platform-registry"
};
describe("PluginsPage", () => {
it("renders the loading state before API data arrives", () => {
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "loading" }} />);
expect(html).toContain("插件市场");
expect(html).toContain("正在加载插件市场");
});
it("renders API error state without falling back in production state", () => {
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "error", listError: "backend unavailable" }} />);
expect(html).toContain("插件市场加载失败");
expect(html).toContain("backend unavailable");
expect(html).not.toContain("本地演示数据仅用于前端开发");
});
it("renders API-backed plugin detail and state actions without unsafe fragments", () => {
const html = renderToStaticMarkup(
<PluginsPage initialState={{ listState: "ready", plugins: [marketplacePlugin], selectedId: marketplacePlugin.id, detail: marketplacePlugin }} />
);
expect(html).toContain("Example Server");
expect(html).toContain("平台 API");
expect(html).toContain("logs.query");
expect(html).toContain("logs.diagnose");
expect(html).toContain("安装");
expect(html).toContain("启用");
expect(html).toContain("停用");
expect(html).not.toContain("billing");
expect(html).not.toContain("/Users/");
expect(html).not.toContain("unix://");
expect(html).not.toContain("Bearer ");
expect(html).not.toContain("sk-");
expect(html).not.toContain("password=");
expect(html).not.toContain("apiKeyRef");
});
it("keeps standalone fallback visibly isolated", () => {
const html = renderToStaticMarkup(
<PluginsPage initialState={{ listState: "ready", plugins: [marketplacePlugin], selectedId: marketplacePlugin.id, detail: marketplacePlugin, usingFallback: true }} />
);
expect(html).toContain("本地演示数据");
expect(html).toContain("本地演示数据仅用于前端开发");
});
});
+386
View File
@@ -0,0 +1,386 @@
import { Boxes, Eye, Filter, PlugZap, Search, Sparkles } from "lucide-react";
import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginStatus, MarketplacePluginFilterRequest, MarketplacePluginResponse, MarketplacePluginStateAction } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { pluginCatalog } from "../contracts/shell";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
type StatusFilter = GamePluginStatus | "all";
interface PluginsPageInitialState {
listState?: ListState;
listError?: string;
plugins?: MarketplacePluginResponse[];
selectedId?: string;
detail?: MarketplacePluginResponse | null;
usingFallback?: boolean;
actionResult?: { status: "succeeded" | "failed"; label: string } | null;
}
interface PluginsPageProps extends Partial<PageComponentProps> {
initialState?: PluginsPageInitialState;
}
const statusFilters: Array<{ id: StatusFilter; label: string }> = [
{ id: "all", label: "全部" },
{ id: "installed", label: "已安装" },
{ id: "disabled", label: "停用" },
{ id: "invalid", label: "异常" },
{ id: "updating", label: "更新中" }
];
export function PluginsPage({ initialState }: PluginsPageProps = {}) {
const [listState, setListState] = useState<ListState>(initialState?.listState ?? "loading");
const [listError, setListError] = useState(initialState?.listError ?? "");
const [plugins, setPlugins] = useState<MarketplacePluginResponse[]>(initialState?.plugins ?? []);
const [selectedId, setSelectedId] = useState<string>(initialState?.selectedId ?? "");
const [detail, setDetail] = useState<MarketplacePluginResponse | null>(initialState?.detail ?? null);
const [detailPending, setDetailPending] = useState(false);
const [detailError, setDetailError] = useState("");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [serverType, setServerType] = useState("");
const [capability, setCapability] = useState("");
const [actionPending, setActionPending] = useState<MarketplacePluginStateAction | null>(null);
const [actionResult, setActionResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(initialState?.actionResult ?? null);
const [usingFallback, setUsingFallback] = useState(initialState?.usingFallback ?? false);
const refresh = useCallback(async () => {
setListState("loading");
setListError("");
const filter: MarketplacePluginFilterRequest = {
keyword: keyword.trim() || undefined,
status: statusFilter,
serverType: serverType.trim() || undefined,
capability: capability.trim() || undefined
};
try {
const response = await platformApiClient.listMarketplacePlugins(filter);
setPlugins(response.items);
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : response.items[0]?.id || ""));
setUsingFallback(false);
setListState("ready");
} catch (error) {
if (import.meta.env.DEV) {
const fallback = fallbackMarketplacePlugins(filter);
setPlugins(fallback);
setSelectedId((current) => (current && fallback.some((plugin) => plugin.id === current) ? current : fallback[0]?.id || ""));
setUsingFallback(true);
setListState("ready");
setListError("");
return;
}
setListState("error");
setListError(error instanceof Error ? error.message : "插件市场加载失败");
setPlugins([]);
setDetail(null);
}
}, [capability, keyword, serverType, statusFilter]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!selectedId) {
setDetail(null);
setDetailError("");
return;
}
const selectedSummary = plugins.find((plugin) => plugin.id === selectedId) ?? null;
if (usingFallback) {
setDetail(selectedSummary);
setDetailError("");
return;
}
setDetailPending(true);
setDetailError("");
platformApiClient
.getMarketplacePlugin(selectedId)
.then((plugin) => setDetail(plugin))
.catch((error: unknown) => {
setDetail(selectedSummary);
setDetailError(error instanceof Error ? error.message : "插件详情加载失败");
})
.finally(() => setDetailPending(false));
}, [plugins, selectedId, usingFallback]);
const serverTypes = useMemo(() => unique(plugins.map((plugin) => plugin.serverType)), [plugins]);
const capabilities = useMemo(() => unique(plugins.flatMap((plugin) => [...plugin.capabilities, ...plugin.bridgeActions])), [plugins]);
const installedCount = plugins.filter((plugin) => plugin.status === "installed").length;
const bridgeActionCount = plugins.reduce((sum, plugin) => sum + plugin.bridgeActions.length, 0);
const invalidCount = plugins.filter((plugin) => plugin.status === "invalid").length;
function updateSelect(event: ChangeEvent<HTMLSelectElement>) {
const { name, value } = event.target;
if (name === "serverType") {
setServerType(value);
} else if (name === "capability") {
setCapability(value);
}
}
async function changeState(action: MarketplacePluginStateAction) {
if (!detail || usingFallback) {
return;
}
setActionPending(action);
setActionResult(null);
try {
const updated = await platformApiClient.setMarketplacePluginState(detail.id, { action });
setDetail(updated);
setPlugins((current) => current.map((plugin) => (plugin.id === updated.id ? updated : plugin)));
setActionResult({ status: "succeeded", label: `${updated.name}${stateActionLabel(action)}` });
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "状态更新失败" });
} finally {
setActionPending(null);
}
}
return (
<div className="console-page">
<PageFrame
kicker="扩展"
title="插件市场"
status={usingFallback ? "本地演示数据" : "平台 API"}
metrics={[
{ label: "已安装", value: `${installedCount}`, tone: "success" },
{ label: "桥接动作", value: `${bridgeActionCount}`, tone: "success" },
{ label: "校验失败", value: `${invalidCount}`, tone: invalidCount > 0 ? "warning" : "success" }
]}
/>
<div className="server-toolbar" role="search">
<Search size={16} aria-hidden="true" />
<input type="search" value={keyword} placeholder="搜索插件名称、ID、标签或能力" aria-label="搜索插件" onChange={(event) => setKeyword(event.target.value)} />
{statusFilters.map((filter) => (
<button
key={filter.id}
type="button"
className={cx("segmented-button", statusFilter === filter.id && "segmented-button-active")}
onClick={() => setStatusFilter(filter.id)}
>
{filter.label}
</button>
))}
</div>
<div className="server-toolbar" aria-label="插件筛选器">
<Filter size={16} aria-hidden="true" />
<select name="serverType" value={serverType} onChange={updateSelect} aria-label="服务器类型筛选">
<option value=""></option>
{serverTypes.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<select name="capability" value={capability} onChange={updateSelect} aria-label="能力筛选">
<option value=""></option>
{capabilities.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<button type="button" className="icon-command" title="刷新插件市场" onClick={() => void refresh()}>
<Sparkles size={16} />
<span></span>
</button>
</div>
{usingFallback && <ResultBadge status="pending" label="本地演示数据仅用于前端开发,连接平台 API 后会自动替换" />}
{actionResult && <ResultBadge status={actionResult.status} label={actionResult.label} />}
{listState === "loading" && <LoadingState label="正在加载插件市场…" />}
{listState === "error" && <ErrorState title="插件市场加载失败" reason={listError} diagnosticId="plugin-marketplace" onRetry={() => void refresh()} />}
{listState === "ready" && plugins.length === 0 && (
<EmptyState icon={<Boxes size={26} />} title="暂无匹配插件" description="调整搜索、状态、服务器类型或能力筛选后再试。" actionLabel="清除筛选" onAction={() => {
setKeyword("");
setStatusFilter("all");
setServerType("");
setCapability("");
}} />
)}
{plugins.length > 0 && (
<section className="catalog-grid" aria-label="marketplace plugins">
{plugins.map((plugin) => (
<article key={plugin.id} className={cx("catalog-card", selectedId === plugin.id && "catalog-card-active")}>
<div className="panel-header">
<div>
<h2>{plugin.name}</h2>
<span className="provider-id">{plugin.id}</span>
</div>
<span className={cx("status-pill", statusClass(plugin.status))}>{statusLabel(plugin.status)}</span>
</div>
<p>{plugin.description || plugin.serverDisplayName || plugin.serverType}</p>
<dl className="detail-list">
<div>
<dt></dt>
<dd>{plugin.version}</dd>
</div>
<div>
<dt></dt>
<dd>{plugin.serverType}</dd>
</div>
<div>
<dt></dt>
<dd>{plugin.capabilities.slice(0, 3).join(", ") || "--"}</dd>
</div>
<div>
<dt></dt>
<dd>{plugin.bridgeActions.slice(0, 3).join(", ") || "--"}</dd>
</div>
</dl>
<button type="button" className="primary-command" onClick={() => setSelectedId(plugin.id)} title="查看插件详情">
<Eye size={16} />
<span></span>
</button>
</article>
))}
</section>
)}
{selectedId && (
<section className="drawer-panel plugin-detail-panel" aria-label="plugin marketplace detail">
{detailPending && <LoadingState label="正在加载插件详情…" compact />}
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} compact />}
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback} onAction={(action) => void changeState(action)} />}
</section>
)}
</div>
);
}
interface PluginDetailProps {
plugin: MarketplacePluginResponse;
actionPending: MarketplacePluginStateAction | null;
actionsDisabled: boolean;
onAction: (action: MarketplacePluginStateAction) => void;
}
function PluginDetail({ plugin, actionPending, actionsDisabled, onAction }: PluginDetailProps) {
return (
<div className="plugin-group">
<div className="panel-header">
<div>
<h2>{plugin.name}</h2>
<span className="provider-id">{plugin.manifestRef}</span>
</div>
<span className={cx("status-pill", statusClass(plugin.status))}>{statusLabel(plugin.status)}</span>
</div>
<div className="server-card-stats">
<DetailStat label="服务器类型" value={plugin.serverDisplayName || plugin.serverType} />
<DetailStat label="页面" value={`${plugin.pages.length}`} />
<DetailStat label="权限" value={`${plugin.declaredPermissions.length}`} />
<DetailStat label="AI 用途" value={plugin.aiPurposes.length ? plugin.aiPurposes.join(", ") : "--"} />
</div>
<dl className="detail-list">
<div>
<dt></dt>
<dd>{plugin.capabilities.join(", ") || "--"}</dd>
</div>
<div>
<dt></dt>
<dd>{plugin.bridgeActions.join(", ") || "--"}</dd>
</div>
<div>
<dt></dt>
<dd>{plugin.pages.map((page) => `${page.title}(${page.key})`).join(", ") || "--"}</dd>
</div>
<div>
<dt></dt>
<dd>{plugin.validationViolations?.length ? plugin.validationViolations.join(", ") : "manifest validated"}</dd>
</div>
</dl>
<div className="action-strip plugin-detail-actions">
<button type="button" className="primary-command" disabled={actionsDisabled || actionPending !== null || plugin.status === "installed"} onClick={() => onAction("install")} title="安装插件状态">
<PlugZap size={16} />
<span>{actionPending === "install" ? "安装中…" : "安装"}</span>
</button>
<button type="button" className="primary-command" disabled={actionsDisabled || actionPending !== null || plugin.status === "installed"} onClick={() => onAction("enable")} title="启用插件">
<Sparkles size={16} />
<span>{actionPending === "enable" ? "启用中…" : "启用"}</span>
</button>
<button type="button" className="segmented-button" disabled={actionsDisabled || actionPending !== null || plugin.status === "disabled"} onClick={() => onAction("disable")} title="停用插件">
</button>
</div>
</div>
);
}
function DetailStat({ label, value }: { label: string; value: string }) {
return (
<span className="server-card-stat">
<span>{label}</span>
<strong>{value}</strong>
</span>
);
}
function fallbackMarketplacePlugins(filter: MarketplacePluginFilterRequest): MarketplacePluginResponse[] {
const keyword = filter.keyword?.trim().toLowerCase() ?? "";
return pluginCatalog
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.validation,
version: plugin.version,
serverType: plugin.serverType,
serverDisplayName: plugin.serverType,
supportedOs: ["linux"],
manifestRef: `manifest://${plugin.id}/${plugin.version}`,
createFormSchemaRef: "schemas/create-form.schema.json",
capabilities: ["process.install", "process.start", "logs.read"],
declaredPermissions: plugin.permissions,
permissions: {
ai: plugin.permissions.includes("ai.invoke"),
logs: plugin.permissions.includes("server.logs.read"),
files: plugin.permissions.some((permission) => permission.includes("files")),
jobs: plugin.permissions.includes("server.lifecycle"),
artifacts: plugin.permissions.some((permission) => permission.includes("artifacts"))
},
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: plugin.bridgeActions,
pages: [{ key: "overview", title: "Overview", path: "/overview", permissions: plugin.permissions, bridgeActions: plugin.bridgeActions }],
tags: [plugin.serverType],
aiPurposes: plugin.permissions.includes("ai.invoke") ? ["logs.diagnose"] : [],
validationViolations: plugin.status === "invalid" ? [plugin.validation] : undefined,
status: plugin.status,
source: "local-development"
}))
.filter((plugin) => !filter.status || filter.status === "all" || plugin.status === filter.status)
.filter((plugin) => !filter.serverType || plugin.serverType === filter.serverType)
.filter((plugin) => !filter.capability || plugin.capabilities.includes(filter.capability) || plugin.bridgeActions.includes(filter.capability))
.filter((plugin) => !keyword || [plugin.id, plugin.name, plugin.serverType, ...plugin.tags, ...plugin.capabilities].some((value) => value.toLowerCase().includes(keyword)));
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right));
}
function statusClass(status: GamePluginStatus): string {
if (status === "installed") {
return "status-active";
}
if (status === "invalid") {
return "status-error";
}
return "status-disabled";
}
function statusLabel(status: string): string {
return status === "installed" ? "已安装" : status === "disabled" ? "停用" : status === "invalid" ? "异常" : status === "updating" ? "更新中" : status;
}
function stateActionLabel(action: MarketplacePluginStateAction): string {
return action === "disable" ? "停用" : "启用";
}
+249
View File
@@ -0,0 +1,249 @@
import { ArrowLeft, LogOut, MoonStar, Palette, Sparkles, Upload, UserRoundPen, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import type { PageComponentProps } from "../contracts/page";
import { defaultPageForUser } from "../routes/routes";
import {
applyBackgroundImage,
applyThemeBackgroundPreset,
applyThemePalette,
loadThemeState,
persistBackgroundImage,
persistThemeBackgroundPreset,
persistThemePalette,
themeBackgroundPresets,
themePalettes,
type ThemeBackgroundId,
type ThemePaletteId,
type WorkspaceThemeState
} from "../theme/tokens";
import { cx } from "../utils/classes";
import { PageFrame } from "../components/PageFrame";
import { ResultBadge } from "../components/StateViews";
type SaveState = { status: "pending" | "succeeded" | "failed"; label: string };
export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSave, onThemePreferenceSave }: PageComponentProps) {
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
const [profileResult, setProfileResult] = useState<SaveState>();
const [themeResult, setThemeResult] = useState<SaveState>();
const [profileDraft, setProfileDraft] = useState({
displayName: session.displayName,
avatarUrl: session.profile.avatarUrl ?? "",
phone: session.profile.phone ?? "",
qq: session.profile.qq ?? "",
contactNote: session.profile.contactNote ?? ""
});
useEffect(() => {
setProfileDraft({
displayName: session.displayName,
avatarUrl: session.profile.avatarUrl ?? "",
phone: session.profile.phone ?? "",
qq: session.profile.qq ?? "",
contactNote: session.profile.contactNote ?? ""
});
}, [session]);
const activePalette = useMemo(() => themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0], [themeState.paletteId]);
const activeBackground = useMemo(() => themeBackgroundPresets.find((preset) => preset.id === themeState.backgroundPresetId) ?? themeBackgroundPresets[0], [themeState.backgroundPresetId]);
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setProfileResult({ status: "pending", label: "正在保存个人资料" });
try {
const saved = await onProfileSave({
displayName: profileDraft.displayName.trim(),
avatarUrl: profileDraft.avatarUrl.trim(),
phone: profileDraft.phone.trim(),
qq: profileDraft.qq.trim(),
contactNote: profileDraft.contactNote.trim()
});
setProfileResult({ status: saved.source === "api" ? "succeeded" : "failed", label: saved.source === "api" ? "个人资料已保存到数据库" : "个人资料 API 暂不可用,已保留在本地会话" });
} catch (error) {
setProfileResult({ status: "failed", label: error instanceof Error ? error.message : "个人资料保存失败" });
}
}
async function persistThemePreference(nextState: WorkspaceThemeState) {
setThemeResult({ status: "pending", label: "正在保存界面偏好" });
try {
const response = await onThemePreferenceSave({
paletteId: nextState.paletteId,
backgroundPresetId: nextState.backgroundPresetId,
backgroundImage: nextState.backgroundImage
});
setThemeResult({ status: response.persistence === "api" ? "succeeded" : "failed", label: response.persistence === "api" ? "界面偏好已保存到数据库" : "界面偏好 API 暂不可用,已本地保存" });
} catch (error) {
setThemeResult({ status: "failed", label: error instanceof Error ? error.message : "界面偏好保存失败" });
}
}
function selectPalette(paletteId: ThemePaletteId) {
applyThemePalette(paletteId);
persistThemePalette(paletteId);
setThemeState((current) => {
const nextState = { ...current, paletteId };
void persistThemePreference(nextState);
return nextState;
});
}
function selectBackgroundPreset(backgroundPresetId: ThemeBackgroundId) {
applyThemeBackgroundPreset(backgroundPresetId);
persistThemeBackgroundPreset(backgroundPresetId);
setThemeState((current) => {
const nextState = { ...current, backgroundPresetId };
void persistThemePreference(nextState);
return nextState;
});
}
function handleBackgroundUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
const dataUrl = typeof reader.result === "string" ? reader.result : null;
applyBackgroundImage(dataUrl);
persistBackgroundImage(dataUrl);
setThemeState((current) => {
const nextState = { ...current, backgroundImage: dataUrl };
void persistThemePreference(nextState);
return nextState;
});
};
reader.readAsDataURL(file);
}
function clearBackground() {
applyBackgroundImage(null);
persistBackgroundImage(null);
setThemeState((current) => {
const nextState = { ...current, backgroundImage: null };
void persistThemePreference(nextState);
return nextState;
});
}
return (
<div className="console-page profile-settings-page">
<PageFrame
kicker="账号"
title="个人设置"
status={session.source === "api" ? "账号 API 已连接" : "本地会话"}
metrics={[
{ label: "身份", value: session.roles.length ? String(session.roles.length) : "0", tone: "neutral" },
{ label: "配色", value: activePalette.label, tone: "success" },
{ label: "背景", value: activeBackground.label, tone: "warning" }
]}
/>
<section className="console-panel profile-settings-hero" aria-label="account summary">
<div className="profile-settings-avatar" aria-hidden="true">{profileDraft.avatarUrl ? <img src={profileDraft.avatarUrl} alt="" /> : <UserRoundPen size={30} />}</div>
<div>
<h2>{profileDraft.displayName || session.displayName}</h2>
<p>{session.email ?? session.id}</p>
<span className="page-status">{session.status}</span>
</div>
<div className="profile-settings-actions">
<button type="button" className="theme-upload" onClick={() => onNavigate(defaultPageForUser(session))}>
<ArrowLeft size={14} />
<span></span>
</button>
<button type="button" className="theme-upload" onClick={() => void onLogout()}>
<LogOut size={14} />
<span>退</span>
</button>
</div>
</section>
<section className="profile-settings-grid">
<form className="console-panel profile-settings-form" onSubmit={saveProfile}>
<div className="panel-header">
<h2></h2>
{profileResult && <ResultBadge status={profileResult.status} label={profileResult.label} />}
</div>
<label>
<span></span>
<input value={profileDraft.displayName} required onChange={(event) => setProfileDraft((current) => ({ ...current, displayName: event.target.value }))} />
</label>
<label>
<span> URL</span>
<input value={profileDraft.avatarUrl} onChange={(event) => setProfileDraft((current) => ({ ...current, avatarUrl: event.target.value }))} />
</label>
<div className="profile-settings-form-row">
<label>
<span></span>
<input value={profileDraft.phone} inputMode="tel" onChange={(event) => setProfileDraft((current) => ({ ...current, phone: event.target.value }))} />
</label>
<label>
<span>QQ</span>
<input value={profileDraft.qq} inputMode="numeric" onChange={(event) => setProfileDraft((current) => ({ ...current, qq: event.target.value }))} />
</label>
</div>
<label>
<span></span>
<textarea value={profileDraft.contactNote} rows={4} onChange={(event) => setProfileDraft((current) => ({ ...current, contactNote: event.target.value }))} />
</label>
<button type="submit" className="profile-save-button">
<Sparkles size={14} />
<span></span>
</button>
</form>
<section className="console-panel profile-settings-form" aria-label="theme preferences">
<div className="panel-header">
<h2></h2>
{themeResult && <ResultBadge status={themeResult.status} label={themeResult.label} />}
</div>
<div className="profile-panel-title">
<Palette size={15} />
<strong></strong>
</div>
<div className="palette-grid profile-palette-grid">
{themePalettes.map((palette) => (
<button key={palette.id} type="button" className={cx("palette-option", palette.id === themeState.paletteId && "palette-option-active")} aria-pressed={palette.id === themeState.paletteId} title={palette.summary} onClick={() => selectPalette(palette.id)}>
<span className="palette-swatch-row" aria-hidden="true">
{palette.swatches.map((swatch) => (
<span key={swatch} style={{ background: swatch }} />
))}
</span>
<span className="palette-option-label">{palette.id === themeState.paletteId ? <Sparkles size={13} /> : <MoonStar size={13} />}{palette.label}</span>
</button>
))}
</div>
<div className="profile-panel-title">
<MoonStar size={15} />
<strong></strong>
</div>
<div className="background-preset-grid profile-background-grid">
{themeBackgroundPresets.map((preset) => (
<button key={preset.id} type="button" className={cx("background-preset-option", preset.id === themeState.backgroundPresetId && "background-preset-option-active")} aria-pressed={preset.id === themeState.backgroundPresetId} title={themeState.backgroundImage ? `${preset.summary},移除上传背景后显示` : preset.summary} onClick={() => selectBackgroundPreset(preset.id)}>
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" />
<span className="background-preset-label">{preset.id === themeState.backgroundPresetId ? <Sparkles size={13} /> : <MoonStar size={13} />}{preset.label}</span>
</button>
))}
</div>
<div className="theme-background-actions">
<label className="theme-upload" title="上传自定义背景桌面">
<Upload size={14} />
<span></span>
<input type="file" accept="image/*" style={{ display: "none" }} onChange={handleBackgroundUpload} />
</label>
{themeState.backgroundImage && (
<button type="button" className="theme-upload" title="移除自定义背景" onClick={clearBackground}>
<X size={14} />
<span></span>
</button>
)}
</div>
<span className="theme-background-note">{themeState.backgroundImage ? "自定义上传背景正在显示,预设会作为移除后的备用桌面。" : "当前使用内置背景桌面。"}</span>
</section>
</section>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
# platform_web/pages
First-party page implementations live here after routes and contracts are defined.
Required page groups:
- `home`
- `servers`
- `plugins`
- `users`
- `ai-providers`
- `plugin-pages`
Do not put shared API clients, shared DTOs, route definitions, or bridge contracts in page files.
## Visual Rules
Pages must use the shared black-mecha / magical-girl visual system from `../theme/` instead of page-local card systems or unrelated palettes.
- Reuse shared shell, card, panel, table, drawer, dialog, status, command, log, diff, plugin group, and operation history classes from `theme/base.css`.
- Keep page surfaces translucent enough for the selected built-in or uploaded background desktop to remain visible.
- Ambient magical particles are global shell chrome. Pages must not add fixed decorative sparkles, hearts, moons, snowflakes, sigils, or custom backdrop layers.
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
- Preserve text/icons for status and operation results; do not rely on color-only cues.
- Read `../theme/README.md` before adding a new page surface pattern.
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types";
const preview: ServerConfigDiffPreviewResponse = {
serverInstanceId: "server-1",
configVersion: 7,
key: "server.properties",
currentContent: "max-players=20\npvp=true\n",
proposedContent: "max-players=40\npvp=true\n",
diff: [
{ kind: "removed", oldNumber: 1, content: "max-players=20" },
{ kind: "added", newNumber: 1, content: "max-players=40" },
{ kind: "context", oldNumber: 2, newNumber: 2, content: "pvp=true" }
],
hasChanges: true,
source: "platform-review",
reviewedAt: "2026-07-06T00:00:00Z"
};
describe("ServerDetailPage config write approval", () => {
it("maps platform diff preview responses into the display diff without losing approval metadata", () => {
const view = configDiffViewFromPreview(preview);
expect(view).toMatchObject({
serverInstanceId: "server-1",
configVersion: 7,
key: "server.properties",
source: "platform-review",
summary: "+1 / -1 行变更",
nextContent: "max-players=40\npvp=true\n"
});
expect(view.lines).toEqual([
{ kind: "removed", text: "max-players=20" },
{ kind: "added", text: "max-players=40" },
{ kind: "same", text: "pvp=true" }
]);
});
it("uses config preview and approval APIs instead of generic config.write job creation", () => {
expect(serverDetailPageSource).toContain("previewServerConfigDiff");
expect(serverDetailPageSource).toContain("approveServerConfigWrite");
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
});
it("does not locally mutate visible config after dispatching approval jobs", () => {
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
});
it("uses platform-mediated artifact download and bridge references without backend internals", () => {
expect(serverDetailPageSource).toContain("openArtifactDownload");
expect(serverDetailPageSource).toContain("readArtifactContent");
expect(serverDetailPageSource).toContain("parsePluginArtifactReference");
expect(serverDetailPageSource).toContain("浏览器制品传输");
expect(serverDetailPageSource).not.toContain("storage://");
expect(serverDetailPageSource).not.toContain("unix://");
expect(serverDetailPageSource).not.toContain("Bearer ");
});
it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => {
expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"');
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"');
expect(serverDetailPageSource).toContain('control.lifecycleAction === "start" || control.lifecycleAction === "stop"');
expect(serverDetailPageSource).toContain("platformApiClient.startServerInstance(instance.id");
expect(serverDetailPageSource).toContain("platformApiClient.stopServerInstance(instance.id");
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"start\")");
expect(serverDetailPageSource).toContain("serverLifecycleCommandRequest(instance, \"stop\")");
expect(serverDetailPageSource).not.toContain('capability: "process.start"');
expect(serverDetailPageSource).not.toContain('capability: "process.stop"');
});
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)");
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
expect(serverDetailPageSource).toContain("platformApiClient.readArtifactContent(reference.artifactId");
expect(serverDetailPageSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
expect(serverDetailPageSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
expect(serverDetailPageSource).not.toContain("storage://bucket");
expect(serverDetailPageSource).not.toContain("runSocket");
expect(serverDetailPageSource).not.toContain("rawApiKey");
expect(serverDetailPageSource).not.toContain("apiKeyRef");
});
});
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
defaultServerCreateForm,
endpointLabel,
pendingJobsForServer,
pluginLabel,
type ServerCreateFormState
} from "../contracts/serverManagement";
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
import { serverCreateRequestFromForm } from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
const statusFilters: Array<{ id: ServerStatusFilter; label: string }> = [
{ id: "all", label: "全部" },
{ id: "online", label: "在线" },
{ id: "offline", label: "离线" },
{ id: "attention", label: "需关注" }
];
export function ServersPage({ session, operations, onNavigate }: PageComponentProps) {
const [listState, setListState] = useState<ListState>("loading");
const [listError, setListError] = useState<string>("");
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
const [endpoints, setEndpoints] = useState<RunEndpointResponse[]>([]);
const [instances, setInstances] = useState<ServerInstanceResponse[]>([]);
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [metrics, setMetrics] = useState<Map<string, ServerMetricsResponse>>(new Map());
const [metricsPending, setMetricsPending] = useState(true);
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
const [showCreate, setShowCreate] = useState(false);
const refresh = useCallback(async () => {
setListState("loading");
try {
const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([
platformApiClient.listGamePlugins(),
platformApiClient.listRunEndpoints(),
platformApiClient.listServerInstances(),
platformApiClient.listJobs()
]);
setPlugins(pluginResponse.items);
setEndpoints(endpointResponse.items);
setInstances(instanceResponse.items);
setJobs(jobResponse.items);
setForm((current) => ({
...current,
pluginId: pluginResponse.items.some((plugin) => plugin.id === current.pluginId) ? current.pluginId : pluginResponse.items[0]?.id || "",
runEndpointId: endpointResponse.items.some((endpoint) => endpoint.id === current.runEndpointId)
? current.runEndpointId
: endpointResponse.items[0]?.id || ""
}));
setListState("ready");
setListError("");
} catch (error) {
setListState("error");
setListError(error instanceof Error ? error.message : "加载失败");
}
setMetricsPending(true);
try {
const metricsResponse = await platformApiClient.listServerMetrics();
setMetrics(new Map(metricsResponse.items.map((item) => [item.serverInstanceId, item])));
} catch {
setMetrics(new Map());
} finally {
setMetricsPending(false);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const cards = useMemo<ServerCardView[]>(
() =>
instances.map((instance) => ({
instance,
metrics: metrics.get(instance.id),
pendingJobs: pendingJobsForServer(jobs, instance.id).length
})),
[instances, jobs, metrics]
);
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
const createPending = operations.isPending("platform", "创建服务器");
function updateForm(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
const { name, value } = event.target;
setForm((current) => ({ ...current, [name]: value }));
}
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const operationId = operations.begin({ intent: "创建服务器", targetKind: "server", targetId: "platform", requester: session.displayName });
try {
const result = await platformApiClient.createServerWorkflow(serverCreateRequestFromForm(form));
operations.succeed(operationId, `已创建实例 ${result.instance.id},安装任务 ${result.job.id} 已派发`, result.job);
setForm(defaultServerCreateForm(plugins, endpoints));
setShowCreate(false);
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "创建失败", operationId);
}
}
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
return (
<section className="servers-page" aria-labelledby="server-page-title">
<header className="page-header">
<div>
<p className="page-kicker">{isPlatformAdmin(session) ? "平台管理员" : "我的服务器"}</p>
<h1 id="server-page-title" className="page-title">
</h1>
</div>
<div className="action-strip">
<button type="button" className="icon-command" title="刷新服务器状态" onClick={() => void refresh()}>
<Sparkles size={16} />
<span></span>
</button>
<button type="button" className="icon-command" title="创建服务器" onClick={() => setShowCreate((current) => !current)}>
<Candy size={16} />
<span></span>
</button>
</div>
</header>
{latestCreate && (
<div className="inline-result-strip" aria-live="polite">
<ResultBadge
status={latestCreate.status}
label={
latestCreate.status === "pending"
? "创建中…"
: latestCreate.status === "succeeded"
? (latestCreate.message ?? "创建成功")
: `创建失败:${latestCreate.errorReason ?? "未知原因"}(诊断 ${latestCreate.diagnosticId ?? latestCreate.id}`
}
/>
</div>
)}
{showCreate && (
<form className="provider-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
<div className="form-header">
<h2></h2>
</div>
<div className="form-grid">
<label>
ID
<input name="id" value={form.id} onChange={updateForm} placeholder="server-example-3" required />
</label>
<label>
<input name="name" value={form.name} onChange={updateForm} placeholder="Example Survival #3" required />
</label>
<label>
<select name="pluginId" value={form.pluginId} onChange={updateForm} required>
{plugins.map((plugin) => (
<option key={plugin.id} value={plugin.id}>
{pluginLabel(plugin, plugin.id)}
</option>
))}
</select>
</label>
<label>
<select name="runEndpointId" value={form.runEndpointId} onChange={updateForm} required>
{endpoints.map((endpoint) => (
<option key={endpoint.id} value={endpoint.id}>
{endpointLabel(endpoint, endpoint.id)}
</option>
))}
</select>
</label>
</div>
<button type="submit" className="primary-command" disabled={createPending} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span>
</button>
</form>
)}
<div className="server-toolbar" role="search">
<Search size={16} aria-hidden="true" />
<input
type="search"
value={keyword}
placeholder="搜索服务器名称、ID 或插件"
aria-label="搜索服务器"
onChange={(event) => setKeyword(event.target.value)}
/>
{statusFilters.map((filter) => (
<button
key={filter.id}
type="button"
className={cx("segmented-button", statusFilter === filter.id && "segmented-button-active")}
onClick={() => setStatusFilter(filter.id)}
>
{filter.label}
</button>
))}
</div>
{listState === "loading" && <LoadingState label="正在加载服务器列表…" />}
{listState === "error" && <ErrorState title="服务器列表加载失败" reason={listError} diagnosticId="server-list" onRetry={() => void refresh()} />}
{listState === "ready" && cards.length === 0 && (
<EmptyState
icon={<CakeSlice size={26} />}
title="暂无可管理的服务器"
description={
isPlatformAdmin(session)
? "平台还没有服务器实例。点击上方“创建服务器”开始,或检查运行节点状态。"
: "当前账号名下没有可管理的服务器。如果这不符合预期,请联系平台管理员为你分配服务器,或点击刷新重试。"
}
actionLabel="刷新"
onAction={() => void refresh()}
/>
)}
{listState === "ready" && cards.length > 0 && visibleCards.length === 0 && (
<EmptyState title="没有匹配的服务器" description="调整搜索关键字或状态筛选后再试。" actionLabel="清除筛选" onAction={() => {
setKeyword("");
setStatusFilter("all");
}} />
)}
{visibleCards.length > 0 && (
<div className="server-card-grid" aria-label="server list">
{visibleCards.map((card) => (
<ServerCard key={card.instance.id} card={card} metricsPending={metricsPending} onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })} />
))}
</div>
)}
</section>
);
}
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;
onOpen: () => void;
}
function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
const online = serverIsOnline(instance.state);
return (
<button type="button" className="server-card" onClick={onOpen} aria-label={`打开 ${instance.name} 详情`}>
<div className="server-card-head">
<span>
<strong>{instance.name}</strong>
<span className="provider-id">{instance.id}</span>
</span>
<span className={cx("status-pill", statusClass(instance.state))}>{stateLabel(instance.state)}</span>
</div>
<div className="server-card-stats">
<span className="server-card-stat">
<span></span>
<strong>{formatStat(metrics?.playerCount, metricsPending, (value) => (metrics?.maxPlayers ? `${value}/${metrics.maxPlayers}` : `${value}`))}</strong>
</span>
<span className="server-card-stat">
<span>TPS</span>
<strong>{formatStat(metrics?.tps, metricsPending, (value) => value.toFixed(1))}</strong>
</span>
<span className="server-card-stat">
<span></span>
<strong>{formatStat(metrics?.latencyMs, metricsPending, (value) => `${Math.round(value)}ms`)}</strong>
</span>
<span className="server-card-stat">
<span></span>
<strong>{pendingJobs > 0 ? `${pendingJobs} 进行中` : online ? "空闲" : "--"}</strong>
</span>
</div>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
</button>
);
}
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
if (typeof value === "number" && Number.isFinite(value)) {
return format(value);
}
return pending ? "…" : "--";
}
export function stateLabel(state: ServerInstanceResponse["state"]): string {
switch (state) {
case "installing":
return "安装中";
case "ready":
return "就绪";
case "running":
return "运行中";
case "stopped":
return "已停止";
case "failed":
return "异常";
case "draft":
return "草稿";
case "deleted":
return "已删除";
}
}
export function statusClass(state: ServerInstanceResponse["state"]): string {
if (state === "running" || state === "ready") {
return "status-active";
}
if (state === "failed" || state === "deleted") {
return "status-error";
}
return "status-disabled";
}
+296
View File
@@ -0,0 +1,296 @@
import { HeartHandshake, Sparkles, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { userAccess } from "../contracts/shell";
import { isPlatformAdmin } from "../contracts/workspace";
import { cx } from "../utils/classes";
const roleOptions = [
{ value: "platform-admin", label: "平台管理员" },
{ value: "server-owner", label: "服主" },
{ value: "server-admin", label: "服务器管理员" }
];
const statusOptions: Array<{ value: UserStatus; label: string }> = [
{ value: "active", label: "启用" },
{ value: "pending", label: "待审核" },
{ value: "disabled", label: "停用" }
];
const fallbackUsers: UserResponse[] = userAccess.map((user, index) => ({
id: user.id,
displayName: user.displayName,
email: `${user.id}@local.example.test`,
status: user.status,
roles: user.roles,
profile: {
phone: index === 0 ? "13800000000" : "",
qq: index === 0 ? "10001" : "",
contactNote: user.review
},
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
}));
export function UsersPage({ session, operations }: PageComponentProps) {
const [users, setUsers] = useState<UserResponse[]>(fallbackUsers);
const [loading, setLoading] = useState(true);
const [source, setSource] = useState<"api" | "local">("local");
const [loadError, setLoadError] = useState("");
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string }>();
const [draft, setDraft] = useState<UserCreateRequest>({
displayName: "",
email: "",
roles: ["server-admin"],
status: "pending",
profile: { phone: "", qq: "", contactNote: "" }
});
useEffect(() => {
let cancelled = false;
void platformApiClient
.listUsers()
.then((response) => {
if (!cancelled) {
setUsers(response.items);
setSource("api");
setLoadError("");
}
})
.catch(() => {
if (!cancelled) {
setUsers(fallbackUsers);
setSource("local");
setLoadError("账号 API 加载失败,当前显示本地样例;创建与状态更新仍会尝试平台 API。");
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, []);
const counts = useMemo(
() => ({
active: users.filter((user) => user.status === "active").length,
pending: users.filter((user) => user.status === "pending").length,
roleCount: new Set(users.flatMap((user) => user.roles)).size
}),
[users]
);
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const request: UserCreateRequest = {
...draft,
displayName: draft.displayName.trim(),
email: draft.email?.trim(),
roles: draft.roles.length ? draft.roles : ["server-admin"],
profile: {
phone: draft.profile?.phone?.trim(),
qq: draft.profile?.qq?.trim(),
contactNote: draft.profile?.contactNote?.trim()
}
};
const operationId = operations.begin({ intent: "创建用户", targetKind: "platform", targetId: "users", requester: session.displayName });
setResult({ status: "pending", label: `正在创建用户 ${operationId}` });
try {
const created = await platformApiClient.createUser(request);
setUsers((current) => [created, ...current.filter((user) => user.id !== created.id)]);
operations.succeed(operationId, `用户已创建:${created.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `已创建 ${created.displayName}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户创建失败");
setResult({ status: "failed", label: "用户创建失败,未写入数据库" });
}
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
}
async function setUserStatus(user: UserResponse, status: UserStatus) {
const operationId = operations.begin({ intent: "更新用户状态", targetKind: "platform", targetId: user.id, requester: session.displayName });
setResult({ status: "pending", label: `正在更新 ${user.displayName}` });
try {
const updated = await platformApiClient.updateUser(user.id, { status });
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
operations.succeed(operationId, `用户状态已更新:${updated.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `${updated.displayName}${statusLabel(updated.status)}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户状态更新失败");
setResult({ status: "failed", label: "状态更新失败,未写入数据库" });
}
}
function toggleRole(role: string) {
setDraft((current) => {
const nextRoles = current.roles.includes(role) ? current.roles.filter((item) => item !== role) : [...current.roles, role];
return { ...current, roles: nextRoles };
});
}
if (!isPlatformAdmin(session)) {
return (
<div className="console-page">
<PageFrame kicker="身份" title="用户管理" status="无访问权限" metrics={[]} />
<section className="console-panel">
<div className="state-view state-error" role="alert">
<HeartHandshake size={24} />
<strong></strong>
<p>访</p>
</div>
</section>
</div>
);
}
return (
<div className="console-page">
<PageFrame
kicker="身份"
title="用户管理"
status={source === "api" ? "账号 API 已连接" : "本地回退"}
metrics={[
{ label: "用户", value: `${users.length}`, tone: "success" },
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
{ label: "待审核", value: `${counts.pending}`, tone: "warning" }
]}
/>
{loading && <LoadingState label="正在加载用户列表…" />}
{loadError && <ErrorState title="用户 API 暂不可用" reason={loadError} diagnosticId="user-management:fallback" compact />}
<section className="console-panel">
<div className="panel-header">
<h2></h2>
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
</div>
<form className="management-form" onSubmit={createUser}>
<label>
<span></span>
<input value={draft.displayName} required onChange={(event) => setDraft((current) => ({ ...current, displayName: event.target.value }))} />
</label>
<label>
<span></span>
<input value={draft.email} type="email" onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))} />
</label>
<label>
<span></span>
<input
value={draft.profile?.phone ?? ""}
inputMode="tel"
onChange={(event) => setDraft((current) => ({ ...current, profile: { ...current.profile, phone: event.target.value } }))}
/>
</label>
<label>
<span>QQ</span>
<input
value={draft.profile?.qq ?? ""}
inputMode="numeric"
onChange={(event) => setDraft((current) => ({ ...current, profile: { ...current.profile, qq: event.target.value } }))}
/>
</label>
<label>
<span></span>
<input
value={draft.profile?.contactNote ?? ""}
onChange={(event) => setDraft((current) => ({ ...current, profile: { ...current.profile, contactNote: event.target.value } }))}
/>
</label>
<label>
<span></span>
<select value={draft.status} onChange={(event) => setDraft((current) => ({ ...current, status: event.target.value as UserStatus }))}>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="role-selector" aria-label="角色">
{roleOptions.map((role) => (
<button key={role.value} type="button" className={cx("role-chip", draft.roles.includes(role.value) && "role-chip-active")} onClick={() => toggleRole(role.value)}>
<Sparkles size={13} />
<span>{role.label}</span>
</button>
))}
</div>
<button type="submit" className="profile-save-button">
<UserRoundPlus size={14} />
<span></span>
</button>
</form>
</section>
<section className="console-panel">
<div className="panel-header">
<h2>访</h2>
<span className="page-status">{source === "api" ? "平台数据" : "本地样例 / 待同步"}</span>
</div>
{users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。创建用户后会在这里显示 API 连接结果。" />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
<span className="provider-id">{user.email ?? user.id}</span>
</div>
<span className={cx("status-pill", statusClass(user.status))}>
<UserRoundCheck size={13} />
{statusLabel(user.status)}
</span>
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
{statusOptions.map((option) => (
<button
key={option.value}
type="button"
className="theme-upload"
disabled={user.status === option.value}
aria-label={`${user.displayName} 设为${option.label}`}
onClick={() => void setUserStatus(user, option.value)}
>
{option.label}
</button>
))}
</div>
</article>
))}
</div>
)}
</section>
</div>
);
}
function statusLabel(status: UserStatus): string {
return status === "active" ? "启用" : status === "pending" ? "待审核" : "停用";
}
function statusClass(status: UserStatus): string {
return status === "active" ? "status-active" : status === "pending" ? "status-pending" : "status-disabled";
}
function roleLabel(role: string): string {
const matched = roleOptions.find((option) => option.value === role || option.value.replace("-", "") === role.toLowerCase());
return matched?.label ?? role;
}
function profileSummary(user: UserResponse): string {
const parts = [user.profile?.phone, user.profile?.qq ? `QQ ${user.profile.qq}` : undefined, user.profile?.contactNote].filter(Boolean);
return parts.length > 0 ? parts.join(" · ") : "暂无联系方式";
}
+22
View File
@@ -0,0 +1,22 @@
import type { ComponentType } from "react";
import type { PageComponentProps, PageId } from "../contracts/page";
import { AiProvidersPage } from "./AiProvidersPage";
import { HomePage } from "./HomePage";
import { MaintenancePage } from "./MaintenancePage";
import { PluginsPage } from "./PluginsPage";
import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage";
export const pageRegistry: Record<PageId, ComponentType<PageComponentProps>> = {
home: HomePage,
servers: ServersPage,
serverDetail: ServerDetailPage,
plugins: PluginsPage,
profileSettings: ProfileSettingsPage,
users: UsersPage,
aiProviders: AiProvidersPage,
maintenance: MaintenancePage
};