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
+4
View File
@@ -0,0 +1,4 @@
VITE_PLATFORM_API_BASE_URL=/api/v1
PLATFORM_API_PROXY=http://127.0.0.1:8080
VITE_ENABLE_LOCAL_AUTH_FALLBACK=true
+40
View File
@@ -0,0 +1,40 @@
# AGENTS.md for platform_web
This file applies to `platform_web/`.
## Frontend Scope
Build the actual management console, not a marketing site. Required first-party areas are 首页、服务器管理、插件市场、用户管理、AI 提供商管理.
## Structure Rules
Do not define API clients, shared DTOs, route definitions, schemas, or bridge contracts inside page components. Put them in dedicated directories.
## Interaction Rules
Do not use fixed left-list/right-detail master-detail layouts for server or plugin details. Use detail routes, modals, or drawers.
## Visual Style Rules
The platform_web visual system is a game operations console, not a generic SaaS dashboard. The default theme is black mecha; the selectable alternate theme is magical-girl. Future UI work must preserve the current style contract:
- Use the shared theme tokens in `theme/tokens.ts` and shared styles in `theme/base.css`; do not add page-local opaque card systems, one-off dark dashboards, or unrelated visual languages.
- Keep major surfaces translucent enough for the selected desktop/background to remain visible: side navigation, account/profile controls, metric cards, console panels, server cards, server detail headers, tables, drawers, dialogs, plugin groups, and operation history.
- Preserve theme-specific materials: black mecha uses dark cockpit panels, angular clipped frames, cyan scanner lines, and amber energy accents; magical-girl uses pink jelly glass, gold star frames, ribbon glow, and visible magic-circle motifs.
- Theme changes must be isolated and complete. Switching from magical-girl to black mecha must clear previous magical variables and update root theme marker, sidebar subtitle, active navigation frame, swatch strip, and shared surface accessories together.
- Do not double-frame nested content. A `.state-view` inside an already framed shared parent such as a console panel, card, table wrapper, plugin group, or operation item is content only; the parent owns the visible border and accessory layer.
- Keep the primary menu as a compact admin sidebar with two states: expanded text menu and collapsed icon rail. Do not reintroduce the single-column / double-column menu toggle. The same route order should render as a restrained dark mecha operations rail in black mecha and as a semi-transparent pink frosted-glass rail with star-framed active states in magical-girl.
- Use `components/MagicalParticleLayer.tsx` for full-workspace theme-aware ultimate effects. Each theme should have a distinct low-cost “大招” scene, not a dense field of tiny rotating particles. Do not reintroduce hardcoded fixed decorative DOM/CSS elements such as page-local sparkles, hearts, moons, snowflakes, or sigils.
- Built-in backgrounds must stay original CSS/generated motif desktops. Do not bundle recognizable third-party character art. User-uploaded backgrounds are allowed and must render behind readable contrast overlays.
- Uploaded backgrounds take precedence over built-in desktop presets; the selected preset remains the fallback after the upload is removed.
- Non-dangerous commands may use theme-appropriate lucide icons. Destructive, failed, warning, and safety-critical operations must retain familiar warning/status iconography and text.
- Status, errors, operation results, logs, configuration diffs, and LLM review output must remain readable, traceable, and not color-only.
- Cards and framed repeated items should keep 8px-or-less radii unless a native control shape requires a pill or circle.
See `theme/README.md` before changing theme tokens, shared CSS surfaces, page chrome, account/theme settings, or background behavior.
## Verification Rules
If a change touches UI pages or interactions, verify the key workflow in a browser before claiming acceptance.
For theme, frame, or uploaded-background changes, the browser walkthrough must include both directions of theme switching and must explicitly check that nested empty/loading/error states do not render a second border or accessory.
+15
View File
@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1
FROM node:22.17.0-alpine AS build
WORKDIR /src/platform_web
COPY platform_web/package.json platform_web/package-lock.json ./
RUN npm ci
COPY platform_web/ ./
ENV VITE_PLATFORM_API_BASE_URL=/api/v1
RUN npm run build
FROM nginx:1.27-alpine
COPY platform_web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/platform_web/dist /usr/share/nginx/html
EXPOSE 80
+91
View File
@@ -0,0 +1,91 @@
# platform_web
Management console frontend for the game server management platform.
## Required Pages
- 首页
- 服务器管理
- 插件市场
- 用户管理
- AI 提供商管理
## Required Directory Plan
Implementation should use dedicated directories for:
- `api/`: platform API clients and API DTO types.
- `routes/`: route definitions and guards.
- `pages/`: page-level views.
- `components/`: reusable UI components.
- `stores/`: state stores.
- `contracts/`: frontend page, plugin page bridge, and bridge contracts.
- `schemas/`: frontend validation schemas.
- `theme/`: design tokens and styling primitives.
- `utils/`: shared frontend helpers.
Plugin page must be hosted by platform_web with safe platform context and without raw credentials.
## Visual Direction
The management console uses a unified game-operations visual system with two first-party themes. The default theme is black mecha: dark cockpit panels, cyan scanner light, angular clipped frames, tactical grid lines, and amber energy accents. The optional theme is magical-girl: pink jelly glass, gold star frames, ribbon glow, and visible magic-circle motifs. It must not drift into a generic opaque SaaS dashboard.
Style guarantees for future changes:
- Use `theme/tokens.ts` for palettes, built-in background presets, storage keys, and theme application helpers.
- Use `theme/base.css` shared classes for shell, navigation, cards, panels, tables, drawers, dialogs, command buttons, status pills, logs, diffs, plugin groups, and operation history.
- Major surfaces stay translucent so the selected desktop/background remains visible while text remains readable.
- Built-in desktops are original CSS/generated motif backgrounds. User-uploaded backgrounds are supported and take visual precedence over the selected preset.
- Global ultimate motion is owned by `components/MagicalParticleLayer.tsx`; each theme should render one visible low-cost effect, such as a mecha scanner/core or magical-girl magic circle, rather than many tiny rotating particles. Do not add page-local fixed decorative spans or backdrop CSS.
- Keep operational clarity: status is text/icon plus color, logs and diffs stay high contrast, and operation feedback remains traceable.
- Read `theme/README.md` before changing style tokens or adding a new shared surface pattern.
## Development Baseline
Tooling:
- Node 22.17.0.
- npm 11.6.1.
- Vite 7, React 19, TypeScript 5.
Commands:
```bash
npm install
npm run dev
npm run typecheck
npm run test
npm run acceptance:browser
npm run build
npm run preview
```
Runtime configuration:
- `VITE_PLATFORM_API_BASE_URL`: platform API base URL, default `/api/v1`.
- `PLATFORM_API_PROXY`: Vite dev-server proxy target for `/api/v1` and `/healthz`, default `http://127.0.0.1:8080`.
- `VITE_ENABLE_LOCAL_AUTH_FALLBACK`: enables local development auth fallback when set to `true`.
For local direct debugging, copy `platform_web/.env.example` to `platform_web/.env`, edit the values, and run:
```bash
npm run dev
```
For Docker, the web console is built with `VITE_PLATFORM_API_BASE_URL=/api/v1` and served by Nginx. Nginx proxies `/api/v1` and `/healthz` to the `platform` compose service, so browser code never needs a direct backend container address.
Current UI behavior is a browser-verifiable console shell with the required first-party page routes. Data-backed workflows, plugin page hosting, and API integration belong to later OpenSpec changes.
Browser walkthrough baseline:
1. Start `npm run dev`.
2. Open the local Vite URL.
3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths.
Automated browser acceptance uses the repository local debug stack:
```bash
LOCAL_DEBUG_PLATFORM_PORT=18189 LOCAL_DEBUG_WEB_PORT=5183 LOCAL_DEBUG_ROOT=/private/tmp/browser-local-debug-acceptance ../scripts/browser-acceptance.sh
```
This command verifies the API-backed local debug console path, first-party route markers, plugin/server operation proof, fallback rejection, and forbidden-fragment scans. It writes evidence under `<LOCAL_DEBUG_ROOT>/browser-acceptance/`.
@@ -0,0 +1,870 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
const platformUrl = process.env.LOCAL_DEBUG_PLATFORM_URL || `http://127.0.0.1:${process.env.LOCAL_DEBUG_PLATFORM_PORT || "18080"}`;
const webUrl = process.env.LOCAL_DEBUG_WEB_URL || `http://127.0.0.1:${process.env.LOCAL_DEBUG_WEB_PORT || "5173"}`;
const localDebugRoot = process.env.LOCAL_DEBUG_ROOT || path.resolve(".local-debug");
const evidenceDir = process.env.BROWSER_ACCEPTANCE_EVIDENCE_DIR || path.join(localDebugRoot, "browser-acceptance");
const apiUrl = `${platformUrl}/api/v1`;
const forbiddenFragments = [
{ name: "host user path", pattern: /\/Users\// },
{ name: "private tmp path", pattern: /\/private\// },
{ name: "unix socket", pattern: /unix:\/\// },
{ name: "tcp socket", pattern: /tcp:\/\// },
{ name: "bearer token", pattern: /Bearer\s+/ },
{ name: "raw api key", pattern: /sk-[A-Za-z0-9_-]+/ },
{ name: "password query", pattern: /password=/ },
{ name: "raw api key ref field", pattern: /apiKeyRef/ },
{ name: "raw api key field", pattern: /rawApiKey/ },
{ name: "session token", pattern: /sessionToken|run session token/i },
{ name: "direct run URL", pattern: /direct run URL/i },
{ name: "plugin-owned transport", pattern: /plugin-owned transport/i }
];
const fallbackFragments = [
{ name: "English fallback", pattern: /fallback/i },
{ name: "demo-only", pattern: /demo-only/i },
{ name: "local fallback", pattern: /local fallback/i },
{ name: "Chinese fallback", pattern: /本地回退|本地演示数据|演示数据|模拟数据|本地视图/ }
];
async function main() {
await mkdir(evidenceDir, { recursive: true });
const session = await loginApi();
const authHeaders = { Authorization: `Bearer ${session.sessionId}` };
await ensureAiProvider(authHeaders);
const [instances, endpoints, jobs, plugins, marketplace, users, providers, logStreams, artifacts, usage] = await Promise.all([
getJson("/server-instances", authHeaders),
getJson("/run/endpoints?status=online", authHeaders),
getJson("/jobs?serverInstanceId=server-local-debug", authHeaders),
getJson("/game-plugins", authHeaders),
getJson("/plugin-marketplace/plugins", authHeaders),
getJson("/users", authHeaders),
getJson("/ai-providers", authHeaders),
getJson("/log-streams", authHeaders),
getJson("/artifacts", authHeaders),
getJson("/metrics/platform", authHeaders)
]);
const server = findRequired(instances.items, (item) => item.id === "server-local-debug", "server-local-debug instance");
const runEndpoint = findRequired(endpoints.items, (item) => item.id === "run-local-debug", "run-local-debug endpoint");
const plugin = findRequired(plugins.items, (item) => item.id === "game.example", "game.example plugin");
const marketplacePlugin = findRequired(marketplace.items, (item) => item.id === "game.example", "game.example marketplace plugin");
const operator = findRequired(users.items, (item) => item.email === "operator.local@example.test", "operator local user");
const aiProvider = findRequired(providers.items, (item) => item.id === "ai.openai" || item.apiKeyRef?.startsWith("secret://"), "redacted AI provider");
assertEqual(server.pluginId, "game.example", "server is backed by game.example");
assertEqual(server.runEndpointId, "run-local-debug", "server is assigned to run-local-debug");
assertIncludes(runEndpoint.capabilities, "process.install", "run endpoint exposes process.install");
assertIncludes(runEndpoint.capabilities, "process.start", "run endpoint exposes process.start");
assertIncludes(runEndpoint.capabilities, "process.stop", "run endpoint exposes process.stop");
assertIncludes(plugin.requiredRunCapabilities, "process.start", "plugin requires process.start");
assertIncludes(plugin.bridgeActions, "logs.query", "plugin exposes logs.query bridge action");
assertIncludes(plugin.bridgeActions, "artifacts.open", "plugin exposes artifacts.open bridge action");
assertIncludes(marketplacePlugin.capabilities, "process.start", "marketplace exposes lifecycle capability");
assertSafeRedactedRef(aiProvider.apiKeyRef, "AI provider key reference");
const chrome = await startChrome();
const evidence = {
checkedAt: new Date().toISOString(),
platformUrl,
webUrl,
localDebugRoot,
seedEvidenceDir: path.join(localDebugRoot, "smoke"),
session: {
userId: session.user.id,
displayName: session.user.displayName,
source: "api"
},
environment: await verifyFrontendEnvironment(),
browser: { chromePath: chrome.chromePath },
apiProof: {
server: pick(server, ["id", "name", "pluginId", "pluginVersion", "runEndpointId", "state", "configVersion"]),
runEndpoint: pick(runEndpoint, ["id", "displayName", "status", "capabilities"]),
plugin: pick(plugin, ["id", "name", "version", "status", "manifestRef", "requiredRunCapabilities", "bridgeActions", "declaredPermissions"]),
marketplacePlugin: pick(marketplacePlugin, ["id", "name", "version", "status", "manifestRef", "capabilities", "bridgeActions", "declaredPermissions"]),
operator: pick(operator, ["id", "displayName", "email", "status", "roles"]),
aiProvider: pick(aiProvider, ["id", "name", "kind", "relayMode", "status", "apiKeyRef"]),
jobs: jobs.items.map((job) => pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"])),
logStreams: logStreams.items.map((stream) => pick(stream, ["id", "serverInstanceId", "streamKey", "source"])),
artifacts: artifacts.items.map((artifact) => pick(artifact, ["id", "ownerKind", "ownerId", "state", "checksum"])),
usage: pick(usage, ["cpuPercent", "memoryPercent", "diskPercent", "source"])
},
routes: [],
safety: {
forbiddenFragments: forbiddenFragments.map((item) => item.name),
fallbackFragments: fallbackFragments.map((item) => item.name)
}
};
try {
await chrome.navigate(webUrl);
const loginState = await loginInBrowser(chrome);
evidence.login = loginState;
const routeChecks = [
{
name: "首页",
hash: "#/home",
markers: ["平台概览", "数据已加载", "game.example", "运行节点", "CPU"]
},
{
name: "服务器管理",
hash: "#/servers",
markers: ["服务器管理", server.name, server.id, "创建服务器", "全部", "离线"]
},
{
name: "插件市场",
hash: "#/plugins",
markers: [
"插件市场",
"平台 API",
marketplacePlugin.id,
marketplacePlugin.manifestRef,
"process.install",
"process.start",
"process.stop",
"server.instances.read",
"jobs.dispatch",
"logs.query",
"artifacts.open"
]
},
{
name: "用户管理",
hash: "#/users",
markers: ["用户管理", "账号 API 已连接", operator.displayName, operator.email, "平台数据"]
},
{
name: "AI 提供商管理",
hash: "#/aiProviders",
markers: ["AI 提供商管理", "已连接", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
},
{
name: "服务器详情",
hash: "#/servers/server-local-debug",
markers: [
server.name,
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
"启动",
"停止",
"日志",
"配置",
"插件控制",
"AI 助手",
"操作历史"
]
}
];
for (const route of routeChecks) {
const state = await verifyBrowserRoute(chrome, route.hash, route.markers, route.name);
evidence.routes.push(state);
}
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
evidence.routes.push({ name: "服务器详情 / 插件控制", url: await chrome.url(), ...pluginControls });
evidence.walkthroughs = await verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server);
const operationProof = await verifyLifecycleOperation(authHeaders, server, chrome);
evidence.operationProof = operationProof;
} finally {
await chrome.close();
}
const evidencePath = path.join(evidenceDir, "browser-acceptance-evidence.json");
await writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
console.log("browser acceptance passed");
console.log(`evidence directory: ${evidenceDir}`);
console.log(`evidence file: ${evidencePath}`);
}
async function loginApi() {
const response = await postJson("/auth/login", {
account: "operator.local@example.test",
password: "operator-local"
});
if (!response.sessionId || response.status !== "authenticated") {
throw new Error("local debug API login did not return an active session");
}
return response;
}
async function loginInBrowser(chrome) {
await chrome.waitForText(["账号 / 邮箱", "密码", "登录"], "login form");
await chrome.evaluate(() => {
const inputs = Array.from(document.querySelectorAll("input"));
const account = inputs.find((input) => input.type !== "password");
const password = inputs.find((input) => input.type === "password");
const submit = document.querySelector('button[type="submit"]');
if (!(account instanceof HTMLInputElement) || !(password instanceof HTMLInputElement) || !(submit instanceof HTMLButtonElement)) {
throw new Error("login controls not found");
}
const setInputValue = (input, value) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
};
setInputValue(account, "operator.local@example.test");
setInputValue(password, "operator-local");
submit.click();
});
await chrome.waitForText(["平台概览", "数据已加载"], "post-login home");
const visibleText = await chrome.visibleText();
scanText(visibleText, "登录后首页");
return {
url: await chrome.url(),
requiredMarkers: ["平台概览", "数据已加载"],
fallbackScan: "passed",
forbiddenFragmentScan: "passed"
};
}
async function verifyBrowserRoute(chrome, hash, markers, name) {
await chrome.evaluate((nextHash) => {
window.location.hash = nextHash;
}, hash);
await chrome.waitForText(markers, name);
const visibleText = await chrome.visibleText();
assertMarkers(visibleText, markers, name);
scanText(visibleText, name);
return {
name,
url: await chrome.url(),
requiredMarkers: markers,
fallbackScan: "passed",
forbiddenFragmentScan: "passed",
textSample: visibleText.slice(0, 1200)
};
}
async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
const walkthroughs = [];
const scenarios = [
{
name: "desktop / black mecha",
viewport: { width: 1440, height: 960, mobile: false },
paletteId: "mecha-black",
backgroundId: "mecha-grid",
themeMarkers: ["Mecha Game Console", "黑色机甲 / OPS"]
},
{
name: "mobile / black mecha",
viewport: { width: 390, height: 844, mobile: true },
paletteId: "mecha-black",
backgroundId: "mecha-grid",
themeMarkers: ["Mecha Game Console", "黑色机甲 / OPS"]
},
{
name: "desktop / magical-girl",
viewport: { width: 1440, height: 960, mobile: false },
paletteId: "magical-girl",
backgroundId: "magic-stage",
themeMarkers: ["Mecha Game Console", "魔法少女 / OPS"]
},
{
name: "mobile / magical-girl",
viewport: { width: 390, height: 844, mobile: true },
paletteId: "magical-girl",
backgroundId: "magic-stage",
themeMarkers: ["Mecha Game Console", "魔法少女 / OPS"]
}
];
for (const scenario of scenarios) {
await chrome.setViewport(scenario.viewport);
await applyBrowserTheme(chrome, scenario.paletteId, scenario.backgroundId);
const routeEvidence = [];
for (const route of routeChecks) {
const state = await verifyBrowserRoute(chrome, route.hash, [...route.markers, ...scenario.themeMarkers], `${scenario.name} / ${route.name}`);
const layout = await chrome.layoutSnapshot();
assertNoVisibleLayoutIssues(layout, `${scenario.name} / ${route.name}`);
routeEvidence.push({
name: route.name,
url: state.url,
requiredMarkers: state.requiredMarkers,
fallbackScan: state.fallbackScan,
forbiddenFragmentScan: state.forbiddenFragmentScan,
layout,
textSample: state.textSample
});
}
await chrome.evaluate(() => {
window.location.hash = "#/servers/server-local-debug";
});
await chrome.waitForText([server.name, "插件控制"], `${scenario.name} / server detail tabs`);
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
const pluginLayout = await chrome.layoutSnapshot();
assertNoVisibleLayoutIssues(pluginLayout, `${scenario.name} / plugin controls`);
routeEvidence.push({
name: "服务器详情 / 插件控制",
url: await chrome.url(),
requiredMarkers: pluginControls.requiredMarkers,
fallbackScan: pluginControls.fallbackScan,
forbiddenFragmentScan: pluginControls.forbiddenFragmentScan,
layout: pluginLayout,
textSample: pluginControls.textSample
});
walkthroughs.push({
name: scenario.name,
viewport: scenario.viewport,
paletteId: scenario.paletteId,
backgroundId: scenario.backgroundId,
routeCount: routeEvidence.length,
routes: routeEvidence
});
}
return walkthroughs;
}
async function applyBrowserTheme(chrome, paletteId, backgroundId) {
await chrome.evaluate(
({ paletteId: nextPaletteId, backgroundId: nextBackgroundId }) => {
window.localStorage.setItem("platform-web.theme.palette", nextPaletteId);
window.localStorage.setItem("platform-web.theme.backgroundPreset", nextBackgroundId);
window.localStorage.removeItem("platform-web.theme.background");
window.location.reload();
},
{ paletteId, backgroundId }
);
await delay(500);
}
function assertNoVisibleLayoutIssues(layout, label) {
if (layout.horizontalOverflow > 1) {
throw new Error(`${label} has horizontal overflow: scrollWidth ${layout.scrollWidth} > viewport ${layout.innerWidth}`);
}
const overlapping = layout.overlappingControls.filter((item) => item.area > 24);
if (overlapping.length > 0) {
throw new Error(`${label} has overlapping controls: ${JSON.stringify(overlapping.slice(0, 3))}`);
}
if (layout.visibleTinyTextBoxes > 0) {
throw new Error(`${label} has ${layout.visibleTinyTextBoxes} visible clipped/tiny text boxes`);
}
}
async function clickAndVerify(chrome, buttonText, markers) {
await chrome.evaluate((label) => {
const button = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.includes(label));
if (!(button instanceof HTMLButtonElement)) {
throw new Error(`button not found: ${label}`);
}
button.click();
}, buttonText);
await chrome.waitForText(markers, buttonText);
const visibleText = await chrome.visibleText();
assertMarkers(visibleText, markers, buttonText);
scanText(visibleText, buttonText);
return {
requiredMarkers: markers,
fallbackScan: "passed",
forbiddenFragmentScan: "passed",
textSample: visibleText.slice(0, 1200)
};
}
async function ensureAiProvider(headers) {
const providers = await getJson("/ai-providers", headers);
if (providers.items.some((item) => item.id === "ai.openai")) {
return;
}
await postJson(
"/ai-providers",
{
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,
redactionPolicy: "default"
},
headers
);
}
async function verifyFrontendEnvironment() {
const [html, packageJsonText, viteConfigText] = await Promise.all([
fetchText(webUrl),
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../vite.config.ts", import.meta.url), "utf8")
]);
const packageJson = JSON.parse(packageJsonText);
const checks = [
{ name: "serves app root", passed: html.includes('<div id="root"></div>') },
{ name: "serves module app entry", passed: html.includes('type="module"') },
{ name: "Vite proxy includes /api/v1", passed: viteConfigText.includes('"/api/v1"') },
{ name: "Vite proxy includes /healthz", passed: viteConfigText.includes('"/healthz"') },
{ name: "Vite env uses platform proxy", passed: viteConfigText.includes("PLATFORM_API_PROXY") },
{ name: "package has browser acceptance script", passed: Boolean(packageJson.scripts?.["acceptance:browser"]) }
];
const failed = checks.filter((check) => !check.passed);
if (failed.length > 0) {
throw new Error(`frontend environment checks failed: ${failed.map((item) => item.name).join(", ")}`);
}
return { webUrl, checks };
}
async function verifyLifecycleOperation(headers, server, chrome) {
const request = { expectedConfigVersion: server.configVersion, idempotencyKey: `browser-acceptance-start-${Date.now()}` };
const result = await postJson(`/server-instances/${encodeURIComponent(server.id)}/start`, request, headers);
if (!result.accepted || !result.job?.id) {
throw new Error("lifecycle start operation did not return accepted job evidence");
}
if (result.job.capability !== "process.start") {
throw new Error(`lifecycle job used unexpected capability ${result.job.capability}`);
}
if (result.job.runEndpointId !== "run-local-debug") {
throw new Error(`lifecycle job used unexpected run endpoint ${result.job.runEndpointId}`);
}
const job = await waitForJob(headers, server.id, result.job.id);
await chrome.navigate(`${webUrl}/#/servers/server-local-debug`);
await chrome.waitForText([server.name, "操作历史"], "server detail after lifecycle operation");
const historyState = await clickAndVerify(chrome, "操作历史", ["操作历史", "平台任务记录", "server-lifecycle", "process."]);
return {
action: "start",
accepted: result.accepted,
request: {
expectedConfigVersion: request.expectedConfigVersion,
idempotencyKey: request.idempotencyKey
},
acceptedJob: pick(result.job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
job: pick(job, ["id", "serverInstanceId", "runEndpointId", "capability", "state", "resultRef"]),
proof: "platform API accepted process.start and platform-owned jobs endpoint returned the same job; browser verified operation history entry point without direct run access",
browserEvidence: {
...historyState,
proofMode: "operation-history-entry-point"
}
};
}
async function waitForJob(headers, serverId, jobId) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const jobs = await getJson(`/jobs?serverInstanceId=${encodeURIComponent(serverId)}`, headers);
const job = jobs.items.find((item) => item.id === jobId);
if (job) {
return job;
}
await delay(250);
}
throw new Error(`platform jobs endpoint did not return lifecycle operation job ${jobId}`);
}
async function startChrome() {
const chromePath = findChromePath();
const userDataDir = path.join(evidenceDir, "chrome-profile");
await rm(userDataDir, { recursive: true, force: true });
await mkdir(userDataDir, { recursive: true });
const chrome = spawn(chromePath, [
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
`--user-data-dir=${userDataDir}`,
"--remote-debugging-port=0",
"about:blank"
], {
stdio: ["ignore", "ignore", "pipe"]
});
let stderr = "";
chrome.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
const activePortFile = path.join(userDataDir, "DevToolsActivePort");
let port = "";
for (let attempt = 0; attempt < 80; attempt += 1) {
try {
const activePort = await readFile(activePortFile, "utf8");
port = activePort.split(/\r?\n/)[0]?.trim();
if (port) {
break;
}
} catch {
// keep waiting for Chrome to write DevToolsActivePort
}
await delay(250);
}
if (!port) {
chrome.kill("SIGTERM");
throw new Error(`Chrome did not expose a DevTools port. ${stderr.slice(0, 500)}`);
}
const targetResponse = await fetch(`http://127.0.0.1:${port}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" });
if (!targetResponse.ok) {
chrome.kill("SIGTERM");
throw new Error(`failed to create Chrome target: HTTP ${targetResponse.status}`);
}
const target = await targetResponse.json();
const client = await CdpClient.connect(target.webSocketDebuggerUrl);
await client.send("Page.enable");
await client.send("Runtime.enable");
return {
chromePath,
async setViewport({ width, height, mobile }) {
await client.send("Emulation.setDeviceMetricsOverride", {
width,
height,
deviceScaleFactor: mobile ? 2 : 1,
mobile
});
},
async navigate(url) {
const loaded = client.waitFor("Page.loadEventFired", 15000);
await client.send("Page.navigate", { url });
await loaded.catch(() => undefined);
},
async evaluate(pageFunction, arg) {
const expression = `(${pageFunction.toString()})(${arg === undefined ? "" : JSON.stringify(arg)})`;
const result = await client.send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || "browser evaluation failed");
}
return result.result?.value;
},
async visibleText() {
return this.evaluate(() => document.body?.innerText || "");
},
async layoutSnapshot() {
return this.evaluate(() => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const scrollWidth = document.documentElement.scrollWidth;
const viewportClip = { left: 0, top: 0, right: viewportWidth, bottom: viewportHeight };
const rectFromDomRect = (rect) => ({
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom
});
const intersection = (first, second) => ({
left: Math.max(first.left, second.left),
top: Math.max(first.top, second.top),
right: Math.min(first.right, second.right),
bottom: Math.min(first.bottom, second.bottom)
});
const clipForElement = (element) => {
let clip = viewportClip;
let parent = element.parentElement;
while (parent) {
const style = window.getComputedStyle(parent);
const clipsX = ["auto", "scroll", "hidden", "clip"].includes(style.overflowX);
const clipsY = ["auto", "scroll", "hidden", "clip"].includes(style.overflowY);
if (clipsX || clipsY) {
const parentRect = rectFromDomRect(parent.getBoundingClientRect());
clip = intersection(clip, {
left: clipsX ? parentRect.left : clip.left,
top: clipsY ? parentRect.top : clip.top,
right: clipsX ? parentRect.right : clip.right,
bottom: clipsY ? parentRect.bottom : clip.bottom
});
}
parent = parent.parentElement;
}
return clip;
};
const controls = Array.from(document.querySelectorAll("button, a, input, select, textarea, [role='button'], [role='tab']"))
.filter((element) => {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
})
.map((element) => {
const rect = element.getBoundingClientRect();
const clipped = intersection(rectFromDomRect(rect), clipForElement(element));
const visibleWidth = Math.max(0, clipped.right - clipped.left);
const visibleHeight = Math.max(0, clipped.bottom - clipped.top);
return {
tag: element.tagName.toLowerCase(),
text: (element.textContent || element.getAttribute("aria-label") || "").trim().slice(0, 60),
left: Math.round(clipped.left),
top: Math.round(clipped.top),
right: Math.round(clipped.right),
bottom: Math.round(clipped.bottom),
width: Math.round(visibleWidth),
height: Math.round(visibleHeight)
};
})
.filter((control) => control.width > 0 && control.height > 0);
const overlappingControls = [];
for (let index = 0; index < controls.length; index += 1) {
for (let otherIndex = index + 1; otherIndex < controls.length; otherIndex += 1) {
const left = Math.max(controls[index].left, controls[otherIndex].left);
const top = Math.max(controls[index].top, controls[otherIndex].top);
const right = Math.min(controls[index].right, controls[otherIndex].right);
const bottom = Math.min(controls[index].bottom, controls[otherIndex].bottom);
const width = right - left;
const height = bottom - top;
if (width > 0 && height > 0) {
overlappingControls.push({
first: controls[index].text || controls[index].tag,
second: controls[otherIndex].text || controls[otherIndex].tag,
area: Math.round(width * height)
});
}
}
}
const visibleTinyTextBoxes = Array.from(document.querySelectorAll("button, a, label, h1, h2, h3, p, span, strong, td, th"))
.filter((element) => {
const text = (element.textContent || "").trim();
if (!text) {
return false;
}
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none" && rect.width < 4;
}).length;
return {
innerWidth: viewportWidth,
innerHeight: viewportHeight,
scrollWidth,
horizontalOverflow: Math.max(0, scrollWidth - viewportWidth),
controlCount: controls.length,
overlappingControls: overlappingControls.slice(0, 10),
visibleTinyTextBoxes
};
});
},
async url() {
return this.evaluate(() => window.location.href);
},
async waitForText(markers, label) {
const required = Array.isArray(markers) ? markers : [markers];
for (let attempt = 0; attempt < 80; attempt += 1) {
const text = await this.visibleText();
if (required.every((marker) => text.includes(marker))) {
return;
}
await delay(250);
}
const text = await this.visibleText();
const missing = required.filter((marker) => !text.includes(marker));
throw new Error(`${label} missing browser markers: ${missing.join(", ")}. Visible sample: ${text.slice(0, 500)}`);
},
async close() {
await client.close();
await stopChrome(chrome);
await rm(userDataDir, { recursive: true, force: true });
}
};
}
async function stopChrome(chrome) {
if (chrome.exitCode !== null || chrome.signalCode !== null) {
return;
}
const exited = new Promise((resolve) => {
chrome.once("exit", resolve);
});
chrome.kill("SIGTERM");
const stopped = await Promise.race([
exited.then(() => true),
delay(3000).then(() => false)
]);
if (!stopped && chrome.exitCode === null && chrome.signalCode === null) {
chrome.kill("SIGKILL");
await Promise.race([
exited,
delay(3000)
]);
}
}
class CdpClient {
constructor(socket) {
this.socket = socket;
this.nextId = 1;
this.pending = new Map();
this.waiters = new Map();
socket.addEventListener("message", (event) => this.handleMessage(event));
}
static async connect(url) {
const socket = new WebSocket(url);
await new Promise((resolve, reject) => {
socket.addEventListener("open", resolve, { once: true });
socket.addEventListener("error", reject, { once: true });
});
return new CdpClient(socket);
}
send(method, params = {}) {
const id = this.nextId;
this.nextId += 1;
const promise = new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
this.socket.send(JSON.stringify({ id, method, params }));
return promise;
}
waitFor(method, timeoutMs) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const waiters = this.waiters.get(method) || [];
this.waiters.set(method, waiters.filter((item) => item.resolve !== resolve));
reject(new Error(`timed out waiting for ${method}`));
}, timeoutMs);
const waiters = this.waiters.get(method) || [];
waiters.push({
resolve: (params) => {
clearTimeout(timeout);
resolve(params);
}
});
this.waiters.set(method, waiters);
});
}
handleMessage(event) {
const message = JSON.parse(event.data);
if (message.id) {
const pending = this.pending.get(message.id);
if (pending) {
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message));
} else {
pending.resolve(message.result);
}
}
return;
}
if (message.method && this.waiters.has(message.method)) {
const waiters = this.waiters.get(message.method);
const waiter = waiters.shift();
if (waiters.length === 0) {
this.waiters.delete(message.method);
}
waiter?.resolve(message.params);
}
}
async close() {
this.socket.close();
}
}
async function getJson(pathname, headers = {}) {
return requestJson("GET", pathname, undefined, headers);
}
async function postJson(pathname, body, headers = {}) {
return requestJson("POST", pathname, body, headers);
}
async function requestJson(method, pathname, body, headers = {}) {
const response = await fetch(`${apiUrl}${pathname}`, {
method,
headers: {
Accept: "application/json",
...(body === undefined ? {} : { "Content-Type": "application/json" }),
...headers
},
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(`${method} ${pathname} failed with HTTP ${response.status}: ${text.slice(0, 300)}`);
}
return response.json();
}
async function fetchText(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`GET ${url} failed with HTTP ${response.status}`);
}
return response.text();
}
function findChromePath() {
const candidates = [
process.env.CHROME_BIN,
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"google-chrome",
"chromium",
"chromium-browser"
].filter(Boolean);
return candidates.find((candidate) => {
if (candidate.startsWith("/")) {
return true;
}
return false;
}) || candidates[0];
}
function findRequired(items, predicate, label) {
const item = items.find(predicate);
if (!item) {
throw new Error(`missing required ${label}`);
}
return item;
}
function assertMarkers(text, markers, routeName) {
const missing = markers.filter((marker) => !text.includes(marker));
if (missing.length > 0) {
throw new Error(`${routeName} missing required markers: ${missing.join(", ")}`);
}
}
function scanText(text, routeName) {
const fallback = fallbackFragments.find((item) => item.pattern.test(text));
if (fallback) {
throw new Error(`${routeName} contains fallback/demo marker: ${fallback.name}`);
}
const forbidden = forbiddenFragments.find((item) => item.pattern.test(text));
if (forbidden) {
throw new Error(`${routeName} contains forbidden visible fragment: ${forbidden.name}`);
}
}
function assertEqual(actual, expected, label) {
if (actual !== expected) {
throw new Error(`${label}: expected ${expected}, got ${actual}`);
}
}
function assertIncludes(values, expected, label) {
if (!Array.isArray(values) || !values.includes(expected)) {
throw new Error(`${label}: missing ${expected}`);
}
}
function assertSafeRedactedRef(value, label) {
if (typeof value !== "string" || (!value.startsWith("secret://") && !value.startsWith("env://"))) {
throw new Error(`${label} must be a safe secret/env reference`);
}
}
function pick(value, keys) {
return Object.fromEntries(keys.map((key) => [key, value?.[key]]));
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from "vitest";
describe("platformApiClient runtime environment", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.resetModules();
});
it("uses VITE_PLATFORM_API_BASE_URL for the shared client", async () => {
vi.stubEnv("VITE_PLATFORM_API_BASE_URL", "http://127.0.0.1:18080/api/v1");
vi.resetModules();
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify({ items: [], count: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" }
})
);
vi.stubGlobal("fetch", fetchMock);
const { platformApiClient } = await import("./client");
await expect(platformApiClient.listUsers()).resolves.toMatchObject({ count: 0 });
expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:18080/api/v1/users", expect.any(Object));
});
});
+506
View File
@@ -0,0 +1,506 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient, setPlatformApiSessionToken } from "./client";
import type { AiProviderResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI",
kind: "openai",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1"],
defaultModel: "gpt-4.1",
relayMode: "direct",
timeoutMs: 30000,
status: "active",
redactionPolicy: "default"
};
const plugin: GamePluginResponse = {
id: "game.example",
name: "Example Server",
version: "0.1.0",
serverType: "example",
serverDisplayName: "Example Server",
manifestRef: "artifact://manifests/game.example/0.1.0",
createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: ["process.start", "logs.read"],
declaredPermissions: ["server.read", "server.logs.read", "ai.invoke"],
permissions: { ai: true, logs: true, files: false, jobs: false, artifacts: false },
lifecycleActions: { start: "actions/start.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"
};
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: false, 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"
};
const server: ServerInstanceResponse = {
id: "server-1",
pluginId: "game.example",
pluginVersion: "0.1.0",
runEndpointId: "run-local",
name: "Example Survival #1",
ownerUserId: "user-owner",
adminUserIds: ["user-admin-1"],
state: "running",
configVersion: 1,
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
const endpoint: RunEndpointResponse = {
id: "run-local",
displayName: "Local Run",
version: "0.1.0",
status: "online",
capabilities: ["process.install", "process.start", "process.stop"],
capacity: { maxJobs: 4, runningJobs: 0, queuedJobs: 1 },
lastHeartbeatAt: "2026-07-03T00:00:00Z"
};
const job: JobResponse = {
id: "job-1",
serverInstanceId: server.id,
runEndpointId: endpoint.id,
capability: "process.start",
idempotencyKey: "idem-start",
state: "queued",
progress: { percent: 0, message: "queued" },
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
const artifact = {
id: "artifact-1",
ownerKind: "job",
ownerId: job.id,
sizeBytes: 18,
checksum: "sha256:artifactchecksum",
state: "available",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
describe("PlatformApiClient AI providers", () => {
afterEach(() => {
setPlatformApiSessionToken(null);
vi.restoreAllMocks();
});
it("calls AI provider management endpoints with named contracts", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/ai-providers") && (!init?.method || init.method === "GET")) {
return jsonResponse({ items: [provider], count: 1 });
}
if (url.endsWith("/api/v1/ai-providers") && init?.method === "POST") {
return jsonResponse(provider);
}
if (url.endsWith("/api/v1/ai-providers/ai.openai") && init?.method === "PUT") {
return jsonResponse({ ...provider, name: "OpenAI Relay" });
}
if (url.endsWith("/api/v1/ai-providers/ai.openai/status") && init?.method === "POST") {
return jsonResponse({ ...provider, status: "disabled" });
}
if (url.endsWith("/api/v1/ai-providers/ai.openai/test") && init?.method === "POST") {
return jsonResponse({ providerId: provider.id, mode: "metadata", success: true, message: "metadata validation passed" });
}
if (url.endsWith("/api/v1/ai-providers/ai.openai/models")) {
return jsonResponse({ providerId: provider.id, defaultModel: provider.defaultModel, models: provider.models });
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.listAiProviders()).resolves.toMatchObject({ count: 1 });
await expect(client.createAiProvider(provider)).resolves.toMatchObject({ id: provider.id });
await expect(client.updateAiProvider(provider.id, { ...provider, name: "OpenAI Relay" })).resolves.toMatchObject({ name: "OpenAI Relay" });
await expect(client.setAiProviderStatus(provider.id, { status: "disabled" })).resolves.toMatchObject({ status: "disabled" });
await expect(client.testAiProvider(provider.id)).resolves.toMatchObject({ success: true, mode: "metadata" });
await expect(client.listAiProviderModels(provider.id)).resolves.toMatchObject({ models: ["gpt-4.1"] });
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it("calls console shell resource endpoints with named contracts", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/healthz")) {
return jsonResponse({ service: "platform", status: "ok", version: "0.1.0", time: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/game-plugins")) {
return jsonResponse({ items: [plugin], count: 1 });
}
if (url.endsWith("/api/v1/server-instances") && (!init?.method || init.method === "GET")) {
return jsonResponse({ items: [server], count: 1 });
}
if (url.endsWith("/api/v1/metrics/platform")) {
return jsonResponse({ cpuPercent: 28, memoryPercent: 42, diskPercent: 19, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" });
}
if (url.endsWith("/api/v1/metrics/server-instances")) {
return jsonResponse({
items: [
{
serverInstanceId: server.id,
online: true,
playerCount: 5,
maxPlayers: 20,
tps: 19.8,
latencyMs: 42,
cpuPercent: 31,
memoryPercent: 44,
diskPercent: 22,
source: "platform-derived",
collectedAt: "2026-07-03T00:00:00Z"
}
],
count: 1
});
}
if (url.endsWith("/api/v1/server-instances/server-1/config")) {
return jsonResponse({
serverInstanceId: server.id,
configVersion: 1,
format: "properties",
key: "server.properties",
content: "server.name=Example Survival #1\n",
source: "platform-derived",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
expectedConfigVersion: 1,
key: "server.properties",
proposedContent: "server.name=Example Survival #2\n"
});
return jsonResponse({
serverInstanceId: server.id,
configVersion: 1,
key: "server.properties",
currentContent: "server.name=Example Survival #1\n",
proposedContent: "server.name=Example Survival #2\n",
diff: [
{ kind: "removed", oldNumber: 1, content: "server.name=Example Survival #1" },
{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }
],
hasChanges: true,
source: "platform-review",
reviewedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/config/approve") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
expectedConfigVersion: 1,
key: "server.properties",
proposedContent: "server.name=Example Survival #2\n",
idempotencyKey: "idem-config"
});
return jsonResponse({
status: "queued",
preview: {
serverInstanceId: server.id,
configVersion: 1,
key: "server.properties",
currentContent: "server.name=Example Survival #1\n",
proposedContent: "server.name=Example Survival #2\n",
diff: [{ kind: "added", newNumber: 1, content: "server.name=Example Survival #2" }],
hasChanges: true,
source: "platform-review",
reviewedAt: "2026-07-03T00:00:00Z"
},
job: { ...job, id: "job-config-write", capability: "config.write", targetKey: "server.properties", inputRef: "input://server-config/server-1/server.properties/v1" }
});
}
if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
serverInstanceId: server.id,
operation: "read",
key: "logs/latest.log",
idempotencyKey: "idem-file"
});
return jsonResponse({
status: "queued",
serverInstanceId: server.id,
operation: "read",
key: "logs/latest.log",
job: { ...job, id: "job-file-read", capability: "files.read", targetKey: "logs/latest.log" }
});
}
if (url.endsWith("/api/v1/run/endpoints")) {
return jsonResponse({ items: [endpoint], count: 1 });
}
if (url.endsWith("/api/v1/jobs")) {
return jsonResponse({ items: [job], count: 1 });
}
if (url.endsWith("/api/v1/jobs?serverInstanceId=server-1")) {
return jsonResponse({ items: [job], count: 1 });
}
if (url.endsWith("/api/v1/artifacts?ownerKind=job&ownerId=job-1&state=available")) {
return jsonResponse({ items: [artifact], count: 1 });
}
if (url.endsWith("/api/v1/artifacts/artifact-1/download") && init?.method === "POST") {
return jsonResponse({
artifactId: artifact.id,
ownerKind: artifact.ownerKind,
ownerId: artifact.ownerId,
filename: "artifact-1.bin",
contentType: "application/octet-stream",
sizeBytes: artifact.sizeBytes,
checksum: artifact.checksum,
state: artifact.state,
downloadUrl: "/api/v1/artifacts/artifact-1/content",
expiresAt: "2026-07-03T00:15:00Z",
rangeSupported: true,
chunkSizeBytes: 1048576,
storageBehavior: "platform-memory-transfer-session"
});
}
if (url.endsWith("/api/v1/artifacts/artifact-1/content?offset=0&limit=8")) {
return new Response(new TextEncoder().encode("artifact").buffer, {
status: 206,
headers: {
"Content-Type": "application/octet-stream",
"Content-Length": "8",
"Content-Range": "bytes 0-7/18",
"X-Artifact-Id": artifact.id,
"X-Artifact-Checksum": artifact.checksum,
"X-Artifact-Content-Checksum": "sha256:chunkchecksum",
"X-Artifact-Storage": "platform-memory-transfer-session"
}
});
}
if (url.endsWith("/api/v1/server-instances/workflows/create") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "create", instance: { ...server, state: "installing" }, job: { ...job, capability: "process.install" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/start") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "start", instance: server, job });
}
if (url.endsWith("/api/v1/server-instances/server-1/stop") && init?.method === "POST") {
return jsonResponse({ accepted: true, action: "stop", instance: server, job: { ...job, capability: "process.stop" } });
}
if (url.endsWith("/api/v1/server-instances/server-1/administrators/candidates") && (!init?.method || init.method === "GET")) {
return jsonResponse({ items: [{ id: "user-2", displayName: "Helper", status: "active", roles: ["server-admin"] }], count: 1 });
}
if (url.endsWith("/api/v1/server-instances/server-1/administrators") && init?.method === "POST") {
return jsonResponse({ ...server, adminUserIds: [...server.adminUserIds, "user-2"] });
}
if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") {
return jsonResponse({ ...server, adminUserIds: [] });
}
if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") {
return jsonResponse({
pluginId: plugin.id,
routeKey: "logs",
action: "logs.query",
allowed: true,
requiredPermissions: ["server.logs.read"],
effectivePermissions: ["server.logs.read"]
});
}
if (url.endsWith("/api/v1/plugin-bridge/execute") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
requestId: "req-bridge",
pluginId: plugin.id,
routeKey: "logs",
serverInstanceId: server.id,
action: "logs.query",
payload: { logStreamId: "log-1" }
});
return jsonResponse({
requestId: "req-bridge",
pluginId: plugin.id,
routeKey: "logs",
serverInstanceId: server.id,
action: "logs.query",
status: "ok",
result: { entryCount: "0" }
});
}
if (url.endsWith("/api/v1/ai/invocations") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
requestId: "ai-1",
serverInstanceId: server.id,
purpose: "config.suggest",
prompt: "Tune PVP safely",
currentConfig: "server.name=Example Survival #1\n"
});
return jsonResponse({
requestId: "ai-1",
purpose: "config.suggest",
providerId: "ai.openai",
model: "gpt-4.1",
status: "ok",
recommendation: "Review before applying.",
configRecommendation: { key: "server.properties", suggestedConfig: "server.name=Example Survival #1\npvp=false\n", diffSummary: "review required" },
usage: { providerId: "ai.openai", model: "gpt-4.1", inputTokens: 20, outputTokens: 12, mocked: true }
});
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.health()).resolves.toMatchObject({ status: "ok" });
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
await expect(
client.previewServerConfigDiff(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n" })
).resolves.toMatchObject({ hasChanges: true, source: "platform-review" });
await expect(
client.approveServerConfigWrite(server.id, { expectedConfigVersion: 1, key: "server.properties", proposedContent: "server.name=Example Survival #2\n", idempotencyKey: "idem-config" })
).resolves.toMatchObject({ status: "queued", job: { capability: "config.write", targetKey: "server.properties" } });
await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
status: "queued",
job: { capability: "files.read", targetKey: "logs/latest.log" }
});
await expect(client.listRunEndpoints()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs()).resolves.toMatchObject({ count: 1 });
await expect(client.listJobs(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.listArtifacts({ ownerKind: "job", ownerId: job.id, state: "available" })).resolves.toMatchObject({ count: 1, items: [{ id: artifact.id }] });
await expect(client.openArtifactDownload(artifact.id)).resolves.toMatchObject({ downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
await expect(client.readArtifactContent(artifact.id, 0, 8)).resolves.toMatchObject({ contentLength: 8, contentRange: "bytes 0-7/18", checksum: artifact.checksum });
await expect(client.createServerWorkflow({ id: "server-2", pluginId: plugin.id, runEndpointId: endpoint.id, name: "Server 2", idempotencyKey: "idem-create" })).resolves.toMatchObject({
action: "create"
});
await expect(client.startServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-start" })).resolves.toMatchObject({ action: "start" });
await expect(client.stopServerInstance(server.id, { expectedConfigVersion: 1, idempotencyKey: "idem-stop" })).resolves.toMatchObject({ action: "stop" });
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] });
await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({
allowed: true
});
await expect(
client.executePluginBridge({ requestId: "req-bridge", pluginId: plugin.id, routeKey: "logs", serverInstanceId: server.id, action: "logs.query", payload: { logStreamId: "log-1" } })
).resolves.toMatchObject({ status: "ok", result: { entryCount: "0" } });
await expect(
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(24);
});
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/plugin-marketplace/plugins?status=installed&serverType=example&capability=logs.read&keyword=example")) {
return jsonResponse({ items: [marketplacePlugin], count: 1 });
}
if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example") && (!init?.method || init.method === "GET")) {
return jsonResponse(marketplacePlugin);
}
if (url.endsWith("/api/v1/plugin-marketplace/plugins/game.example/state") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ action: "disable" });
return jsonResponse({ ...marketplacePlugin, status: "disabled" });
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.listMarketplacePlugins({ status: "installed", serverType: "example", capability: "logs.read", keyword: "example" })).resolves.toMatchObject({ count: 1 });
await expect(client.getMarketplacePlugin(marketplacePlugin.id)).resolves.toMatchObject({ source: "platform-registry" });
await expect(client.setMarketplacePluginState(marketplacePlugin.id, { action: "disable" })).resolves.toMatchObject({ status: "disabled" });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("surfaces config diff preview failures from the platform", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/server-instances/server-1/config/diff") && init?.method === "POST") {
return new Response(JSON.stringify({ code: "validation", message: "expectedConfigVersion must match server instance" }), {
status: 400,
headers: { "Content-Type": "application/json" }
});
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
await expect(client.previewServerConfigDiff(server.id, { expectedConfigVersion: 0, key: "server.properties", proposedContent: "changed=true\n" })).rejects.toThrow(
"expectedConfigVersion must match server instance"
);
});
it("keeps raw key fields out of provider responses", () => {
expect("apiKey" in provider).toBe(false);
expect("rawApiKey" in provider).toBe(false);
expect(provider.apiKeyRef).toBe("secret://providers/openai");
});
it("calls auth endpoints and attaches bearer sessions", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/api/v1/auth/login") && init?.method === "POST") {
return jsonResponse({
user: { id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] },
sessionId: "session-token",
status: "authenticated",
message: "登录成功"
});
}
if (url.endsWith("/api/v1/users/current")) {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token");
return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] });
}
if (url.endsWith("/api/v1/auth/logout") && init?.method === "POST") {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token");
return new Response(null, { status: 204 });
}
throw new Error(`unexpected request: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const client = new PlatformApiClient();
const login = await client.login({ account: "operator.local@example.test", password: "operator-local" });
expect(login.sessionId).toBe("session-token");
setPlatformApiSessionToken(login.sessionId ?? null);
await expect(client.getCurrentUser()).resolves.toMatchObject({ id: "user-admin" });
await expect(client.logout()).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
+414
View File
@@ -0,0 +1,414 @@
import type {
AiProviderListResponse,
AiProviderModelsResponse,
AiProviderRequest,
AiProviderResponse,
AiProviderStatusRequest,
AiProviderTestResponse,
AiProviderUpdateRequest,
AIInvocationRequest,
AIInvocationResponse,
ApiErrorResponse,
ArtifactContentChunk,
ArtifactDownloadReferenceResponse,
ArtifactFilterRequest,
ArtifactListResponse,
AuthSessionResponse,
AuditEventListResponse,
CurrentUserResponse,
FileOperationDispatchRequest,
FileOperationDispatchResponse,
GamePluginListResponse,
HealthResponse,
JobCreateRequest,
JobListResponse,
JobResponse,
LlmConfigSuggestionRequest,
LlmConfigSuggestionResponse,
LogStreamCursorRequest,
LogStreamCursorResponse,
LogStreamListResponse,
LoginRequest,
MarketplacePluginFilterRequest,
MarketplacePluginListResponse,
MarketplacePluginResponse,
MarketplacePluginStateRequest,
PlatformResourceUsageResponse,
PluginBridgeAuthorizeRequest,
PluginBridgeAuthorizeResponse,
PluginBridgeExecuteRequest,
PluginBridgeExecuteResponse,
RegisterRequest,
RunEndpointListResponse,
ServerConfigResponse,
ServerConfigDiffPreviewRequest,
ServerConfigDiffPreviewResponse,
ServerLifecycleCommandRequest,
ServerLifecycleCreateRequest,
ServerLifecycleResponse,
ServerConfigWriteApprovalRequest,
ServerConfigWriteDispatchResponse,
ServerInstanceListResponse,
ServerInstanceResponse,
ServerMemberListResponse,
ServerMemberRequest,
ServerMetricsListResponse,
UserCreateRequest,
UserListResponse,
UserProfileUpdateRequest,
UserResponse,
UserThemePreferenceRequest,
UserThemePreferenceResponse,
UserUpdateRequest
} from "./types";
import { readWebRuntimeEnv } from "../schemas/env";
let platformApiSessionToken: string | null = null;
export function setPlatformApiSessionToken(token: string | null) {
platformApiSessionToken = token;
}
export class PlatformApiClient {
constructor(private readonly baseUrl = "/api/v1", private readonly sessionTokenProvider: () => string | null = () => platformApiSessionToken) {}
async health(): Promise<HealthResponse> {
return this.request<HealthResponse>("/healthz", { absolute: true });
}
async listGamePlugins(): Promise<GamePluginListResponse> {
return this.request<GamePluginListResponse>("/game-plugins");
}
async listMarketplacePlugins(filter: MarketplacePluginFilterRequest = {}): Promise<MarketplacePluginListResponse> {
return this.request<MarketplacePluginListResponse>(`/plugin-marketplace/plugins${marketplaceQuery(filter)}`);
}
async getMarketplacePlugin(id: string): Promise<MarketplacePluginResponse> {
return this.request<MarketplacePluginResponse>(`/plugin-marketplace/plugins/${encodeURIComponent(id)}`);
}
async setMarketplacePluginState(id: string, request: MarketplacePluginStateRequest): Promise<MarketplacePluginResponse> {
return this.request<MarketplacePluginResponse>(`/plugin-marketplace/plugins/${encodeURIComponent(id)}/state`, {
method: "POST",
body: request
});
}
async listServerInstances(): Promise<ServerInstanceListResponse> {
return this.request<ServerInstanceListResponse>("/server-instances");
}
async createServerWorkflow(request: ServerLifecycleCreateRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>("/server-instances/workflows/create", {
method: "POST",
body: request
});
}
async startServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/start`, {
method: "POST",
body: request
});
}
async stopServerInstance(id: string, request: ServerLifecycleCommandRequest): Promise<ServerLifecycleResponse> {
return this.request<ServerLifecycleResponse>(`/server-instances/${encodeURIComponent(id)}/stop`, {
method: "POST",
body: request
});
}
async listServerAdministratorCandidates(id: string): Promise<ServerMemberListResponse> {
return this.request<ServerMemberListResponse>(`/server-instances/${encodeURIComponent(id)}/administrators/candidates`);
}
async addServerAdministrator(id: string, request: ServerMemberRequest): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}/administrators`, {
method: "POST",
body: request
});
}
async removeServerAdministrator(id: string, userId: string): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}/administrators/${encodeURIComponent(userId)}`, {
method: "DELETE"
});
}
async listRunEndpoints(): Promise<RunEndpointListResponse> {
return this.request<RunEndpointListResponse>("/run/endpoints");
}
async listJobs(serverInstanceId?: string): Promise<JobListResponse> {
const params = serverInstanceId ? `?serverInstanceId=${encodeURIComponent(serverInstanceId)}` : "";
return this.request<JobListResponse>(`/jobs${params}`);
}
async listArtifacts(filter: ArtifactFilterRequest = {}): Promise<ArtifactListResponse> {
return this.request<ArtifactListResponse>(`/artifacts${artifactQuery(filter)}`);
}
async openArtifactDownload(id: string): Promise<ArtifactDownloadReferenceResponse> {
return this.request<ArtifactDownloadReferenceResponse>(`/artifacts/${encodeURIComponent(id)}/download`, { method: "POST", body: {} });
}
async readArtifactContent(id: string, offset = 0, limit?: number): Promise<ArtifactContentChunk> {
const params = new URLSearchParams({ offset: String(offset) });
if (limit !== undefined) {
params.set("limit", String(limit));
}
const headers = new Headers();
const sessionToken = this.sessionTokenProvider();
if (sessionToken) {
headers.set("Authorization", `Bearer ${sessionToken}`);
}
const response = await fetch(`${this.baseUrl}/artifacts/${encodeURIComponent(id)}/content?${params.toString()}`, { headers });
if (!response.ok) {
const apiError = await safeReadError(response);
throw new Error(apiError?.message ?? `request failed: ${response.status}`);
}
const payload = await response.arrayBuffer();
return {
artifactId: response.headers.get("X-Artifact-Id") ?? undefined,
payload,
contentType: response.headers.get("Content-Type") ?? "application/octet-stream",
contentLength: Number(response.headers.get("Content-Length") ?? payload.byteLength),
contentRange: response.headers.get("Content-Range") ?? undefined,
checksum: response.headers.get("X-Artifact-Checksum") ?? undefined,
contentChecksum: response.headers.get("X-Artifact-Content-Checksum") ?? undefined,
storageBehavior: response.headers.get("X-Artifact-Storage") ?? undefined
};
}
async getJob(id: string): Promise<JobResponse> {
return this.request<JobResponse>(`/jobs/${encodeURIComponent(id)}`);
}
async createJob(request: JobCreateRequest): Promise<JobResponse> {
return this.request<JobResponse>("/jobs", { method: "POST", body: request });
}
async register(request: RegisterRequest): Promise<AuthSessionResponse> {
return this.request<AuthSessionResponse>("/auth/register", { method: "POST", body: request });
}
async login(request: LoginRequest): Promise<AuthSessionResponse> {
return this.request<AuthSessionResponse>("/auth/login", { method: "POST", body: request });
}
async logout(): Promise<void> {
await this.request<void>("/auth/logout", { method: "POST", parseJson: false });
}
async getCurrentUser(): Promise<CurrentUserResponse> {
return this.request<CurrentUserResponse>("/users/current");
}
async listUsers(): Promise<UserListResponse> {
return this.request<UserListResponse>("/users");
}
async createUser(request: UserCreateRequest): Promise<UserResponse> {
return this.request<UserResponse>("/users", { method: "POST", body: request });
}
async updateUser(id: string, request: UserUpdateRequest): Promise<UserResponse> {
return this.request<UserResponse>(`/users/${encodeURIComponent(id)}`, { method: "PUT", body: request });
}
async updateCurrentUserProfile(request: UserProfileUpdateRequest): Promise<CurrentUserResponse> {
return this.request<CurrentUserResponse>("/users/current/profile", { method: "PUT", body: request });
}
async updateCurrentUserTheme(request: UserThemePreferenceRequest): Promise<UserThemePreferenceResponse> {
return this.request<UserThemePreferenceResponse>("/users/current/theme", { method: "PUT", body: request });
}
async getServerInstance(id: string): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`);
}
async getPlatformResourceUsage(): Promise<PlatformResourceUsageResponse> {
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
}
async listServerMetrics(): Promise<ServerMetricsListResponse> {
return this.request<ServerMetricsListResponse>("/metrics/server-instances");
}
async getServerConfig(id: string): Promise<ServerConfigResponse> {
return this.request<ServerConfigResponse>(`/server-instances/${encodeURIComponent(id)}/config`);
}
async previewServerConfigDiff(id: string, request: ServerConfigDiffPreviewRequest): Promise<ServerConfigDiffPreviewResponse> {
return this.request<ServerConfigDiffPreviewResponse>(`/server-instances/${encodeURIComponent(id)}/config/diff`, {
method: "POST",
body: request
});
}
async approveServerConfigWrite(id: string, request: ServerConfigWriteApprovalRequest): Promise<ServerConfigWriteDispatchResponse> {
return this.request<ServerConfigWriteDispatchResponse>(`/server-instances/${encodeURIComponent(id)}/config/approve`, {
method: "POST",
body: request
});
}
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
return this.request<FileOperationDispatchResponse>("/file-operations/dispatch", {
method: "POST",
body: request
});
}
async listLogStreams(): Promise<LogStreamListResponse> {
return this.request<LogStreamListResponse>("/log-streams");
}
async queryLogStream(request: LogStreamCursorRequest): Promise<LogStreamCursorResponse> {
return this.request<LogStreamCursorResponse>("/log-streams/query", { method: "POST", body: request });
}
async listAuditEvents(): Promise<AuditEventListResponse> {
return this.request<AuditEventListResponse>("/audit-events");
}
async suggestServerConfig(request: LlmConfigSuggestionRequest): Promise<LlmConfigSuggestionResponse> {
return this.request<LlmConfigSuggestionResponse>("/ai/config-suggestions", { method: "POST", body: request });
}
async invokeAI(request: AIInvocationRequest): Promise<AIInvocationResponse> {
return this.request<AIInvocationResponse>("/ai/invocations", { method: "POST", body: request });
}
async authorizePluginBridge(request: PluginBridgeAuthorizeRequest): Promise<PluginBridgeAuthorizeResponse> {
return this.request<PluginBridgeAuthorizeResponse>("/plugin-bridge/authorize", {
method: "POST",
body: request
});
}
async executePluginBridge(request: PluginBridgeExecuteRequest): Promise<PluginBridgeExecuteResponse> {
return this.request<PluginBridgeExecuteResponse>("/plugin-bridge/execute", {
method: "POST",
body: request
});
}
async listAiProviders(): Promise<AiProviderListResponse> {
return this.request<AiProviderListResponse>("/ai-providers");
}
async createAiProvider(request: AiProviderRequest): Promise<AiProviderResponse> {
return this.request<AiProviderResponse>("/ai-providers", {
method: "POST",
body: request
});
}
async updateAiProvider(id: string, request: AiProviderUpdateRequest): Promise<AiProviderResponse> {
return this.request<AiProviderResponse>(`/ai-providers/${encodeURIComponent(id)}`, {
method: "PUT",
body: request
});
}
async setAiProviderStatus(id: string, request: AiProviderStatusRequest): Promise<AiProviderResponse> {
return this.request<AiProviderResponse>(`/ai-providers/${encodeURIComponent(id)}/status`, {
method: "POST",
body: request
});
}
async testAiProvider(id: string): Promise<AiProviderTestResponse> {
return this.request<AiProviderTestResponse>(`/ai-providers/${encodeURIComponent(id)}/test`, {
method: "POST"
});
}
async listAiProviderModels(id: string): Promise<AiProviderModelsResponse> {
return this.request<AiProviderModelsResponse>(`/ai-providers/${encodeURIComponent(id)}/models`);
}
private async request<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const headers = new Headers(options.init?.headers);
if (options.body !== undefined) {
headers.set("Content-Type", "application/json");
}
const sessionToken = this.sessionTokenProvider();
if (sessionToken && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${sessionToken}`);
}
const response = await fetch(options.absolute ? path : `${this.baseUrl}${path}`, {
...options.init,
method: options.method ?? options.init?.method ?? "GET",
headers,
body: options.body === undefined ? options.init?.body : JSON.stringify(options.body)
});
if (!response.ok) {
const apiError = await safeReadError(response);
throw new Error(apiError?.message ?? `request failed: ${response.status}`);
}
if (options.parseJson === false || response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
}
interface ApiRequestOptions {
absolute?: boolean;
method?: string;
body?: unknown;
init?: RequestInit;
parseJson?: boolean;
}
async function safeReadError(response: Response): Promise<ApiErrorResponse | null> {
try {
return (await response.json()) as ApiErrorResponse;
} catch {
return null;
}
}
function marketplaceQuery(filter: MarketplacePluginFilterRequest): string {
const params = new URLSearchParams();
if (filter.status && filter.status !== "all") {
params.set("status", filter.status);
}
if (filter.serverType) {
params.set("serverType", filter.serverType);
}
if (filter.capability) {
params.set("capability", filter.capability);
}
if (filter.keyword) {
params.set("keyword", filter.keyword);
}
const query = params.toString();
return query ? `?${query}` : "";
}
function artifactQuery(filter: ArtifactFilterRequest): string {
const params = new URLSearchParams();
if (filter.ownerKind) {
params.set("ownerKind", filter.ownerKind);
}
if (filter.ownerId) {
params.set("ownerId", filter.ownerId);
}
if (filter.state) {
params.set("state", filter.state);
}
const query = params.toString();
return query ? `?${query}` : "";
}
export const platformApiClient = new PlatformApiClient(readWebRuntimeEnv().platformApiBaseUrl);
+50
View File
@@ -0,0 +1,50 @@
# Frontend API Contracts
API clients and DTO types live here, not inside page components.
## Client Groups
- `users`: user and role APIs.
- `serverPlugins`: plugin marketplace and installed plugin APIs.
- `serverInstances`: create server, lifecycle, config read, config diff/approval, scoped files, logs, and detail APIs.
- `aiProviders`: provider CRUD, test, and model APIs.
- `jobs`: job status and operation APIs.
- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs.
- `artifacts`: artifact upload/download APIs.
- `logs`: historical query and tail APIs.
- `pluginPageBridge`: safe bridge APIs for hosted plugin page.
Every API client must use named request and response types.
## Server Management Workflows
- `createServerWorkflow` posts `ServerLifecycleCreateRequest` to `/server-instances/workflows/create` and receives the accepted instance plus install job.
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- `getServerConfig`, `previewServerConfigDiff`, and `approveServerConfigWrite` call platform-mediated config routes. ServerDetailPage must preview the platform diff first, keep the explicit confirmation step, and dispatch writes only through the approval API.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths.
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
- `invokeAI` posts `AIInvocationRequest` to `/ai/invocations` for platform-mediated AI assistance. Responses carry redacted recommendations, usage metadata, optional reviewable config suggestions, and safe errors; they must not include provider base URLs, key refs, raw keys, or direct provider transport details.
- `listRunEndpoints` and `listJobs` provide refresh data for endpoint availability, capacity, and pending lifecycle status.
- Server management DTOs may include bounded `ownerUserId` and `adminUserIds` metadata, but must not include raw run credentials, host paths, direct socket details, user password hashes, or AI provider keys.
## Redesign Contract Gaps (redesign-platform-web-interactions)
Existing platform APIs already cover server lifecycle, jobs, log stream metadata and cursor query, audit events, users, run endpoints, game plugins, plugin bridge authorization, and AI provider health/test. The redesigned UI additionally declares the following frontend contracts; where the platform backend does not yet serve them, the UI must degrade to a clearly labeled local/unavailable state instead of failing silently:
- `POST /api/v1/auth/register` (`RegisterRequest`/`AuthSessionResponse`): visitor registration. Implemented: the first registered user becomes an active platform administrator; later self-registered users become pending server-scoped users and do not receive platform administrator privileges.
- `POST /api/v1/auth/login` (`LoginRequest`/`AuthSessionResponse`) and `POST /api/v1/auth/logout`: implemented bearer session lifecycle for authenticated workspace entry.
- `GET /api/v1/users/current` (`CurrentUserResponse`): implemented current session identity, roles, profile summary, and theme preference reference for role-aware navigation and default landing.
- `PUT /api/v1/users/current/profile` (`UserProfileUpdateRequest`/`CurrentUserResponse`): implemented current-user profile updates such as display name, avatar reference, phone, QQ, and bounded contact fields.
- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference.
- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen.
- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards and the server detail header.
- `GET /api/v1/server-instances/{id}/config` (`ServerConfigResponse`): implemented readable configuration content for diff-based editing.
- `POST /api/v1/server-instances/{id}/config/diff` (`ServerConfigDiffPreviewRequest`/`ServerConfigDiffPreviewResponse`) and `POST /api/v1/server-instances/{id}/config/approve` (`ServerConfigWriteApprovalRequest`/`ServerConfigWriteDispatchResponse`): implemented platform-mediated config write review and approval. Manual config edits and AI suggestion applies must not create generic `config.write` jobs through `POST /api/v1/jobs`.
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
- Per-server plugin controls are rendered from installed plugin manifests (`bridgeActions`, `lifecycleActions`, `pages`, `declaredPermissions`); a richer declared-control schema remains a future plugin contract. Hosted bridge execution uses `POST /api/v1/plugin-bridge/execute` for server context, scoped file, log, job, artifact reference, and AI action envelopes instead of direct plugin fetches to platform internals.
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
- Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters.
+679
View File
@@ -0,0 +1,679 @@
export interface HealthResponse {
service: string;
status: "ok" | "degraded";
version: string;
time: string;
}
export type GamePluginStatus = "installed" | "disabled" | "invalid" | "updating";
export type ServerInstanceState = "draft" | "installing" | "ready" | "running" | "stopped" | "failed" | "deleted";
export type RunEndpointStatus = "online" | "offline" | "degraded" | "disabled";
export type JobState = "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled";
export type ServerLifecycleAction = "create" | "start" | "stop";
export interface PluginPermissionsResponse {
ai: boolean;
logs: boolean;
files: boolean;
jobs: boolean;
artifacts: boolean;
}
export interface GamePluginPageResponse {
key: string;
title: string;
path: string;
permissions: string[];
bridgeActions?: string[];
}
export interface GamePluginResponse {
id: string;
name: string;
description?: string;
version: string;
serverType: string;
serverDisplayName?: string;
supportedOs?: string[];
manifestRef: string;
createFormSchemaRef: string;
requiredRunCapabilities: string[];
declaredPermissions: string[];
permissions: PluginPermissionsResponse;
lifecycleActions: Record<string, string>;
bridgeActions: string[];
pages: GamePluginPageResponse[];
tags: string[];
aiPurposes: string[];
validationViolations?: string[];
status: GamePluginStatus;
}
export interface GamePluginListResponse {
items: GamePluginResponse[];
count: number;
}
export type MarketplacePluginStateAction = "install" | "enable" | "disable";
export interface MarketplacePluginResponse {
id: string;
name: string;
description?: string;
version: string;
serverType: string;
serverDisplayName?: string;
supportedOs?: string[];
manifestRef: string;
createFormSchemaRef: string;
capabilities: string[];
declaredPermissions: string[];
permissions: PluginPermissionsResponse;
lifecycleActions: Record<string, string>;
bridgeActions: string[];
pages: GamePluginPageResponse[];
tags: string[];
aiPurposes: string[];
validationViolations?: string[];
status: GamePluginStatus;
source: string;
}
export interface MarketplacePluginListResponse {
items: MarketplacePluginResponse[];
count: number;
}
export interface MarketplacePluginFilterRequest {
status?: GamePluginStatus | "all";
serverType?: string;
capability?: string;
keyword?: string;
}
export interface MarketplacePluginStateRequest {
action: MarketplacePluginStateAction;
}
export interface ServerInstanceResponse {
id: string;
pluginId: string;
pluginVersion: string;
runEndpointId: string;
name: string;
ownerUserId?: string;
adminUserIds: string[];
state: ServerInstanceState;
configVersion: number;
createdAt: string;
updatedAt: string;
}
export interface ServerInstanceListResponse {
items: ServerInstanceResponse[];
count: number;
}
export interface ServerLifecycleCreateRequest {
id: string;
pluginId: string;
runEndpointId: string;
name: string;
idempotencyKey: string;
}
export interface ServerLifecycleCommandRequest {
expectedConfigVersion: number;
idempotencyKey: string;
}
export interface ServerLifecycleResponse {
accepted: boolean;
action: ServerLifecycleAction;
instance: ServerInstanceResponse;
job: JobResponse;
}
export interface RunCapacityResponse {
maxJobs: number;
runningJobs: number;
queuedJobs: number;
summary?: string;
}
export interface RunEndpointResponse {
id: string;
displayName: string;
version: string;
status: RunEndpointStatus;
capabilities: string[];
capacity: RunCapacityResponse;
lastHeartbeatAt: string;
}
export interface RunEndpointListResponse {
items: RunEndpointResponse[];
count: number;
}
export interface JobProgressBody {
percent: number;
message?: string;
}
export interface JobResponse {
id: string;
serverInstanceId?: string;
runEndpointId: string;
capability: string;
targetKey?: string;
inputRef?: string;
idempotencyKey: string;
state: JobState;
progress: JobProgressBody;
resultRef?: string;
createdAt: string;
updatedAt: string;
}
export interface JobListResponse {
items: JobResponse[];
count: number;
}
export type ArtifactOwnerKind = "platform" | "plugin" | "server-instance" | "job";
export type ArtifactState = "uploading" | "available" | "expired" | "failed";
export interface ArtifactResponse {
id: string;
ownerKind: ArtifactOwnerKind;
ownerId: string;
sizeBytes: number;
checksum: string;
state: ArtifactState;
createdAt: string;
updatedAt: string;
}
export interface ArtifactListResponse {
items: ArtifactResponse[];
count: number;
}
export interface ArtifactFilterRequest {
ownerKind?: ArtifactOwnerKind;
ownerId?: string;
state?: ArtifactState;
}
export interface ArtifactDownloadReferenceResponse {
artifactId: string;
ownerKind: ArtifactOwnerKind;
ownerId: string;
filename: string;
contentType: string;
sizeBytes: number;
checksum: string;
state: ArtifactState;
downloadUrl: string;
expiresAt: string;
rangeSupported: boolean;
chunkSizeBytes: number;
storageBehavior: string;
}
export interface ArtifactContentChunk {
artifactId?: string;
payload: ArrayBuffer;
contentType: string;
contentLength: number;
contentRange?: string;
checksum?: string;
contentChecksum?: string;
storageBehavior?: string;
}
export interface PluginBridgeAuthorizeRequest {
pluginId: string;
routeKey: string;
serverInstanceId?: string;
action: string;
aiPurpose?: string;
}
export interface PluginBridgeAuthorizeResponse {
pluginId: string;
routeKey: string;
serverInstanceId?: string;
action: string;
allowed: boolean;
requiredPermissions: string[];
effectivePermissions: string[];
reason?: string;
}
export interface PluginBridgeExecuteRequest {
requestId: string;
pluginId: string;
routeKey: string;
serverInstanceId?: string;
action: string;
aiPurpose?: string;
payload?: Record<string, string>;
}
export interface PluginBridgeSafeErrorResponse {
code: string;
message: string;
details?: string[];
}
export interface PluginBridgeExecuteResponse {
requestId: string;
pluginId: string;
routeKey: string;
serverInstanceId?: string;
action: string;
status: "ok" | "queued" | "denied" | "unsupported" | "cancelled" | "error" | string;
result?: Record<string, string>;
error?: PluginBridgeSafeErrorResponse;
}
export type AiProviderKind = "openai-compatible" | "openai" | "claude" | "gemini" | "ollama" | "custom";
export type AiRelayMode = "direct" | "relay" | "local";
export type AiProviderStatus = "active" | "disabled" | "error";
export interface AiProviderRequest {
id: string;
name: string;
kind: AiProviderKind;
baseUrl: string;
apiKeyRef: string;
models: string[];
defaultModel?: string;
relayMode: AiRelayMode;
timeoutMs: number;
redactionPolicy: string;
}
export type AiProviderUpdateRequest = Omit<AiProviderRequest, "id">;
export interface AiProviderStatusRequest {
status: Extract<AiProviderStatus, "active" | "disabled">;
}
export interface AiProviderResponse {
id: string;
name: string;
kind: AiProviderKind;
baseUrl: string;
apiKeyRef: string;
models: string[];
defaultModel?: string;
relayMode: AiRelayMode;
timeoutMs: number;
status: AiProviderStatus;
redactionPolicy: string;
}
export interface AiProviderListResponse {
items: AiProviderResponse[];
count: number;
}
export interface AiProviderTestResponse {
providerId: string;
mode: "metadata";
success: boolean;
message: string;
violations?: string[];
}
export interface AiProviderModelsResponse {
providerId: string;
defaultModel?: string;
models: string[];
}
export interface ApiErrorResponse {
code: string;
message: string;
details?: string[];
}
export type UserStatus = "active" | "disabled" | "pending";
export type UserThemePersistence = "api" | "local";
export interface UserContactProfile {
avatarUrl?: string;
phone?: string;
qq?: string;
contactNote?: string;
}
export interface UserThemePreferenceRequest {
paletteId: string;
backgroundPresetId: string;
backgroundImage?: string | null;
}
export interface UserThemePreferenceResponse extends UserThemePreferenceRequest {
userId: string;
persistence: UserThemePersistence;
updatedAt: string;
}
export interface UserResponse {
id: string;
displayName: string;
email?: string;
status: UserStatus;
roles: string[];
profile?: UserContactProfile;
themePreference?: UserThemePreferenceResponse;
createdAt: string;
updatedAt: string;
}
export interface ServerMemberResponse {
id: string;
displayName: string;
email?: string;
status: UserStatus;
roles: string[];
profile?: UserContactProfile;
}
export interface ServerMemberListResponse {
items: ServerMemberResponse[];
count: number;
}
export interface ServerMemberRequest {
userId: string;
}
export interface UserListResponse {
items: UserResponse[];
count: number;
}
export interface CurrentUserResponse {
id: string;
displayName: string;
email?: string;
status?: UserStatus;
roles: string[];
capabilities?: string[];
profile?: UserContactProfile;
themePreference?: UserThemePreferenceResponse;
}
export interface LoginRequest {
account: string;
password: string;
}
export interface RegisterRequest {
displayName: string;
email: string;
password: string;
phone?: string;
qq?: string;
}
export interface AuthSessionResponse {
user: CurrentUserResponse;
sessionId?: string;
status: "authenticated" | "pending";
message?: string;
}
export interface UserProfileUpdateRequest {
displayName: string;
avatarUrl?: string;
phone?: string;
qq?: string;
contactNote?: string;
}
export interface UserCreateRequest {
displayName: string;
email?: string;
roles: string[];
status: UserStatus;
profile?: UserContactProfile;
}
export interface UserUpdateRequest {
displayName?: string;
email?: string;
roles?: string[];
status?: UserStatus;
profile?: UserContactProfile;
}
export interface PlatformResourceUsageResponse {
cpuPercent: number;
memoryPercent: number;
diskPercent: number;
source?: string;
collectedAt: string;
}
export interface ServerMetricsResponse {
serverInstanceId: string;
online: boolean;
playerCount?: number;
maxPlayers?: number;
tps?: number;
latencyMs?: number;
cpuPercent?: number;
memoryPercent?: number;
diskPercent?: number;
source?: string;
collectedAt: string;
}
export interface ServerMetricsListResponse {
items: ServerMetricsResponse[];
count: number;
}
export interface ServerConfigResponse {
serverInstanceId: string;
configVersion: number;
format: string;
key?: string;
content: string;
source?: string;
updatedAt: string;
}
export type ConfigDiffLineKind = "context" | "added" | "removed";
export interface ConfigDiffLineResponse {
kind: ConfigDiffLineKind;
oldNumber?: number;
newNumber?: number;
content: string;
}
export interface ServerConfigDiffPreviewRequest {
expectedConfigVersion: number;
key: string;
proposedContent?: string;
proposedContentInputRef?: string;
}
export interface ServerConfigDiffPreviewResponse {
serverInstanceId: string;
configVersion: number;
key: string;
currentContent: string;
proposedContent?: string;
proposedContentInputRef?: string;
diff: ConfigDiffLineResponse[];
hasChanges: boolean;
source: string;
reviewedAt: string;
}
export interface ServerConfigWriteApprovalRequest {
expectedConfigVersion: number;
key: string;
proposedContent?: string;
proposedContentInputRef?: string;
idempotencyKey: string;
}
export interface ServerConfigWriteDispatchResponse {
status: string;
preview: ServerConfigDiffPreviewResponse;
job: JobResponse;
}
export type FileOperationKind = "read" | "write";
export interface FileOperationDispatchRequest {
serverInstanceId: string;
pluginId?: string;
operation: FileOperationKind;
key: string;
inputRef?: string;
expectedConfigVersion?: number;
idempotencyKey: string;
}
export interface FileOperationDispatchResponse {
status: string;
serverInstanceId: string;
pluginId?: string;
operation: FileOperationKind;
key: string;
inputRef?: string;
job: JobResponse;
}
export interface LogStreamResponse {
id: string;
serverInstanceId: string;
source: string;
streamKey: string;
latestSeq: number;
storageBackend: string;
retentionPolicy: string;
createdAt: string;
updatedAt: string;
}
export interface LogStreamListResponse {
items: LogStreamResponse[];
count: number;
}
export interface LogEntryBody {
seq: number;
timestamp: string;
level?: string;
line: string;
fields?: Record<string, string>;
redacted: boolean;
}
export interface LogStreamCursorRequest {
logStreamId: string;
afterSeq: number;
limit: number;
}
export interface LogStreamCursorResponse {
logStreamId: string;
entries: LogEntryBody[];
nextSeq: number;
latestSeq: number;
}
export interface AuditEventResponse {
id: string;
actorId: string;
action: string;
resourceKind: string;
resourceId: string;
result: string;
summary: string;
createdAt: string;
}
export interface AuditEventListResponse {
items: AuditEventResponse[];
count: number;
}
export interface JobCreateRequest {
id: string;
serverInstanceId?: string;
runEndpointId: string;
capability: string;
targetKey?: string;
inputRef?: string;
idempotencyKey: string;
progress?: JobProgressBody;
}
export interface LlmConfigSuggestionRequest {
serverInstanceId: string;
prompt: string;
currentConfig: string;
}
export interface LlmConfigSuggestionResponse {
serverInstanceId: string;
recommendation: string;
suggestedConfig?: string;
}
export interface AIInvocationRequest {
requestId: string;
pluginId?: string;
routeKey?: string;
serverInstanceId?: string;
purpose: string;
providerId?: string;
model?: string;
prompt: string;
currentConfig?: string;
contextRefs?: Record<string, string>;
}
export interface AIInvocationUsageResponse {
providerId: string;
model: string;
inputTokens: number;
outputTokens: number;
mocked: boolean;
}
export interface AIConfigRecommendationResponse {
key: string;
suggestedConfig?: string;
diffSummary: string;
}
export interface AIInvocationSafeErrorResponse {
code: string;
message: string;
details?: string[];
}
export interface AIInvocationResponse {
requestId: string;
purpose: string;
providerId?: string;
model?: string;
status: "ok" | "denied" | "error" | string;
recommendation?: string;
configRecommendation?: AIConfigRecommendationResponse;
usage: AIInvocationUsageResponse;
error?: AIInvocationSafeErrorResponse;
}
+14
View File
@@ -0,0 +1,14 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { App } from "./App";
describe("App", () => {
it("renders a visible authentication loading state before the session resolves", () => {
const html = renderToStaticMarkup(<App />);
expect(html).toContain("正在加载身份状态");
expect(html).toContain("auth-shell");
expect(html).not.toContain("blank");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { AuthView } from "../components/AuthView";
import { AppShell } from "../components/AppShell";
import { EmptyState, LoadingState } from "../components/StateViews";
import type { PageId, PageParams } from "../contracts/page";
import { pageRegistry } from "../pages/pageRegistry";
import { canAccessRoute, defaultPageForUser, hashForPage, navigationRoutesForUser } from "../routes/routes";
import { initialNavigationState, type NavigationState } from "../stores/navigation";
import { useOperationTracker } from "../stores/operations";
import { useSession } from "../stores/session";
export function App() {
const session = useSession();
const user = session.user;
const [navigation, setNavigation] = useState<NavigationState | undefined>();
const operations = useOperationTracker();
useEffect(() => {
if (session.loaded && user) {
const nextNavigation = initialNavigationState(user);
setNavigation(nextNavigation);
if (typeof window !== "undefined") {
const nextHash = hashForPage(nextNavigation.pageId, nextNavigation.params);
if (window.location.hash !== nextHash) {
window.history.replaceState(null, "", nextHash);
}
}
}
}, [session.loaded, user]);
useEffect(() => {
function syncPageFromHash() {
if (user) {
setNavigation(initialNavigationState(user));
}
}
window.addEventListener("hashchange", syncPageFromHash);
window.addEventListener("popstate", syncPageFromHash);
return () => {
window.removeEventListener("hashchange", syncPageFromHash);
window.removeEventListener("popstate", syncPageFromHash);
};
}, [user]);
function handleNavigate(pageId: PageId, params: PageParams = {}) {
if (!user) {
return;
}
const target: NavigationState = canAccessRoute(user, pageId) ? { pageId, params } : { pageId: defaultPageForUser(user), params: {} };
setNavigation(target);
if (typeof window !== "undefined") {
window.history.replaceState(null, "", hashForPage(target.pageId, target.params));
}
}
if (!session.loaded) {
return (
<main className="auth-shell">
<LoadingState label="正在加载身份状态…" />
</main>
);
}
if (!user || !navigation) {
return <AuthView session={session} />;
}
const navRoutes = navigationRoutesForUser(user);
const ActivePage = pageRegistry[navigation.pageId];
const allowed = canAccessRoute(user, navigation.pageId);
return (
<AppShell
routes={navRoutes}
currentPage={navigation.pageId}
session={user}
onNavigate={handleNavigate}
>
{allowed ? (
<ActivePage
session={user}
params={navigation.params}
operations={operations}
onNavigate={handleNavigate}
onLogout={session.logout}
onProfileSave={session.updateProfile}
onThemePreferenceSave={session.updateThemePreference}
/>
) : (
<EmptyState
title="没有访问权限"
description="当前账号无权访问该区域。请返回你的默认工作台,或联系平台管理员调整权限。"
actionLabel="返回工作台"
onAction={() => handleNavigate(defaultPageForUser(user))}
/>
)}
</AppShell>
);
}
+11
View File
@@ -0,0 +1,11 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "../theme/base.css";
createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+164
View File
@@ -0,0 +1,164 @@
import {
Bot,
Heart,
LayoutDashboard,
PackageSearch,
PanelLeft,
PanelRight,
ServerCog,
ShieldCheck,
UserRoundPen,
WandSparkles,
Wrench
} from "lucide-react";
import { type ComponentType, type ReactNode, useEffect, useState } from "react";
import type { PageId, PageParams, PageRoute } from "../contracts/page";
import type { CurrentUserView } from "../contracts/workspace";
import { MagicalParticleLayer } from "./MagicalParticleLayer";
import {
applyBackgroundImage,
applyThemeBackgroundPreset,
applyThemePalette,
loadThemeState,
themePaletteChangeEvent,
themePalettes,
themeTokens,
type ThemePaletteChangeDetail,
type WorkspaceThemeState
} from "../theme/tokens";
import { cx } from "../utils/classes";
interface AppShellProps {
routes: PageRoute[];
currentPage: PageId;
session: CurrentUserView;
onNavigate: (pageId: PageId, params?: PageParams) => void;
children: ReactNode;
}
interface MenuGroup {
id: string;
label: string;
routeIds: PageId[];
icon: ComponentType<{ size?: number; className?: string }>;
}
const menuGroups: MenuGroup[] = [
{ id: "overview", label: "平台概览", routeIds: ["home"], icon: LayoutDashboard },
{ id: "servers", label: "服务器管理", routeIds: ["servers"], icon: ServerCog },
{ id: "plugins", label: "插件市场", routeIds: ["plugins"], icon: PackageSearch },
{ id: "users", label: "用户管理", routeIds: ["users"], icon: ShieldCheck },
{ id: "ai", label: "AI 提供商管理", routeIds: ["aiProviders"], icon: Bot },
{ id: "tools", label: "系统工具", routeIds: ["maintenance"], icon: Wrench }
];
const roleLabels: Record<CurrentUserView["roles"][number], string> = {
platformAdmin: "平台管理员",
serverOwner: "服主",
serverAdmin: "服务器管理员"
};
export function AppShell({ routes, currentPage, session, onNavigate, children }: AppShellProps) {
const [themeState, setThemeState] = useState<WorkspaceThemeState>(() => loadThemeState());
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
useEffect(() => {
function handleThemePaletteChange(event: Event) {
const paletteId = (event as CustomEvent<ThemePaletteChangeDetail>).detail?.paletteId;
if (!paletteId) {
return;
}
setThemeState((current) => (current.paletteId === paletteId ? current : { ...current, paletteId }));
}
window.addEventListener(themePaletteChangeEvent, handleThemePaletteChange);
const stored = loadThemeState();
applyThemePalette(stored.paletteId);
applyThemeBackgroundPreset(stored.backgroundPresetId);
applyBackgroundImage(stored.backgroundImage);
setThemeState(stored);
return () => window.removeEventListener(themePaletteChangeEvent, handleThemePaletteChange);
}, []);
const activePalette = themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0];
const routesById = new Map(routes.map((route) => [route.id, route]));
const visibleGroups = menuGroups
.map((group) => ({
...group,
routes: group.routeIds.map((routeId) => routesById.get(routeId)).filter((route): route is PageRoute => Boolean(route))
}))
.filter((group) => group.routes.length > 0);
function activateGroup(group: (typeof visibleGroups)[number]) {
if (group.routes[0].id !== currentPage) {
onNavigate(group.routes[0].id);
}
}
return (
<div className={cx("app-shell", isSidebarCollapsed && "app-shell-sidebar-collapsed")}>
<MagicalParticleLayer />
<aside className="app-sidebar">
<div className="app-brand" aria-label={themeTokens.appName}>
<span className="app-brand-mark">
<WandSparkles size={17} />
</span>
<span>
<strong>{themeTokens.appName}</strong>
<span className="app-brand-subtitle">{activePalette.label} / OPS</span>
</span>
</div>
<div className="app-menu-toolbar" aria-label="sidebar mode">
<span></span>
<button type="button" className="app-menu-toggle" aria-label={isSidebarCollapsed ? "展开完整菜单栏" : "收起为图标栏"} aria-pressed={isSidebarCollapsed} onClick={() => setIsSidebarCollapsed((current) => !current)}>
{isSidebarCollapsed ? <PanelRight size={18} /> : <PanelLeft size={18} />}
<span>{isSidebarCollapsed ? "展开" : "收起"}</span>
</button>
</div>
<nav className="app-nav" aria-label="primary">
{visibleGroups.map((group) => {
const Icon = group.icon;
const isActive = group.routes.some((route) => route.id === currentPage);
return (
<section key={group.id} className={cx("app-nav-group", isActive && "app-nav-group-active")}>
<button
type="button"
className={cx("app-nav-group-button", isActive && "app-nav-item-active")}
aria-current={isActive ? "page" : undefined}
title={group.label}
onClick={() => activateGroup(group)}
>
<span className="app-nav-icon" aria-hidden="true">
<Icon size={22} />
</span>
<span className="app-nav-copy">
<strong>{group.label}</strong>
</span>
</button>
</section>
);
})}
</nav>
<div className="app-session">
<button type="button" className="app-account-button" aria-current={currentPage === "profileSettings" ? "page" : undefined} onClick={() => onNavigate("profileSettings")}>
<span className="account-avatar" aria-hidden="true">
{session.profile.avatarUrl ? <img src={session.profile.avatarUrl} alt="" /> : <Heart size={17} />}
</span>
<span>
<strong>{session.displayName}</strong>
<span className="provider-id">{session.roles.map((role) => roleLabels[role]).join(" / ")}</span>
</span>
<UserRoundPen className="account-chevron" size={16} />
</button>
<div className="palette-mini-strip" aria-label={`当前配色 ${activePalette.label}`}>
{activePalette.swatches.map((swatch) => (
<span key={swatch} style={{ background: swatch }} />
))}
</div>
</div>
</aside>
<main className="app-main">{children}</main>
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { HeartHandshake, Loader2, MoonStar, Sparkles, WandSparkles } from "lucide-react";
import { type FormEvent, useState } from "react";
import type { SessionState } from "../stores/session";
import { cx } from "../utils/classes";
interface AuthViewProps {
session: SessionState;
}
export function AuthView({ session }: AuthViewProps) {
const isRegister = session.auth.mode === "register";
const [draft, setDraft] = useState({
account: "",
displayName: "",
email: "",
password: "",
phone: "",
qq: ""
});
function updateDraft(key: keyof typeof draft, value: string) {
setDraft((current) => ({ ...current, [key]: value }));
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isRegister) {
await session.register({
displayName: draft.displayName.trim() || draft.account.trim() || "待审核玩家",
email: draft.email.trim(),
password: draft.password,
phone: draft.phone.trim(),
qq: draft.qq.trim()
});
return;
}
await session.login({ account: draft.account.trim(), password: draft.password });
}
return (
<main className="auth-shell">
<section className="auth-card" aria-label={isRegister ? "注册账号" : "登录账号"}>
<div className="auth-brand">
<span className="app-brand-mark">
<WandSparkles size={18} />
</span>
<div>
<p className="page-kicker">Mecha Ops Console</p>
<h1>{isRegister ? "申请进入服务器工作台" : "登录机甲运维工作台"}</h1>
</div>
</div>
<p className="auth-copy">
{isRegister
? "新账号默认进入待审核或服务器范围,不会获得平台管理员权限。"
: "登录后会按你的角色进入默认工作区:平台管理员看概览,服务器用户看服务器列表。"}
</p>
<div className="auth-mode-tabs" role="tablist" aria-label="认证方式">
<button type="button" className={cx(!isRegister && "auth-mode-active")} onClick={() => session.switchAuthMode("login")}>
<MoonStar size={14} />
<span></span>
</button>
<button type="button" className={cx(isRegister && "auth-mode-active")} onClick={() => session.switchAuthMode("register")}>
<HeartHandshake size={14} />
<span></span>
</button>
</div>
<form className="auth-form" onSubmit={submit}>
{isRegister ? (
<>
<label>
<span></span>
<input value={draft.displayName} required onChange={(event) => updateDraft("displayName", event.target.value)} />
</label>
<label>
<span></span>
<input value={draft.email} type="email" required onChange={(event) => updateDraft("email", event.target.value)} />
</label>
<label>
<span></span>
<input value={draft.phone} inputMode="tel" onChange={(event) => updateDraft("phone", event.target.value)} />
</label>
<label>
<span>QQ</span>
<input value={draft.qq} inputMode="numeric" onChange={(event) => updateDraft("qq", event.target.value)} />
</label>
</>
) : (
<label>
<span> / </span>
<input value={draft.account} required onChange={(event) => updateDraft("account", event.target.value)} />
</label>
)}
<label>
<span></span>
<input value={draft.password} type="password" required minLength={6} onChange={(event) => updateDraft("password", event.target.value)} />
</label>
{session.auth.error && (
<div className="auth-result auth-result-error" role="alert">
<strong></strong>
<span>{session.auth.error}</span>
</div>
)}
{session.auth.success && (
<div className="auth-result auth-result-success" role="status">
<strong></strong>
<span>{session.auth.success}</span>
</div>
)}
{session.authUnavailable && session.localFallbackAvailable && (
<div className="auth-result" role="status">
<strong>退</strong>
<span> API UI </span>
</div>
)}
<button type="submit" className="profile-save-button auth-submit" disabled={session.auth.pending}>
{session.auth.pending ? <Loader2 size={14} className="state-spinner" /> : <Sparkles size={14} />}
<span>{isRegister ? "提交注册" : "登录"}</span>
</button>
{session.authUnavailable && session.localFallbackAvailable && (
<button type="button" className="state-action auth-local-action" onClick={session.continueWithLocalFallback}>
<MoonStar size={14} />
<span>退</span>
</button>
)}
</form>
</section>
</main>
);
}
@@ -0,0 +1,41 @@
import type { CSSProperties } from "react";
const particleSlots = [
{ x: "12%", y: "16%" },
{ x: "31%", y: "24%" },
{ x: "72%", y: "12%" },
{ x: "88%", y: "36%" },
{ x: "18%", y: "58%" },
{ x: "46%", y: "66%" },
{ x: "78%", y: "74%" },
{ x: "92%", y: "86%" }
];
type ParticleStyle = CSSProperties & {
"--particle-index": string;
"--particle-x": string;
"--particle-y": string;
};
export function MagicalParticleLayer() {
return (
<>
<div className="workspace-background-layer" aria-hidden="true" />
<div className="global-particle-layer" aria-hidden="true">
<span className="global-particle-orbit" />
<span className="global-particle-orbit global-particle-orbit-secondary" />
<span className="global-particle-sweep" />
<span className="global-particle-ribbon" />
<span className="global-particle-frame global-particle-frame-tl" />
<span className="global-particle-frame global-particle-frame-br" />
{particleSlots.map((slot, index) => (
<span
key={`${slot.x}-${slot.y}`}
className="global-particle-glint"
style={{ "--particle-index": String(index), "--particle-x": slot.x, "--particle-y": slot.y } as ParticleStyle}
/>
))}
</div>
</>
);
}
@@ -0,0 +1,72 @@
import type { ReactNode } from "react";
interface ConfirmDialogProps {
open: boolean;
title: string;
description: ReactNode;
confirmLabel: string;
danger?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: ReactNode;
}
export function ConfirmDialog({ open, title, description, confirmLabel, danger, busy, onConfirm, onCancel, children }: ConfirmDialogProps) {
if (!open) {
return null;
}
return (
<div className="confirm-backdrop" role="presentation" onClick={onCancel}>
<div className="confirm-panel" role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
<h2>{title}</h2>
<p>{description}</p>
{children}
<div className="confirm-actions">
<button type="button" onClick={onCancel} disabled={busy}>
</button>
<button type="button" className={danger ? "confirm-danger" : "confirm-primary"} onClick={onConfirm} disabled={busy}>
{busy ? "提交中…" : confirmLabel}
</button>
</div>
</div>
</div>
);
}
interface UsageMeterProps {
label: string;
percent?: number;
}
export function UsageMeter({ label, percent }: UsageMeterProps) {
const known = typeof percent === "number" && Number.isFinite(percent);
const clamped = known ? Math.max(0, Math.min(100, percent)) : 0;
return (
<div className="usage-meter">
<span>{label}</span>
<span className="usage-meter-track" role="img" aria-label={known ? `${label} ${Math.round(clamped)}%` : `${label} 暂无数据`}>
{known && <span className={`usage-meter-fill${clamped >= 85 ? " usage-high" : ""}`} style={{ width: `${clamped}%` }} />}
</span>
<span className="usage-meter-value">{known ? `${Math.round(clamped)}%` : "--"}</span>
</div>
);
}
interface DiffViewProps {
lines: Array<{ kind: "same" | "added" | "removed"; text: string }>;
}
export function DiffView({ lines }: DiffViewProps) {
return (
<div className="diff-view" role="figure" aria-label="配置变更对比">
{lines.map((line, index) => (
<span key={`${index}-${line.text}`} className={`diff-line diff-line-${line.kind}`}>
<span aria-hidden="true">{line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "}</span>
<span>{line.text || " "}</span>
</span>
))}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { MoonStar } from "lucide-react";
import type { PageMetric } from "../contracts/page";
import { cx } from "../utils/classes";
interface PageFrameProps {
kicker: string;
title: string;
status: string;
metrics: PageMetric[];
}
export function PageFrame({ kicker, title, status, metrics }: PageFrameProps) {
return (
<section className="page-frame" aria-labelledby="page-title">
<header className="page-header">
<div>
<p className="page-kicker">{kicker}</p>
<h1 id="page-title" className="page-title">
<MoonStar size={22} aria-hidden="true" />
{title}
</h1>
</div>
<span className="page-status">{status}</span>
</header>
<div className="metric-grid">
{metrics.map((metric) => (
<article key={metric.label} className={cx("metric-card", `metric-tone-${metric.tone}`)}>
<span className="metric-label">{metric.label}</span>
<strong className="metric-value">{metric.value}</strong>
</article>
))}
</div>
</section>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { AlertTriangle, CheckCircle2, Copy, Loader2, MoonStar, Sparkles, XCircle } from "lucide-react";
import type { ReactNode } from "react";
import { cx } from "../utils/classes";
interface EmptyStateProps {
title: string;
description: string;
actionLabel?: string;
onAction?: () => void;
icon?: ReactNode;
}
export function EmptyState({ title, description, actionLabel, onAction, icon }: EmptyStateProps) {
return (
<div className="state-view state-empty" role="status">
<span className="state-icon" aria-hidden="true">
{icon ?? <MoonStar size={26} />}
</span>
<strong>{title}</strong>
<p>{description}</p>
{actionLabel && onAction && (
<button type="button" className="state-action" onClick={onAction}>
<Sparkles size={14} />
<span>{actionLabel}</span>
</button>
)}
</div>
);
}
interface LoadingStateProps {
label: string;
compact?: boolean;
}
export function LoadingState({ label, compact }: LoadingStateProps) {
return (
<div className={cx("state-view state-loading", compact && "state-compact")} role="status" aria-live="polite">
<Loader2 size={compact ? 16 : 22} className="state-spinner" aria-hidden="true" />
<span>{label}</span>
</div>
);
}
interface ErrorStateProps {
title: string;
reason?: string;
diagnosticId?: string;
onRetry?: () => void;
compact?: boolean;
}
export function ErrorState({ title, reason, diagnosticId, onRetry, compact }: ErrorStateProps) {
return (
<div className={cx("state-view state-error", compact && "state-compact")} role="alert">
<span className="state-icon" aria-hidden="true">
<AlertTriangle size={compact ? 16 : 24} />
</span>
<strong>{title}</strong>
{reason && <p>{reason}</p>}
{diagnosticId && <DiagnosticSummary diagnosticId={diagnosticId} />}
{onRetry && (
<button type="button" className="state-action" onClick={onRetry}>
<Sparkles size={14} />
<span></span>
</button>
)}
</div>
);
}
interface ResultBadgeProps {
status: "pending" | "succeeded" | "failed";
label: string;
}
export function ResultBadge({ status, label }: ResultBadgeProps) {
const icon =
status === "pending" ? <Loader2 size={13} className="state-spinner" /> : status === "succeeded" ? <CheckCircle2 size={13} /> : <XCircle size={13} />;
return (
<span className={cx("result-badge", `result-badge-${status}`)}>
{icon}
<span>{label}</span>
</span>
);
}
interface DiagnosticSummaryProps {
diagnosticId: string;
detail?: string;
}
export function DiagnosticSummary({ diagnosticId, detail }: DiagnosticSummaryProps) {
const summary = detail ? `${diagnosticId} ${detail}` : diagnosticId;
return (
<span className="diagnostic-summary">
<code>{diagnosticId}</code>
<button
type="button"
className="diagnostic-copy"
title="复制诊断信息"
onClick={() => {
void navigator.clipboard?.writeText(summary);
}}
>
<Copy size={12} />
<span></span>
</button>
</span>
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { AiProviderKind, AiProviderResponse, AiProviderStatus, AiRelayMode } from "../api/types";
export type AiProviderFilter = AiProviderStatus | "all";
export type AiProviderViewState = "api" | "local" | "saving" | "error";
export interface AiProviderFormState {
id: string;
name: string;
kind: AiProviderKind;
baseUrl: string;
apiKeyRef: string;
modelsText: string;
defaultModel: string;
relayMode: AiRelayMode;
timeoutMs: string;
redactionPolicy: string;
}
export interface AiProviderMetrics {
total: number;
active: number;
disabled: number;
models: number;
}
export interface AiProviderActionState {
providerId: string;
label: string;
success: boolean;
message: string;
}
export function emptyAiProviderForm(): AiProviderFormState {
return {
id: "",
name: "",
kind: "openai-compatible",
baseUrl: "https://api.example.test/v1",
apiKeyRef: "secret://providers/",
modelsText: "gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "direct",
timeoutMs: "30000",
redactionPolicy: "default"
};
}
export function aiProviderToForm(provider: AiProviderResponse): AiProviderFormState {
return {
id: provider.id,
name: provider.name,
kind: provider.kind,
baseUrl: provider.baseUrl,
apiKeyRef: provider.apiKeyRef,
modelsText: provider.models.join(", "),
defaultModel: provider.defaultModel ?? "",
relayMode: provider.relayMode,
timeoutMs: String(provider.timeoutMs),
redactionPolicy: provider.redactionPolicy
};
}
export function summarizeAiProviders(providers: AiProviderResponse[]): AiProviderMetrics {
return providers.reduce<AiProviderMetrics>(
(metrics, provider) => ({
total: metrics.total + 1,
active: metrics.active + (provider.status === "active" ? 1 : 0),
disabled: metrics.disabled + (provider.status === "disabled" ? 1 : 0),
models: metrics.models + provider.models.length
}),
{ total: 0, active: 0, disabled: 0, models: 0 }
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { ComponentType } from "react";
import type { UserProfileUpdateRequest, UserThemePreferenceRequest, UserThemePreferenceResponse } from "../api/types";
import type { CurrentUserView, WorkspaceCapability } from "./workspace";
import type { OperationTracker } from "../stores/operations";
export type PageId = "home" | "servers" | "serverDetail" | "plugins" | "users" | "aiProviders" | "maintenance" | "profileSettings";
export interface PageParams {
serverId?: string;
}
export interface PageRoute {
id: PageId;
label: string;
path: string;
hash: string;
description: string;
requiredCapability: WorkspaceCapability;
showInNav: boolean;
}
export interface PageComponentProps {
session: CurrentUserView;
params: PageParams;
operations: OperationTracker;
onNavigate: (pageId: PageId, params?: PageParams) => void;
onLogout: () => Promise<void>;
onProfileSave: (request: UserProfileUpdateRequest) => Promise<CurrentUserView>;
onThemePreferenceSave: (request: UserThemePreferenceRequest) => Promise<UserThemePreferenceResponse>;
}
export type PageComponent = ComponentType<PageComponentProps>;
export interface PageMetric {
label: string;
value: string;
tone: "neutral" | "success" | "warning";
}
export interface ShellSummaryItem {
label: string;
value: string;
detail: string;
tone: PageMetric["tone"];
}
export interface ServerInstanceView {
id: string;
name: string;
plugin: string;
runEndpoint: string;
state: "draft" | "installing" | "ready" | "running" | "stopped" | "failed" | "deleted";
pendingJobs: number;
latestLog: string;
}
export interface PluginCatalogItem {
id: string;
name: string;
version: string;
serverType: string;
status: "installed" | "disabled" | "invalid" | "updating";
permissions: string[];
bridgeActions: string[];
validation: string;
}
export interface UserAccessView {
id: string;
displayName: string;
status: "active" | "disabled" | "pending";
roles: string[];
review: string;
}
+42
View File
@@ -0,0 +1,42 @@
# Page Contracts
## Shared Visual Contract
All first-party pages inherit the platform_web game-operations style with black-mecha default materials and a selectable magical-girl theme. Page implementations must use shared theme tokens and surface classes so 首页、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护, server details, drawers, dialogs, logs, diffs, plugin controls, and operation history all feel like one console.
- Major surfaces remain transparent jelly/glass panels with visible background desktop, icy rim light, diamond borders, shine sweeps, and candy-color accents.
- Built-in magical desktops and user-uploaded backgrounds render behind readable contrast surfaces.
- Global theme ultimate motion is supplied by the shell-level background layer and lightweight global particle DOM layer, not by page-local fixed decoration elements. It must remain theme-specific and low-cost rather than a dense field of tiny rotating particles.
- Page-specific work must not introduce opaque card islands, unrelated dark/light themes, marketing-style hero layouts, or one-off decorative systems.
- Status, errors, warnings, destructive operations, LLM diff review, and operation/job feedback remain text/icon-visible and traceable.
## 平台概览(原首页)
Platform-administrator-only first screen. Shows online/offline server counts, abnormal instance count, run endpoint health, game type distribution, CPU/memory/disk load, LLM provider connectivity, and recent operational signals (faults, failed jobs, provider errors, audit events) that link to the relevant server, plugin, or AI provider context. Each module loads independently with scoped loading/empty/error states.
## 服务器管理
Default landing page for server owners and server administrators. Shows searchable/status-filterable server cards with online/offline state, player count, TPS, latency, CPU, memory, and disk usage; pending metric values render as stable placeholders. Provides create server workflow, refresh, and routed server detail pages. Empty states cover no servers, no matching filters, and role-scoped no-access.
- Create uses installed plugin and run endpoint selections, then renders the returned instance and install job through one visible operation lifecycle.
- The page must not display raw run credentials, host paths, direct socket details, or AI provider keys.
## 服务器详情
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk, plus confirmed start/stop lifecycle actions. Sections: 概览 (live status cards, warnings), 日志 (level/keyword/time/source filters + log detail drawer with diagnostics), 配置 (edit with reviewable diff before any write job), 插件控制 (controls grouped by plugin, scoped to this server instance, confirmation + lifecycle feedback per action), AI 助手 (LLM suggestions produce recommendation/diff; write jobs require explicit diff confirmation; no raw AI keys reach the frontend), 操作历史 (operation/job IDs, status, timestamps, target, requester, error reasons).
## 插件市场
Shows available and installed game management plugins, versions, capabilities, and create-server entry points. Day-to-day plugin actions live in each server's detail page, not here.
## 用户管理
Shows users, roles, permissions, status, and audit entry points. Platform administrators only.
## AI 提供商管理
Shows configured model providers, base URL, model list, relay mode, status, and test actions. Raw API keys must never be displayed after save. Platform administrators only.
## 系统维护
Shows run endpoint health/capacity and audit events. Platform administrators only.
@@ -0,0 +1,24 @@
# Plugin Page Bridge Contract
Plugin page runs with safe platform context.
## Host Context
- `pluginId`: installed plugin ID.
- `routeKey`: current plugin route.
- `serverInstanceId`: optional selected server instance.
- `themeTokens`: safe theme tokens.
- `permissions`: effective plugin permissions.
## Host APIs
- `api.request`: platform-scoped request only.
- `jobs.dispatch`: authorized job dispatch.
- `logs.query`: historical log query and analysis windows.
- `artifacts.open`: artifact upload/download references.
- `files.request`: scoped file operation requests.
- `ai.invoke`: platform-mediated AI invocation.
## Forbidden
The host must not expose raw auth storage, AI keys, run credentials, host paths, or storage backend credentials.
+70
View File
@@ -0,0 +1,70 @@
export type PluginPermission =
| "server.create"
| "server.read"
| "server.lifecycle"
| "server.files.read"
| "server.files.write"
| "server.logs.read"
| "server.artifacts.read"
| "server.artifacts.write"
| "ai.invoke";
export type PluginBridgeAction =
| "server.instances.read"
| "jobs.dispatch"
| "logs.query"
| "artifacts.open"
| "files.request"
| "ai.invoke";
export interface PluginPageContract {
key: string;
title: string;
path: string;
permissions?: PluginPermission[];
bridgeActions?: PluginBridgeAction[];
}
export interface PluginBridgeManifestContract {
id: string;
declaredPermissions: PluginPermission[];
bridgeActions: PluginBridgeAction[];
pages: PluginPageContract[];
aiPurposes?: string[];
}
export interface PluginBridgeThemeTokens {
colorScheme: "light" | "dark";
accentColor: string;
}
export interface PluginBridgeHostContext {
pluginId: string;
routeKey: string;
serverInstanceId?: string;
themeTokens: PluginBridgeThemeTokens;
permissions: PluginPermission[];
bridgeActions: PluginBridgeAction[];
aiPurposes: string[];
}
export interface PluginBridgeExecuteEnvelope {
requestId: string;
action: PluginBridgeAction;
aiPurpose?: string;
payload?: Record<string, string>;
}
export interface PluginBridgeSafeError {
code: string;
message: string;
details?: string[];
}
export interface PluginBridgeExecutionResult {
requestId: string;
action: PluginBridgeAction;
status: "ok" | "queued" | "denied" | "unsupported" | "cancelled" | "error" | string;
result?: Record<string, string>;
error?: PluginBridgeSafeError;
}
@@ -0,0 +1,85 @@
import type {
GamePluginResponse,
JobResponse,
RunEndpointResponse,
ServerInstanceResponse,
ServerInstanceState
} from "../api/types";
export type ServerWorkflowViewState = "api" | "local" | "saving";
export type ServerLifecycleActionLabel = "create" | "start" | "stop" | "refresh";
export interface ServerCreateFormState {
id: string;
name: string;
pluginId: string;
runEndpointId: string;
}
export interface ServerWorkflowActionState {
label: ServerLifecycleActionLabel;
serverInstanceId?: string;
success: boolean;
message: string;
}
export interface ServerManagementSummary {
total: number;
running: number;
pendingJobs: number;
failed: number;
}
export const emptyServerCreateForm: ServerCreateFormState = {
id: "",
name: "",
pluginId: "",
runEndpointId: ""
};
export function summarizeServerManagement(instances: ServerInstanceResponse[], jobs: JobResponse[]): ServerManagementSummary {
return {
total: instances.length,
running: instances.filter((instance) => instance.state === "running").length,
pendingJobs: jobs.filter((job) => isPendingJobState(job.state)).length,
failed: instances.filter((instance) => instance.state === "failed").length
};
}
export function pendingJobsForServer(jobs: JobResponse[], serverInstanceId: string): JobResponse[] {
return jobs.filter((job) => job.serverInstanceId === serverInstanceId && isPendingJobState(job.state));
}
export function pluginLabel(plugin: GamePluginResponse | undefined, pluginId: string): string {
if (!plugin) {
return pluginId;
}
return plugin.serverDisplayName || plugin.name || plugin.id;
}
export function endpointLabel(endpoint: RunEndpointResponse | undefined, runEndpointId: string): string {
if (!endpoint) {
return runEndpointId;
}
return endpoint.displayName || endpoint.id;
}
export function canStartServer(state: ServerInstanceState): boolean {
return state === "ready" || state === "stopped";
}
export function canStopServer(state: ServerInstanceState): boolean {
return state === "running";
}
export function isPendingJobState(state: JobResponse["state"]): boolean {
return state === "queued" || state === "accepted" || state === "running";
}
export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints: RunEndpointResponse[]): ServerCreateFormState {
return {
...emptyServerCreateForm,
pluginId: plugins[0]?.id ?? "",
runEndpointId: endpoints[0]?.id ?? ""
};
}
+69
View File
@@ -0,0 +1,69 @@
import type { PluginCatalogItem, ServerInstanceView, ShellSummaryItem, UserAccessView } from "./page";
export const shellSummary: ShellSummaryItem[] = [
{ label: "服务器实例", value: "2", detail: "1 个运行中,1 个待配置", tone: "success" },
{ label: "运行节点", value: "1", detail: "本地执行器在线", tone: "success" },
{ label: "插件", value: "2", detail: "桥接能力已就绪", tone: "success" },
{ label: "访问审核", value: "1", detail: "待确认角色变更", tone: "warning" }
];
export const serverInstances: ServerInstanceView[] = [
{
id: "server-example-1",
name: "Example Survival #1",
plugin: "game.example",
runEndpoint: "run-local",
state: "running",
pendingJobs: 0,
latestLog: "stdout seq 1842 acknowledged"
},
{
id: "server-example-2",
name: "Example Creative #2",
plugin: "game.example",
runEndpoint: "run-local",
state: "draft",
pendingJobs: 1,
latestLog: "waiting for create workflow"
}
];
export const pluginCatalog: PluginCatalogItem[] = [
{
id: "game.example",
name: "Example Server",
version: "0.1.0",
serverType: "example",
status: "installed",
permissions: ["server.read", "server.lifecycle", "server.logs.read", "ai.invoke"],
bridgeActions: ["server.instances.read", "logs.query", "ai.invoke"],
validation: "manifest validated"
},
{
id: "game.scum",
name: "SCUM Server",
version: "planned",
serverType: "scum",
status: "disabled",
permissions: ["server.read"],
bridgeActions: ["server.instances.read"],
validation: "pending proof plugin"
}
];
export const userAccess: UserAccessView[] = [
{
id: "user-admin",
displayName: "Operator",
status: "active",
roles: ["admin", "operator"],
review: "baseline owner"
},
{
id: "user-review",
displayName: "Plugin Reviewer",
status: "pending",
roles: ["plugin-reviewer"],
review: "needs approval"
}
];
+192
View File
@@ -0,0 +1,192 @@
import type { JobResponse, ServerInstanceResponse, ServerMetricsResponse, UserContactProfile, UserStatus, UserThemePreferenceResponse } from "../api/types";
export type WorkspaceRole = "platformAdmin" | "serverOwner" | "serverAdmin";
export type WorkspaceCapability =
| "profile.settings.manage"
| "platform.overview.read"
| "servers.read"
| "servers.manage"
| "plugins.market.read"
| "users.manage"
| "aiProviders.manage"
| "system.maintenance";
export interface CurrentUserView {
id: string;
displayName: string;
email?: string;
status: UserStatus;
roles: WorkspaceRole[];
capabilities: WorkspaceCapability[];
profile: UserContactProfile;
themePreference?: UserThemePreferenceResponse;
source: "api" | "local";
}
const roleCapabilities: Record<WorkspaceRole, WorkspaceCapability[]> = {
platformAdmin: [
"profile.settings.manage",
"platform.overview.read",
"servers.read",
"servers.manage",
"plugins.market.read",
"users.manage",
"aiProviders.manage",
"system.maintenance"
],
serverOwner: ["profile.settings.manage", "servers.read", "servers.manage"],
serverAdmin: ["profile.settings.manage", "servers.read", "servers.manage"]
};
export function capabilitiesForRoles(roles: WorkspaceRole[]): WorkspaceCapability[] {
const set = new Set<WorkspaceCapability>();
for (const role of roles) {
for (const capability of roleCapabilities[role] ?? []) {
set.add(capability);
}
}
return [...set];
}
export function isPlatformAdmin(user: CurrentUserView): boolean {
return user.roles.includes("platformAdmin");
}
export function rolesFromBackendRoles(roles: string[]): WorkspaceRole[] {
const mapped = new Set<WorkspaceRole>();
for (const role of roles) {
const normalized = role.toLowerCase();
if (normalized === "admin" || normalized === "platform-admin" || normalized === "platformadmin") {
mapped.add("platformAdmin");
}
if (normalized === "server-owner" || normalized === "owner") {
mapped.add("serverOwner");
}
if (normalized === "server-admin" || normalized === "operator") {
mapped.add("serverAdmin");
}
}
return mapped.size > 0 ? [...mapped] : ["serverAdmin"];
}
export type OperationStatus = "pending" | "succeeded" | "failed";
export interface OperationRecord {
id: string;
intent: string;
targetKind: "server" | "plugin" | "config" | "llm" | "platform";
targetId: string;
requester: string;
status: OperationStatus;
jobId?: string;
jobState?: JobResponse["state"];
message?: string;
errorReason?: string;
diagnosticId?: string;
createdAt: string;
updatedAt: string;
}
export interface ServerCardView {
instance: ServerInstanceResponse;
metrics?: ServerMetricsResponse;
pendingJobs: number;
}
export type ServerStatusFilter = "all" | "online" | "offline" | "attention";
export function serverIsOnline(state: ServerInstanceResponse["state"]): boolean {
return state === "running";
}
export function serverNeedsAttention(state: ServerInstanceResponse["state"]): boolean {
return state === "failed" || state === "installing";
}
export function filterServerCards(cards: ServerCardView[], keyword: string, status: ServerStatusFilter): ServerCardView[] {
const query = keyword.trim().toLowerCase();
return cards.filter((card) => {
if (query && !`${card.instance.name} ${card.instance.id} ${card.instance.pluginId}`.toLowerCase().includes(query)) {
return false;
}
if (status === "online") {
return serverIsOnline(card.instance.state);
}
if (status === "offline") {
return !serverIsOnline(card.instance.state);
}
if (status === "attention") {
return serverNeedsAttention(card.instance.state);
}
return true;
});
}
export interface GameTypeDistributionEntry {
serverType: string;
label: string;
count: number;
}
export interface PlatformOverviewSignal {
id: string;
kind: "log" | "fault" | "plugin" | "aiProvider" | "job";
summary: string;
detail: string;
targetPage: "servers" | "plugins" | "aiProviders" | "maintenance";
targetId?: string;
tone: "info" | "warning" | "error";
at: string;
}
export type ServerDetailSection = "overview" | "logs" | "config" | "plugins" | "llm" | "history";
export const serverDetailSections: Array<{ id: ServerDetailSection; label: string }> = [
{ id: "overview", label: "概览" },
{ id: "logs", label: "日志" },
{ id: "config", label: "配置" },
{ id: "plugins", label: "插件控制" },
{ id: "llm", label: "AI 助手" },
{ id: "history", label: "操作历史" }
];
export interface PluginControlDescriptor {
key: string;
label: string;
description: string;
capability: string;
lifecycleAction?: "start" | "stop";
dangerous: boolean;
}
export interface PluginControlGroupView {
pluginId: string;
pluginName: string;
version: string;
status: string;
controls: PluginControlDescriptor[];
}
export interface DiffLine {
kind: "same" | "added" | "removed";
text: string;
}
export interface ConfigDiffView {
serverInstanceId: string;
configVersion?: number;
key?: string;
source?: string;
summary: string;
lines: DiffLine[];
nextContent: string;
proposedContentInputRef?: string;
}
export interface LlmSuggestionView {
serverInstanceId: string;
source: "api" | "local";
recommendation: string;
diff?: ConfigDiffView;
}
+12
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>Game Server Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/app/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/v1/ {
proxy_pass http://platform:8080/api/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /healthz {
proxy_pass http://platform:8080/healthz;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+1806
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "browser-platform-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"acceptance:browser": "node acceptance/browser-acceptance.mjs",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"lucide-react": "1.23.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react-swc": "4.3.1",
"typescript": "5.9.3",
"vite": "7.3.1",
"vitest": "4.0.18"
}
}
@@ -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
};
+25
View File
@@ -0,0 +1,25 @@
# Platform Web Routes
First-party routes must be declared here before page implementation.
## Required Navigation
- `/`: 平台概览(平台管理员默认落地页).
- `/servers`: 服务器管理(服主/服务器管理员默认落地页).
- `/servers/:serverId`: 服务器详情 route(日常运维工作台:概览、日志、配置、插件控制、AI 助手、操作历史).
- `/plugins`: 插件市场.
- `/users`: 用户管理.
- `/ai-providers`: AI 提供商管理.
- `/maintenance`: 系统维护(审计事件与运行节点).
- `/plugin-pages/:pluginId/:routeKey`: platform-hosted plugin page route.
## Role Scoping
Navigation entries are generated from the current user's capability set (`contracts/workspace.ts`):
- Platform administrators see 平台概览、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护.
- Server owners and server administrators see only 服务器管理 and their server detail workspaces; unauthorized hashes redirect to the role default workspace.
- Default landing: platform administrators land on `/`, server owners/administrators land on `/servers`.
- Server creation opens inline from 服务器管理 (no separate `/servers/new` page).
Server and plugin detail flows must use routes, modals, or drawers. Do not build a fixed left-list/right-detail page.
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { defaultPageForUser, firstPartyRoutes, hashForPage, navigationRoutesForUser, resolveRouteHash, routeForPage } from "./routes";
import { initialNavigationState } from "../stores/navigation";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
const platformAdmin: CurrentUserView = {
id: "user-admin",
displayName: "Admin",
status: "active",
roles: ["platformAdmin"],
capabilities: capabilitiesForRoles(["platformAdmin"]),
profile: {},
source: "local"
};
const serverAdmin: CurrentUserView = {
id: "user-server",
displayName: "Server Operator",
status: "active",
roles: ["serverAdmin"],
capabilities: capabilitiesForRoles(["serverAdmin"]),
profile: {},
source: "local"
};
describe("console shell routes", () => {
it("resolves known hashes and paths", () => {
expect(resolveRouteHash("#/servers", platformAdmin).route.id).toBe("servers");
expect(resolveRouteHash("#/profile", platformAdmin).route.id).toBe("profileSettings");
expect(resolveRouteHash("#/aiProviders", platformAdmin).route.id).toBe("aiProviders");
expect(resolveRouteHash("#/ai-providers", platformAdmin).route.id).toBe("aiProviders");
expect(resolveRouteHash("#/unknown", platformAdmin).route.id).toBe("home");
expect(resolveRouteHash("#/plugins", platformAdmin).route.id).toBe("plugins");
});
it("resolves server detail hashes with params", () => {
const resolved = resolveRouteHash("#/servers/server-example-1", platformAdmin);
expect(resolved.route.id).toBe("serverDetail");
expect(resolved.params.serverId).toBe("server-example-1");
expect(hashForPage("serverDetail", { serverId: "server-example-1" })).toBe("#/servers/server-example-1");
});
it("keeps route metadata available for shell navigation", () => {
expect(firstPartyRoutes.map((route) => route.id)).toContain("maintenance");
expect(firstPartyRoutes.map((route) => route.id)).toContain("profileSettings");
expect(routeForPage("users")).toMatchObject({ label: "用户管理", hash: "#/users" });
expect(routeForPage("profileSettings")).toMatchObject({ label: "个人设置", hash: "#/profile" });
});
it("routes platform administrators to the platform overview by default", () => {
expect(defaultPageForUser(platformAdmin)).toBe("home");
expect(initialNavigationState(platformAdmin, "").pageId).toBe("home");
});
it("routes server owners and administrators to the server list by default", () => {
expect(defaultPageForUser(serverAdmin)).toBe("servers");
expect(initialNavigationState(serverAdmin, "").pageId).toBe("servers");
});
it("hides platform-only navigation from server-only users", () => {
const labels = navigationRoutesForUser(serverAdmin).map((route) => route.label);
expect(labels).toContain("服务器管理");
expect(labels).not.toContain("平台概览");
expect(labels).not.toContain("用户管理");
expect(labels).not.toContain("AI 提供商管理");
expect(labels).not.toContain("系统维护");
expect(labels).not.toContain("个人设置");
});
it("shows all platform areas to platform administrators", () => {
const labels = navigationRoutesForUser(platformAdmin).map((route) => route.label);
expect(labels).toEqual(["平台概览", "服务器管理", "插件市场", "用户管理", "AI 提供商管理", "系统维护"]);
});
it("allows every authenticated role to open profile settings", () => {
expect(initialNavigationState(platformAdmin, "#/profile").pageId).toBe("profileSettings");
expect(initialNavigationState(serverAdmin, "#/profile").pageId).toBe("profileSettings");
});
it("redirects unauthorized hashes to the role default workspace", () => {
expect(initialNavigationState(serverAdmin, "#/home").pageId).toBe("servers");
expect(initialNavigationState(serverAdmin, "#/users").pageId).toBe("servers");
expect(initialNavigationState(serverAdmin, "#/servers/server-1").pageId).toBe("serverDetail");
});
});
+141
View File
@@ -0,0 +1,141 @@
import type { PageId, PageParams, PageRoute } from "../contracts/page";
import type { CurrentUserView } from "../contracts/workspace";
import { isPlatformAdmin } from "../contracts/workspace";
export const firstPartyRoutes: PageRoute[] = [
{
id: "profileSettings",
label: "个人设置",
path: "/profile",
hash: "#/profile",
description: "个人资料与界面偏好",
requiredCapability: "profile.settings.manage",
showInNav: false
},
{
id: "home",
label: "平台概览",
path: "/",
hash: "#/home",
description: "平台运行健康概览",
requiredCapability: "platform.overview.read",
showInNav: true
},
{
id: "servers",
label: "服务器管理",
path: "/servers",
hash: "#/servers",
description: "服务器列表、状态与生命周期",
requiredCapability: "servers.read",
showInNav: true
},
{
id: "serverDetail",
label: "服务器详情",
path: "/servers/:serverId",
hash: "#/servers/:serverId",
description: "单服务器日常运维工作台",
requiredCapability: "servers.read",
showInNav: false
},
{
id: "plugins",
label: "插件市场",
path: "/plugins",
hash: "#/plugins",
description: "插件发现、安装状态与文档",
requiredCapability: "plugins.market.read",
showInNav: true
},
{
id: "users",
label: "用户管理",
path: "/users",
hash: "#/users",
description: "用户、角色和访问审核",
requiredCapability: "users.manage",
showInNav: true
},
{
id: "aiProviders",
label: "AI 提供商管理",
path: "/ai-providers",
hash: "#/aiProviders",
description: "平台中介模型配置",
requiredCapability: "aiProviders.manage",
showInNav: true
},
{
id: "maintenance",
label: "系统维护",
path: "/maintenance",
hash: "#/maintenance",
description: "审计事件与运行节点健康",
requiredCapability: "system.maintenance",
showInNav: true
}
];
const routesById = new Map(firstPartyRoutes.map((route) => [route.id, route]));
export interface ResolvedRoute {
route: PageRoute;
params: PageParams;
}
export function routeForPage(pageId: PageId): PageRoute {
return routesById.get(pageId) ?? firstPartyRoutes[0];
}
export function hashForPage(pageId: PageId, params: PageParams = {}): string {
const route = routeForPage(pageId);
if (pageId === "serverDetail" && params.serverId) {
return `#/servers/${encodeURIComponent(params.serverId)}`;
}
return route.hash;
}
export function defaultPageForUser(user: CurrentUserView): PageId {
return isPlatformAdmin(user) ? "home" : "servers";
}
export function navigationRoutesForUser(user: CurrentUserView): PageRoute[] {
return firstPartyRoutes.filter((route) => route.showInNav && user.capabilities.includes(route.requiredCapability));
}
export function canAccessRoute(user: CurrentUserView, pageId: PageId): boolean {
const route = routesById.get(pageId);
if (!route) {
return false;
}
return user.capabilities.includes(route.requiredCapability);
}
export function resolveRouteHash(hash: string, user?: CurrentUserView): ResolvedRoute {
const fallback: ResolvedRoute = {
route: routeForPage(user ? defaultPageForUser(user) : "servers"),
params: {}
};
const normalized = hash.replace(/^#/, "");
if (!normalized || normalized === "/") {
return fallback;
}
const segments = normalized.replace(/^\//, "").split("/").filter(Boolean);
if (segments.length === 0) {
return fallback;
}
if (segments[0] === "servers" && segments.length > 1) {
return { route: routeForPage("serverDetail"), params: { serverId: decodeURIComponent(segments[1]) } };
}
const key = segments[0];
const byId = routesById.get(key as PageId);
if (byId) {
return { route: byId, params: {} };
}
const byPath = firstPartyRoutes.find((route) => route.path === `/${key}`);
if (byPath) {
return { route: byPath, params: {} };
}
return fallback;
}
+28
View File
@@ -0,0 +1,28 @@
import type { AiProviderRequest, AiProviderUpdateRequest } from "../api/types";
import type { AiProviderFormState } from "../contracts/aiProviders";
export function aiProviderCreateRequestFromForm(form: AiProviderFormState): AiProviderRequest {
return {
id: form.id.trim(),
...aiProviderUpdateRequestFromForm(form)
};
}
export function aiProviderUpdateRequestFromForm(form: AiProviderFormState): AiProviderUpdateRequest {
const models = form.modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
return {
name: form.name.trim(),
kind: form.kind,
baseUrl: form.baseUrl.trim(),
apiKeyRef: form.apiKeyRef.trim(),
models,
defaultModel: form.defaultModel.trim() || models[0],
relayMode: form.relayMode,
timeoutMs: Number.parseInt(form.timeoutMs, 10),
redactionPolicy: form.redactionPolicy.trim() || "default"
};
}
+11
View File
@@ -0,0 +1,11 @@
export interface WebRuntimeEnv {
platformApiBaseUrl: string;
enableLocalAuthFallback: boolean;
}
export function readWebRuntimeEnv(): WebRuntimeEnv {
return {
platformApiBaseUrl: import.meta.env.VITE_PLATFORM_API_BASE_URL ?? "/api/v1",
enableLocalAuthFallback: import.meta.env.VITE_ENABLE_LOCAL_AUTH_FALLBACK === "true"
};
}
@@ -0,0 +1,12 @@
# Frontend Structure Rules
Validation rules for future frontend checks:
- Page components must not define shared API request/response types.
- Route definitions must live under `routes/`.
- API clients must live under `api/`.
- Plugin bridge contracts must live under `contracts/`.
- Validation schemas must live under `schemas/`.
- Visual theme tokens, shared surface styles, and background behavior must live under `theme/`, and `theme/README.md` must document the active style contract.
- Full-workspace magical ultimate-effect behavior must live in `components/MagicalParticleLayer.tsx` and be mounted from shared shell chrome, not copied into individual pages.
- Shared utilities must live under `utils/`.
+24
View File
@@ -0,0 +1,24 @@
import type { ServerLifecycleCommandRequest, ServerLifecycleCreateRequest, ServerInstanceResponse } from "../api/types";
import type { ServerCreateFormState } from "../contracts/serverManagement";
export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
const id = form.id.trim();
return {
id,
pluginId: form.pluginId.trim(),
runEndpointId: form.runEndpointId.trim(),
name: form.name.trim(),
idempotencyKey: lifecycleIdempotencyKey("create", id, sequence)
};
}
export function serverLifecycleCommandRequest(instance: ServerInstanceResponse, action: "start" | "stop", sequence = Date.now()): ServerLifecycleCommandRequest {
return {
expectedConfigVersion: instance.configVersion,
idempotencyKey: lifecycleIdempotencyKey(action, instance.id, sequence)
};
}
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop", serverInstanceId: string, sequence: number): string {
return `web:${action}:${serverInstanceId}:${sequence}`;
}
+23
View File
@@ -0,0 +1,23 @@
import type { PageId, PageParams } from "../contracts/page";
import type { CurrentUserView } from "../contracts/workspace";
import { canAccessRoute, defaultPageForUser, resolveRouteHash } from "../routes/routes";
import { firstPartyRoutes } from "../routes/routes";
const routeIds: PageId[] = firstPartyRoutes.map((route) => route.id);
export function isPageId(value: string): value is PageId {
return routeIds.includes(value as PageId);
}
export interface NavigationState {
pageId: PageId;
params: PageParams;
}
export function initialNavigationState(user: CurrentUserView, hash = typeof window === "undefined" ? "" : window.location.hash): NavigationState {
const resolved = resolveRouteHash(hash, user);
if (!canAccessRoute(user, resolved.route.id)) {
return { pageId: defaultPageForUser(user), params: {} };
}
return { pageId: resolved.route.id, params: resolved.params };
}
+93
View File
@@ -0,0 +1,93 @@
import { useCallback, useRef, useState } from "react";
import type { JobResponse } from "../api/types";
import type { OperationRecord, OperationStatus } from "../contracts/workspace";
export interface BeginOperationInput {
intent: string;
targetKind: OperationRecord["targetKind"];
targetId: string;
requester?: string;
}
export interface OperationUpdate {
status?: OperationStatus;
job?: JobResponse;
message?: string;
errorReason?: string;
diagnosticId?: string;
}
export interface OperationTracker {
operations: OperationRecord[];
begin: (input: BeginOperationInput) => string;
update: (operationId: string, update: OperationUpdate) => void;
succeed: (operationId: string, message?: string, job?: JobResponse) => void;
fail: (operationId: string, errorReason: string, diagnosticId?: string) => void;
isPending: (targetId: string, intent?: string) => boolean;
}
export function useOperationTracker(): OperationTracker {
const [operations, setOperations] = useState<OperationRecord[]>([]);
const sequence = useRef(0);
const begin = useCallback((input: BeginOperationInput): string => {
sequence.current += 1;
const now = new Date().toISOString();
const record: OperationRecord = {
id: `op-${Date.now()}-${sequence.current}`,
intent: input.intent,
targetKind: input.targetKind,
targetId: input.targetId,
requester: input.requester ?? "当前用户",
status: "pending",
createdAt: now,
updatedAt: now
};
setOperations((current) => [record, ...current]);
return record.id;
}, []);
const update = useCallback((operationId: string, patch: OperationUpdate) => {
setOperations((current) =>
current.map((operation) =>
operation.id === operationId
? {
...operation,
status: patch.status ?? operation.status,
jobId: patch.job?.id ?? operation.jobId,
jobState: patch.job?.state ?? operation.jobState,
message: patch.message ?? operation.message,
errorReason: patch.errorReason ?? operation.errorReason,
diagnosticId: patch.diagnosticId ?? operation.diagnosticId,
updatedAt: new Date().toISOString()
}
: operation
)
);
}, []);
const succeed = useCallback(
(operationId: string, message?: string, job?: JobResponse) => {
update(operationId, { status: "succeeded", message, job });
},
[update]
);
const fail = useCallback(
(operationId: string, errorReason: string, diagnosticId?: string) => {
update(operationId, { status: "failed", errorReason, diagnosticId: diagnosticId ?? operationId });
},
[update]
);
const isPending = useCallback(
(targetId: string, intent?: string) =>
operations.some(
(operation) => operation.status === "pending" && operation.targetId === targetId && (intent === undefined || operation.intent === intent)
),
[operations]
);
return { operations, begin, update, succeed, fail, isPending };
}
+69
View File
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { setPlatformApiSessionToken } from "../api/client";
import { rolesFromBackendRoles } from "../contracts/workspace";
import { currentUserFromResponse, loadCurrentUser, localFallbackUser } from "./session";
describe("session store helpers", () => {
afterEach(() => {
setPlatformApiSessionToken(null);
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("does not grant platform administrator access for local fallback", () => {
expect(localFallbackUser.roles).toEqual(["serverAdmin"]);
expect(localFallbackUser.capabilities).not.toContain("users.manage");
});
it("maps backend platform admin roles only from API responses", () => {
const user = currentUserFromResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] }, "api");
expect(user.roles).toEqual(["platformAdmin"]);
expect(user.capabilities).toContain("users.manage");
expect(rolesFromBackendRoles(["server-admin"])).toEqual(["serverAdmin"]);
});
it("returns null without a stored API session token", async () => {
stubWindowStorage(new Map());
await expect(loadCurrentUser()).resolves.toBeNull();
});
it("loads current user with a stored API bearer token", async () => {
const storage = new Map([["platform-web.session.apiToken", "session-token"]]);
stubWindowStorage(storage);
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer session-token");
return jsonResponse({ id: "user-admin", displayName: "Operator", status: "active", roles: ["platform-admin"] });
});
vi.stubGlobal("fetch", fetchMock);
await expect(loadCurrentUser()).resolves.toMatchObject({ id: "user-admin", roles: ["platformAdmin"], source: "api" });
expect(storage.get("platform-web.session.apiToken")).toBe("session-token");
});
it("clears stale stored API session tokens", async () => {
const storage = new Map([["platform-web.session.apiToken", "stale-token"]]);
stubWindowStorage(storage);
vi.stubGlobal("fetch", vi.fn(async () => jsonResponse({ message: "authentication required" }, 401)));
await expect(loadCurrentUser()).resolves.toBeNull();
expect(storage.has("platform-web.session.apiToken")).toBe(false);
});
});
function stubWindowStorage(values: Map<string, string>) {
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key)
}
});
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" }
});
}
+281
View File
@@ -0,0 +1,281 @@
import { useEffect, useState } from "react";
import { platformApiClient, setPlatformApiSessionToken } from "../api/client";
import type {
AuthSessionResponse,
CurrentUserResponse,
LoginRequest,
RegisterRequest,
UserProfileUpdateRequest,
UserThemePreferenceRequest,
UserThemePreferenceResponse
} from "../api/types";
import type { CurrentUserView } from "../contracts/workspace";
import { capabilitiesForRoles, rolesFromBackendRoles } from "../contracts/workspace";
import { readWebRuntimeEnv } from "../schemas/env";
import { defaultThemeBackgroundId, defaultThemePaletteId, loadThemeState } from "../theme/tokens";
const sessionTokenStorageKey = "platform-web.session.apiToken";
const localFallbackEnabled = readWebRuntimeEnv().enableLocalAuthFallback;
export const localFallbackUser: CurrentUserView = {
id: "local-server-operator",
displayName: "Local Server Operator",
email: "local.server@example.test",
status: "active",
roles: ["serverAdmin"],
capabilities: capabilitiesForRoles(["serverAdmin"]),
profile: {
phone: "",
qq: "",
contactNote: "本地开发回退账号,仅限服务器工作台"
},
source: "local"
};
export interface AuthFormState {
mode: "login" | "register";
pending: boolean;
error?: string;
success?: string;
}
export interface SessionState {
user?: CurrentUserView;
loaded: boolean;
authenticated: boolean;
authUnavailable: boolean;
localFallbackAvailable: boolean;
auth: AuthFormState;
login: (request: LoginRequest) => Promise<void>;
register: (request: RegisterRequest) => Promise<void>;
logout: () => Promise<void>;
continueWithLocalFallback: () => void;
switchAuthMode: (mode: AuthFormState["mode"]) => void;
updateProfile: (request: UserProfileUpdateRequest) => Promise<CurrentUserView>;
updateThemePreference: (request: UserThemePreferenceRequest) => Promise<UserThemePreferenceResponse>;
}
export async function loadCurrentUser(): Promise<CurrentUserView | null> {
const storedToken = readStoredSessionToken();
setPlatformApiSessionToken(storedToken);
if (!storedToken) {
return null;
}
try {
const response = await platformApiClient.getCurrentUser();
return currentUserFromResponse(response, "api");
} catch {
persistSessionToken(null);
setPlatformApiSessionToken(null);
return null;
}
}
export function useSession(): SessionState {
const [user, setUser] = useState<CurrentUserView | undefined>();
const [loaded, setLoaded] = useState(false);
const [authUnavailable, setAuthUnavailable] = useState(false);
const [auth, setAuth] = useState<AuthFormState>({ mode: "login", pending: false });
useEffect(() => {
let cancelled = false;
void loadCurrentUser().then((currentUser) => {
if (cancelled) {
return;
}
setUser(currentUser ?? undefined);
setAuthUnavailable(!currentUser);
setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
async function login(request: LoginRequest) {
setAuth({ mode: "login", pending: true });
try {
const response = await platformApiClient.login(request);
finishAuth(response, "登录成功");
} catch (error) {
setAuth({
mode: "login",
pending: false,
error: error instanceof Error ? error.message : "登录失败,请重试。"
});
}
}
async function register(request: RegisterRequest) {
setAuth({ mode: "register", pending: true });
try {
const response = await platformApiClient.register(request);
const nextUser = currentUserFromResponse(response.user, "api");
if (response.status === "authenticated") {
setUser(nextUser);
persistSessionToken(response.sessionId ?? null);
setPlatformApiSessionToken(response.sessionId ?? null);
}
setAuth({
mode: response.status === "authenticated" ? "login" : "register",
pending: false,
success: response.message ?? (response.status === "authenticated" ? "注册成功,已进入工作台。" : "注册申请已提交,等待平台管理员审核。")
});
} catch (error) {
setAuth({
mode: "register",
pending: false,
error: error instanceof Error ? error.message : "注册失败,请稍后重试。"
});
}
}
async function logout() {
try {
await platformApiClient.logout();
} catch {
// Local fallback logout still clears the visible session.
}
persistSessionToken(null);
setPlatformApiSessionToken(null);
setUser(undefined);
setAuthUnavailable(false);
setAuth({ mode: "login", pending: false, success: "已退出登录。" });
}
function continueWithLocalFallback() {
if (!localFallbackEnabled) {
setAuth({ mode: "login", pending: false, error: "本地回退未启用,请使用真实账号登录。" });
return;
}
setUser(localFallbackUser);
setAuthUnavailable(false);
setAuth({ mode: "login", pending: false, success: "已进入本地回退工作台。" });
}
function switchAuthMode(mode: AuthFormState["mode"]) {
setAuth({ mode, pending: false });
}
async function updateProfile(request: UserProfileUpdateRequest): Promise<CurrentUserView> {
const current = user ?? localFallbackUser;
try {
const response = await platformApiClient.updateCurrentUserProfile(request);
const nextUser = currentUserFromResponse(response, "api");
setUser(nextUser);
return nextUser;
} catch {
const nextUser: CurrentUserView = {
...current,
displayName: request.displayName,
profile: {
avatarUrl: request.avatarUrl,
phone: request.phone,
qq: request.qq,
contactNote: request.contactNote
},
source: "local"
};
setUser(nextUser);
return nextUser;
}
}
async function updateThemePreference(request: UserThemePreferenceRequest): Promise<UserThemePreferenceResponse> {
const current = user ?? localFallbackUser;
try {
const response = await platformApiClient.updateCurrentUserTheme(request);
setUser({ ...current, themePreference: response, source: "api" });
return response;
} catch {
const response: UserThemePreferenceResponse = {
...request,
userId: current.id,
persistence: "local",
updatedAt: new Date().toISOString()
};
const nextUser = { ...current, themePreference: response, source: "local" as const };
setUser(nextUser);
return response;
}
}
function finishAuth(response: AuthSessionResponse, fallbackMessage: string) {
const nextUser = currentUserFromResponse(response.user, "api");
setUser(nextUser);
persistSessionToken(response.sessionId ?? null);
setPlatformApiSessionToken(response.sessionId ?? null);
setAuthUnavailable(false);
setAuth({ mode: "login", pending: false, success: response.message ?? fallbackMessage });
}
return {
user,
loaded,
authenticated: Boolean(user),
authUnavailable,
localFallbackAvailable: localFallbackEnabled,
auth,
login,
register,
logout,
continueWithLocalFallback,
switchAuthMode,
updateProfile,
updateThemePreference
};
}
export function currentUserFromResponse(response: CurrentUserResponse, source: CurrentUserView["source"]): CurrentUserView {
const roles = rolesFromBackendRoles(response.roles);
const themeState = loadThemeState();
return {
id: response.id,
displayName: response.displayName,
email: response.email,
status: response.status ?? "active",
roles,
capabilities: response.capabilities?.length ? (response.capabilities as CurrentUserView["capabilities"]) : capabilitiesForRoles(roles),
profile: response.profile ?? {},
themePreference:
response.themePreference ??
(source === "local"
? {
userId: response.id,
paletteId: themeState.paletteId || defaultThemePaletteId,
backgroundPresetId: themeState.backgroundPresetId || defaultThemeBackgroundId,
backgroundImage: themeState.backgroundImage,
persistence: "local",
updatedAt: new Date().toISOString()
}
: undefined),
source
};
}
function readStoredSessionToken(): string | null {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage.getItem(sessionTokenStorageKey);
} catch {
return null;
}
}
function persistSessionToken(token: string | null) {
if (typeof window === "undefined") {
return;
}
try {
if (token) {
window.localStorage.setItem(sessionTokenStorageKey, token);
} else {
window.localStorage.removeItem(sessionTokenStorageKey);
}
} catch {
// Storage can be unavailable; the in-memory session remains usable.
}
}
+61
View File
@@ -0,0 +1,61 @@
# platform_web/theme
This directory owns the platform_web visual system. Keep the console in a unified game-operations style with two first-party theme families: the default black mecha console and the optional magical-girl console.
## Style Contract
- The UI is a game operations console, not a landing page and not a generic SaaS dashboard.
- The default visual language is black mecha: dark cockpit panels, cyan scanner light, angular frame cuts, tactical grid lines, and amber energy accents.
- The optional magical-girl visual language uses pink moonlight, jelly glass, gold star borders, ribbon glow, and large visible magic-circle motifs.
- The primary menu is a compact admin sidebar with two states: an expanded text menu and a collapsed icon rail. It must keep the same route order, readable Chinese labels, icon-only tooltips in collapsed mode, and active state framing. In the magical-girl theme it should feel like a pink moonlit operations rail; in the default theme it should feel like a black mecha console rail with restrained scanner accents.
- Global floating decoration must come from `components/MagicalParticleLayer.tsx`, using a full-workspace background image layer plus a lightweight global particle DOM layer that reads the active theme variables. Do not add fixed page-local decorative spans or page-local backdrop systems for sparkles, hearts, moons, sigils, or snowflakes.
- Each theme must have its own visible low-cost ultimate effect: mecha themes use scanner/core/targeting effects, and magical-girl themes use large magic circles, star glints, and ribbon bursts. Do not implement the global effect as dozens of tiny rotating particles.
- The background desktop is part of the interface. Major surfaces must remain translucent enough for the selected built-in or uploaded background to show through while preserving readable text.
- Operational clarity wins over decoration. Logs, diffs, forms, errors, warning states, destructive confirmations, LLM review output, and operation/job feedback must stay readable and traceable.
- Default visual assets must be original CSS/generated motifs. Do not bundle recognizable third-party character art. Users may upload their own backgrounds.
## Files
- `tokens.ts`: theme palette IDs, built-in desktop preset IDs, CSS variable values, local-storage keys, apply/persist helpers, and the live palette-change event used to keep shell chrome synchronized after theme switches.
- `base.css`: shared CSS primitives for the shell, collapsible sidebar navigation, account/profile panel, cards, panels, tables, drawers, dialogs, logs, diffs, plugin groups, command buttons, mecha frame cuts, magical star frames, global background/particle layer placement, and responsive behavior.
- `tokens.test.ts`: tests that lock the default black mecha palette, built-in mecha/magical desktop presets, palette-variable cleanup, and theme-change notifications.
- `base-css.test.js`: CSS contract tests for shared frame behavior that should not be represented in TypeScript token tests.
## Background Rules
- `defaultThemePaletteId` must remain `mecha-black` unless an OpenSpec change explicitly replaces the visual direction.
- `defaultThemeBackgroundId` should point to a built-in mecha desktop preset that works without uploaded imagery.
- Built-in presets use CSS variables named `--workspace-background-pattern-*` and render behind the app shell.
- Uploaded backgrounds use `--workspace-background-image`, set `data-custom-background="true"`, and take visual precedence over the selected preset.
- Removing an uploaded background must reveal the selected built-in preset again.
- Theme palette switches must keep uploaded backgrounds intact. Changing from magical-girl to black mecha, or back again, must not clear `--workspace-background-image` or alter the custom-background fallback preset.
- Any new preset must include an `id`, `label`, `summary`, `preview`, and all required `--workspace-background-pattern-*` variables. Current presets are 机甲格纳库 and 粉月魔法阵.
## Theme Switching Rules
- `applyThemePalette()` is the single writer for active palette variables. It must remove the union of known palette variables before applying the selected palette so previous-theme variables, such as magical-girl accessory SVGs, cannot leak into black mecha.
- After applying a palette, `applyThemePalette()` must dispatch `themePaletteChangeEvent`. Shell chrome such as the sidebar subtitle and mini swatch strip should subscribe to that event instead of keeping stale local theme labels.
- `data-theme-palette` on the document root is the source of truth for theme-specific CSS selectors. Do not infer the active theme from route state, component-local state, or background preset IDs.
- Theme changes must be visually atomic: root marker, CSS variables, menu/sidebar chrome, active navigation frame, and shared surface accessories should all reflect the same selected palette immediately.
## Surface Rules
- Use shared classes such as `metric-card`, `overview-card`, `console-panel`, `catalog-card`, `server-card`, `server-detail-header`, `resource-table-wrap`, `provider-table-wrap`, `drawer-panel`, `confirm-panel`, `plugin-group`, and `operation-item` instead of creating page-local card styles.
- Shared framed surfaces should use `var(--panel-material)`, `var(--panel-shadow)`, `var(--frame-corner)`, and `var(--frame-accent)` so each theme can change structure, fill, and glow style beyond simple color swaps.
- A visual region should have only one ornamental frame at a hierarchy level. If a `.state-view` is nested inside a shared framed parent such as `.console-panel`, `.catalog-card`, `.server-card`, `.resource-table-wrap`, `.provider-table-wrap`, `.server-table-wrap`, `.plugin-group`, or `.operation-item`, the parent owns the frame and the nested state view must render as transparent, borderless content with no `::before` or `::after` accessory.
- Standalone `.state-view` instances may keep their own readable state treatment when they are not inside an already framed surface.
- Menu frames should use `var(--menu-item-bg)`, `var(--menu-item-active-bg)`, `var(--menu-glyph-bg)`, `var(--menu-title-shadow)`, and `var(--menu-active-outline)` so each theme changes active-state treatment, icon material, and rail/sidebar structure.
- Shared decoration variables are part of the contract: `--frosted-edge`, `--frosted-surface`, `--corner-sparkle`, `--jelly-highlight`, `--sugar-dust`, `--crystal-edge-glow`, and `--jelly-inset`. In mecha themes these become scanner/grid/bevel materials; in magical themes they become star, ribbon, and jelly-glass materials.
- Full-screen ambient motifs use the shared `MagicalParticleLayer` background layer and global particle DOM layer. They should remain non-interactive, theme-colored, reduced-motion aware, and behind operational surfaces. Page code should not create one-off fixed decoration containers.
- Keep framed repeated items at 8px radius or less. Pills and circular avatars are allowed for native pill/circle controls.
- Do not replace shared panels with opaque white cards, heavy dark themes, unthemed gradients, or one-off color systems.
- Do not use color as the only status signal. Pair color with text or familiar status icons.
## Adding UI
1. Reuse existing shared surface, command, table, form, status, and state-view classes first.
2. If a new shared pattern is truly needed, add it in `base.css` and describe its intended use here.
3. When placing empty/loading/error states inside an existing shared panel, verify the state view does not introduce a second framed panel or accessory layer.
4. If a new palette or background preset is added, update `tokens.ts`, `tokens.test.ts`, and any CSS contract tests together.
5. Run `npm run typecheck`, `npm test`, `npm run build`, `scripts/check-structure.sh`, and `openspec validate <change> --strict` before claiming completion.
6. For page or interaction changes, perform a browser walkthrough before marking visual acceptance tasks complete. At minimum, switch black mecha -> magical-girl -> black mecha with both built-in and uploaded backgrounds when the change touches theme switching, frame accessories, or custom-background styling.
+13
View File
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("platform web shared theme CSS", () => {
it("suppresses nested state frames inside shared framed surfaces", () => {
const themeCss = readFileSync(new URL("./base.css", import.meta.url), "utf8");
const nestedStateReset = themeCss.slice(themeCss.indexOf("A state view inside an already framed surface"));
expect(nestedStateReset).toContain(".state-view::after");
expect(nestedStateReset).toContain("content: none");
expect(nestedStateReset).toContain("background: transparent");
});
});
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applyThemePalette,
defaultThemeBackgroundId,
defaultThemePaletteId,
getThemeBackgroundPreset,
getThemePalette,
themeBackgroundPresets,
themePaletteChangeEvent,
themePalettes
} from "./tokens";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("platform web theme palettes", () => {
it("offers black mecha and magical-girl palettes with usable swatches", () => {
expect(themePalettes.map((palette) => palette.label)).toEqual(["黑色机甲", "魔法少女"]);
for (const palette of themePalettes) {
expect(palette.swatches.length).toBeGreaterThanOrEqual(5);
expect(palette.variables["--accent"]).toMatch(/^#[0-9a-f]{6}$/i);
expect(palette.variables["--surface"]).toContain("rgba");
expect(palette.variables["--rim-light"]).toContain("rgba");
expect(palette.variables["--crystal-rim"]).toContain("rgba");
expect(palette.variables["--sparkle-gold"]).toContain("rgba");
expect(palette.variables["--panel-material"]).toContain("var");
expect(palette.variables["--menu-item-bg"]).toContain("gradient");
expect(palette.variables["--frame-accessory-top"]).toContain("data:image/svg+xml");
expect(palette.variables["--frame-accessory-bottom"]).toContain("data:image/svg+xml");
expect(palette.variables["--frame-accessory-opacity"]).toMatch(/^0\.\d{2}$/);
expect(accessorySizeNumbers(palette.variables["--frame-accessory-size"])).toEqual(
expect.arrayContaining([expect.any(Number)])
);
expect(palette.variables["--custom-background-overlay"]).toBe("transparent");
const accessoryOpacity = Number(palette.variables["--frame-accessory-opacity"]);
const largestAccessoryDimension = Math.max(...accessorySizeNumbers(palette.variables["--frame-accessory-size"]));
if (palette.id === "magical-girl") {
expect(accessoryOpacity).toBeGreaterThanOrEqual(0.65);
expect(largestAccessoryDimension).toBeGreaterThanOrEqual(56);
expect(largestAccessoryDimension).toBeLessThanOrEqual(80);
expect(
new Set([
palette.variables["--frame-accessory-heart"],
palette.variables["--frame-accessory-wand"],
palette.variables["--frame-accessory-moon"],
palette.variables["--frame-accessory-ribbon"],
palette.variables["--frame-accessory-crystal"],
palette.variables["--frame-accessory-circle"]
]).size
).toBe(6);
} else {
expect(accessoryOpacity).toBeLessThanOrEqual(0.4);
expect(largestAccessoryDimension).toBeLessThanOrEqual(60);
}
}
});
it("falls back to the default black mecha palette", () => {
expect(defaultThemePaletteId).toBe("mecha-black");
expect(getThemePalette("missing").id).toBe("mecha-black");
});
it("clears stale palette variables and publishes the active palette", () => {
const removeProperty = vi.fn();
const setProperty = vi.fn();
const dispatchEvent = vi.fn(() => true);
class TestCustomEvent<T> {
readonly type: string;
readonly detail: T | null;
constructor(type: string, init?: CustomEventInit<T>) {
this.type = type;
this.detail = init?.detail ?? null;
}
}
vi.stubGlobal("document", {
documentElement: {
dataset: {},
style: { removeProperty, setProperty }
}
});
vi.stubGlobal("window", { dispatchEvent });
vi.stubGlobal("CustomEvent", TestCustomEvent);
applyThemePalette("mecha-black");
expect(removeProperty).toHaveBeenCalledWith("--frame-accessory-heart");
expect(setProperty).toHaveBeenCalledWith("--accent", "#48e6ff");
expect(dispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: themePaletteChangeEvent,
detail: { paletteId: "mecha-black" }
})
);
});
it("offers mecha and magical desktop presets with a mecha default", () => {
expect(defaultThemeBackgroundId).toBe("mecha-grid");
expect(themeBackgroundPresets.map((preset) => preset.label)).toEqual(["机甲格纳库", "粉月魔法阵"]);
for (const preset of themeBackgroundPresets) {
expect(preset.preview).toContain("gradient");
expect(preset.variables["--workspace-background-pattern-image"]).toContain("gradient");
expect(preset.variables["--workspace-background-pattern-opacity"]).toMatch(/^0\./);
}
expect(getThemeBackgroundPreset("missing").id).toBe("mecha-grid");
});
});
function accessorySizeNumbers(sizeValue: string): number[] {
return Array.from(sizeValue.matchAll(/(\d+)px/g), (match) => Number(match[1]));
}
+368
View File
@@ -0,0 +1,368 @@
export const themeTokens = {
appName: "Mecha Game Console",
contentMaxWidth: "1180px",
accent: "#48e6ff",
accentSecondary: "#ff77c8",
narrowBreakpoint: 760
} as const;
export type ThemePaletteId = "mecha-black" | "magical-girl";
export type ThemeBackgroundId = "mecha-grid" | "magic-stage";
export const themePaletteChangeEvent = "platform-web:theme-palette-change";
export interface ThemePaletteChangeDetail {
paletteId: ThemePaletteId;
}
export interface ThemePalette {
id: ThemePaletteId;
label: string;
summary: string;
swatches: string[];
variables: Record<string, string>;
}
export interface ThemeBackgroundPreset {
id: ThemeBackgroundId;
label: string;
summary: string;
preview: string;
variables: Record<string, string>;
}
export interface WorkspaceThemeState {
paletteId: ThemePaletteId;
backgroundPresetId: ThemeBackgroundId;
backgroundImage: string | null;
}
const backgroundStorageKey = "platform-web.theme.background";
const backgroundPresetStorageKey = "platform-web.theme.backgroundPreset";
const paletteStorageKey = "platform-web.theme.palette";
const mechaFrameAccessoryTop =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 132 48'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 31h34l9-12h28l7 8h18' stroke='%2348e6ff' stroke-opacity='.86' stroke-width='2.4'/%3E%3Cpath d='M75 17h16l10 10M25 36h22M100 32h15' stroke='%23ffb84d' stroke-opacity='.68' stroke-width='1.7'/%3E%3Ccircle cx='58' cy='24' r='8' stroke='%239af7ff' stroke-opacity='.58' stroke-width='1.6'/%3E%3Cpath d='M58 15v18M49 24h18' stroke='%239af7ff' stroke-opacity='.34' stroke-width='1.2'/%3E%3Cpath d='M106 10l4 4 6-1-4 4 2 6-6-3-5 4 1-7-5-4 6 1z' stroke='%23ffb84d' stroke-opacity='.58' stroke-width='1.4'/%3E%3C/g%3E%3C/svg%3E\")";
const mechaFrameAccessoryBottom =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 132 56'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 38h30l12-19 15 25h25' stroke='%2348e6ff' stroke-opacity='.78' stroke-width='2.4'/%3E%3Cpath d='M34 38l9-13 8 13M74 37h21' stroke='%23ffb84d' stroke-opacity='.62' stroke-width='1.7'/%3E%3Ccircle cx='100' cy='26' r='15' stroke='%239af7ff' stroke-opacity='.54' stroke-width='1.6'/%3E%3Ccircle cx='100' cy='26' r='7' stroke='%23ffb84d' stroke-opacity='.62' stroke-width='1.4'/%3E%3Cpath d='M84 26h32M100 10v32M89 15l22 22M111 15L89 37' stroke='%239af7ff' stroke-opacity='.28' stroke-width='1.1'/%3E%3Cpath d='M18 20l11-7 16 4-13 9z' stroke='%239af7ff' stroke-opacity='.58' stroke-width='1.5'/%3E%3C/g%3E%3C/svg%3E\")";
const magicalFrameAccessoryHeart =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 48'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M36 38S23 29 23 20c0-7 9-9 13-3 4-6 13-4 13 3 0 9-13 18-13 18z' fill='%23ff77c8' fill-opacity='.12' stroke='%23fff6fb' stroke-width='2'/%3E%3Cpath d='M22 20c-6-4-11-2-15 2 5 0 8 2 11 6-5-1-8 1-10 4 7-1 12 1 17 5M50 20c6-4 11-2 15 2-5 0-8 2-11 6 5-1 8 1 10 4-7-1-12 1-17 5' stroke='%23ffd66d' stroke-opacity='.92' stroke-width='1.7'/%3E%3Cpath d='M36 8l2 4 5 1-4 3 1 5-4-3-4 3 1-5-4-3 5-1z' fill='%23ffd66d' fill-opacity='.22' stroke='%23ffd66d' stroke-width='1.4'/%3E%3C/g%3E%3C/svg%3E\")";
const magicalFrameAccessoryWand =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 48'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 40L45 13' stroke='%23ff77c8' stroke-width='3'/%3E%3Cpath d='M49 5l3 6 7 1-5 5 1 7-6-4-7 4 2-7-6-5 7-1z' fill='%23ffd66d' fill-opacity='.2' stroke='%23ffd66d' stroke-width='1.9'/%3E%3Cpath d='M12 14l1.5 3 3.5.5-2.5 2.5.5 3.5-3-2-3 2 .5-3.5L6 17.5l3.5-.5zM59 29l1.5 3 3.5.5-2.5 2.5.5 3.5-3-2-3 2 .5-3.5-2.5-2.5 3.5-.5z' stroke='%23fff6fb' stroke-opacity='.9' stroke-width='1.3'/%3E%3Cpath d='M15 36l7 7' stroke='%23ffd66d' stroke-opacity='.86' stroke-width='1.6'/%3E%3C/g%3E%3C/svg%3E\")";
const magicalFrameAccessoryMoon =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 48'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M42 8c-9 2-15 10-13 19 2 10 12 15 21 11-6-1-11-6-12-12-2-7 0-13 4-18z' fill='%23ff77c8' fill-opacity='.1' stroke='%23fff6fb' stroke-width='2'/%3E%3Cpath d='M19 19l2.5 5 5.5 1-4 4 1 6-5-3-5 3 1-6-4-4 5.5-1z' fill='%23ffd66d' fill-opacity='.2' stroke='%23ffd66d' stroke-width='1.7'/%3E%3Cpath d='M48 10c5 1 9 4 12 8M50 38c5-2 9-5 12-10' stroke='%23ff77c8' stroke-opacity='.84' stroke-width='1.6'/%3E%3Ccircle cx='59' cy='24' r='2' fill='%23fff6fb'/%3E%3C/g%3E%3C/svg%3E\")";
const magicalFrameAccessoryRibbon =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 48'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M35 22c-7-8-17-9-20-4-3 6 5 12 20 8M37 22c7-8 17-9 20-4 3 6-5 12-20 8' fill='%23ff77c8' fill-opacity='.12' stroke='%23fff6fb' stroke-width='2'/%3E%3Cpath d='M35 25L23 42l13-5 5 5 3-17M37 25l12 17-13-5-5 5-3-17' stroke='%23ff77c8' stroke-width='1.8'/%3E%3Ccircle cx='36' cy='24' r='5' fill='%23ffd66d' fill-opacity='.18' stroke='%23ffd66d' stroke-width='1.6'/%3E%3Cpath d='M10 10l1.5 3 3.5.5-2.5 2.5.5 3.5-3-2-3 2 .5-3.5L4 13.5l3.5-.5zM60 8l1.5 3 3.5.5-2.5 2.5.5 3.5-3-2-3 2 .5-3.5-2.5-2.5 3.5-.5z' stroke='%23ffd66d' stroke-width='1.2'/%3E%3C/g%3E%3C/svg%3E\")";
const magicalFrameAccessoryCrystal =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 48'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M36 5l16 14-6 22H26l-6-22z' fill='%23ff77c8' fill-opacity='.09' stroke='%23fff6fb' stroke-width='2'/%3E%3Cpath d='M20 19h32M36 5l-8 14 8 22 8-22zM26 41l10-22 10 22' stroke='%23ffd66d' stroke-opacity='.88' stroke-width='1.35'/%3E%3Cpath d='M10 16l2 4 4 .5-3 3 .8 4.5-3.8-2.4L6 28l1-4.5-3-3 4-.5zM59 9l1.5 3 3.5.5-2.5 2.5.5 3.5-3-2-3 2 .5-3.5-2.5-2.5 3.5-.5z' stroke='%23ff77c8' stroke-width='1.3'/%3E%3C/g%3E%3C/svg%3E\")";
const magicalFrameAccessoryCircle =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 48'%3E%3Cg fill='none' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='36' cy='24' r='18' stroke='%23fff6fb' stroke-width='1.7'/%3E%3Ccircle cx='36' cy='24' r='11' stroke='%23ffd66d' stroke-width='1.4'/%3E%3Cpath d='M36 6v36M18 24h36M23 11l26 26M49 11L23 37' stroke='%23ff77c8' stroke-opacity='.68' stroke-width='1.1'/%3E%3Cpath d='M36 14l3 6 7 1-5 5 1 7-6-4-6 4 1-7-5-5 7-1z' fill='%23ffd66d' fill-opacity='.12' stroke='%23ffd66d' stroke-width='1.2'/%3E%3C/g%3E%3C/svg%3E\")";
const mechaMaterialVariables = {
"--surface": "rgba(5, 12, 18, 0.58)",
"--surface-solid": "rgba(6, 14, 21, 0.88)",
"--surface-raised": "rgba(10, 24, 32, 0.64)",
"--glass-wash": "linear-gradient(145deg, rgba(33, 55, 64, 0.46), rgba(5, 12, 18, 0.42) 46%, rgba(72, 230, 255, 0.08))",
"--shine-sweep": "linear-gradient(112deg, transparent 0 38%, rgba(132, 243, 255, 0.24) 46%, transparent 56%)",
"--glass-tint": "rgba(72, 230, 255, 0.08)",
"--crystal-rim": "rgba(137, 239, 255, 0.64)",
"--moonbeam": "rgba(60, 176, 255, 0.34)",
"--diamond-line": "rgba(119, 237, 255, 0.34)",
"--glass-shadow": "rgba(0, 0, 0, 0.56)",
"--crystal-spark": "rgba(172, 250, 255, 0.92)",
"--sparkle": "rgba(196, 252, 255, 0.96)",
"--sparkle-gold": "rgba(255, 184, 77, 0.82)",
"--corner-sparkle":
"linear-gradient(135deg, rgba(72, 230, 255, 0.72) 0 2px, transparent 2px 100%), linear-gradient(315deg, rgba(255, 184, 77, 0.72) 0 2px, transparent 2px 100%), radial-gradient(circle at 86% 18%, rgba(132, 243, 255, 0.88) 0 1.4px, transparent 2.4px)",
"--jelly-highlight": "linear-gradient(145deg, rgba(132, 243, 255, 0.16), transparent 32%), radial-gradient(circle at 82% 16%, rgba(255, 184, 77, 0.16), transparent 24%)",
"--sugar-dust":
"linear-gradient(90deg, rgba(72, 230, 255, 0.08) 1px, transparent 1px), linear-gradient(180deg, rgba(72, 230, 255, 0.07) 1px, transparent 1px), radial-gradient(circle at 88% 18%, rgba(255, 184, 77, 0.62) 0 1.2px, transparent 2px)",
"--crystal-edge-glow": "0 0 0 1px rgba(72, 230, 255, 0.28), 0 0 22px rgba(72, 230, 255, 0.2), 0 0 44px rgba(0, 0, 0, 0.48)",
"--jelly-inset": "inset 0 1px 0 rgba(180, 248, 255, 0.22), inset 0 -1px 0 rgba(72, 230, 255, 0.16), inset 0 0 24px rgba(72, 230, 255, 0.08)",
"--frosted-edge": "linear-gradient(180deg, rgba(132, 243, 255, 0.18), rgba(7, 12, 18, 0.74) 48%, rgba(255, 184, 77, 0.1))",
"--frosted-surface": "linear-gradient(145deg, rgba(14, 28, 38, 0.62), rgba(5, 12, 18, 0.46) 48%, rgba(72, 230, 255, 0.07))",
"--frame-corner": "linear-gradient(135deg, rgba(72, 230, 255, 0.9) 0 2px, transparent 2px), linear-gradient(315deg, rgba(255, 184, 77, 0.72) 0 2px, transparent 2px)",
"--frame-accent": "linear-gradient(90deg, rgba(72, 230, 255, 0), rgba(72, 230, 255, 0.78), rgba(255, 184, 77, 0.58), rgba(72, 230, 255, 0))",
"--frame-accessory-top": mechaFrameAccessoryTop,
"--frame-accessory-bottom": mechaFrameAccessoryBottom,
"--frame-accessory-opacity": "0.22",
"--frame-accessory-size": "44px 16px, 48px 20px",
"--menu-item-bg": "linear-gradient(90deg, rgba(72, 230, 255, 0.14) 0 1px, transparent 1px 100%), linear-gradient(135deg, rgba(17, 31, 43, 0.94), rgba(4, 8, 13, 0.82) 58%, rgba(255, 184, 77, 0.08))",
"--menu-item-active-bg": "linear-gradient(90deg, rgba(72, 230, 255, 0.44) 0 3px, transparent 3px 100%), linear-gradient(135deg, rgba(20, 51, 64, 0.98), rgba(6, 11, 18, 0.9) 52%, rgba(255, 184, 77, 0.18))",
"--menu-glyph-bg": "linear-gradient(135deg, rgba(72, 230, 255, 0.28), rgba(255, 184, 77, 0.14)), repeating-linear-gradient(90deg, rgba(137, 239, 255, 0.18) 0 1px, transparent 1px 6px), rgba(5, 9, 15, 0.9)",
"--menu-title-shadow": "0 0 14px rgba(72, 230, 255, 0.5), 0 0 2px rgba(255, 184, 77, 0.8)",
"--menu-active-outline": "linear-gradient(90deg, rgba(72, 230, 255, 0.95), rgba(255, 184, 77, 0.72), rgba(72, 230, 255, 0.95))",
"--panel-material": "var(--sugar-dust), var(--glass-wash), linear-gradient(135deg, rgba(5, 12, 18, 0.46), rgba(10, 24, 32, 0.38))",
"--panel-shadow": "var(--jelly-inset), inset 0 0 0 1px rgba(119, 237, 255, 0.24), inset 9px 0 0 rgba(72, 230, 255, 0.06), 0 18px 42px rgba(0, 0, 0, 0.42), 0 0 28px rgba(72, 230, 255, 0.11)"
} as const;
const magicalMaterialVariables = {
"--surface": "rgba(88, 18, 52, 0.48)",
"--surface-solid": "rgba(78, 14, 46, 0.82)",
"--surface-raised": "rgba(118, 26, 68, 0.54)",
"--glass-wash": "linear-gradient(180deg, rgba(255, 226, 242, 0.2), rgba(120, 24, 68, 0.32) 56%, rgba(72, 12, 42, 0.28))",
"--shine-sweep": "radial-gradient(circle at 88% 12%, rgba(255, 255, 255, 0.58) 0 2px, transparent 3px)",
"--glass-tint": "rgba(255, 119, 200, 0.1)",
"--crystal-rim": "rgba(255, 236, 250, 0.92)",
"--moonbeam": "rgba(255, 185, 226, 0.76)",
"--diamond-line": "rgba(255, 217, 239, 0.72)",
"--glass-shadow": "rgba(72, 0, 34, 0.36)",
"--crystal-spark": "rgba(255, 246, 253, 0.98)",
"--sparkle": "rgba(255, 255, 255, 0.98)",
"--sparkle-gold": "rgba(255, 221, 117, 0.9)",
"--corner-sparkle":
"radial-gradient(circle at 86% 14%, rgba(255, 255, 255, 0.98) 0 2.4px, transparent 3.2px), radial-gradient(circle at 93% 23%, rgba(255, 221, 117, 0.92) 0 1.8px, transparent 2.8px)",
"--jelly-highlight": "radial-gradient(circle at 18% 14%, rgba(255, 255, 255, 0.72), transparent 30%), linear-gradient(145deg, rgba(255, 255, 255, 0.34), transparent 34%)",
"--sugar-dust":
"radial-gradient(circle at 14% 20%, rgba(255, 255, 255, 0.95) 0 1.4px, transparent 2.2px), radial-gradient(circle at 86% 18%, rgba(255, 221, 117, 0.9) 0 1.6px, transparent 2.6px), radial-gradient(circle at 78% 84%, rgba(255, 119, 200, 0.76) 0 1.8px, transparent 2.8px)",
"--crystal-edge-glow": "0 0 0 1px rgba(255, 217, 239, 0.78), 0 0 18px rgba(255, 119, 200, 0.46), 0 0 34px rgba(255, 221, 117, 0.18)",
"--jelly-inset": "inset 0 1px 0 rgba(255, 255, 255, 0.52), inset 0 -1px 0 rgba(255, 119, 200, 0.28), inset 0 0 20px rgba(255, 255, 255, 0.18)",
"--frosted-edge": "linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 119, 200, 0.2) 48%, rgba(255, 221, 117, 0.18))",
"--frosted-surface": "linear-gradient(180deg, rgba(255, 226, 242, 0.18), rgba(118, 24, 66, 0.36) 52%, rgba(72, 12, 42, 0.28))",
"--frame-corner": "radial-gradient(circle at 0 0, rgba(255, 221, 117, 0.9) 0 4px, transparent 5px), radial-gradient(circle at 100% 100%, rgba(255, 119, 200, 0.9) 0 4px, transparent 5px)",
"--frame-accent": "linear-gradient(90deg, rgba(255, 221, 117, 0), rgba(255, 221, 117, 0.86), rgba(255, 119, 200, 0.78), rgba(255, 221, 117, 0))",
"--frame-accessory-top": magicalFrameAccessoryWand,
"--frame-accessory-bottom": magicalFrameAccessoryCircle,
"--frame-accessory-heart": magicalFrameAccessoryHeart,
"--frame-accessory-wand": magicalFrameAccessoryWand,
"--frame-accessory-moon": magicalFrameAccessoryMoon,
"--frame-accessory-ribbon": magicalFrameAccessoryRibbon,
"--frame-accessory-crystal": magicalFrameAccessoryCrystal,
"--frame-accessory-circle": magicalFrameAccessoryCircle,
"--frame-accessory-opacity": "0.78",
"--frame-accessory-size": "68px 44px",
"--menu-item-bg": "radial-gradient(circle at 92% 12%, rgba(255, 255, 255, 0.52) 0 3px, transparent 4px), linear-gradient(180deg, rgba(255, 185, 226, 0.22), rgba(135, 10, 65, 0.42) 68%, rgba(94, 0, 44, 0.48))",
"--menu-item-active-bg": "radial-gradient(circle at 96% 18%, rgba(255, 255, 255, 0.72) 0 3px, transparent 4px), linear-gradient(180deg, rgba(255, 203, 232, 0.34), rgba(177, 22, 86, 0.58) 54%, rgba(105, 0, 48, 0.62))",
"--menu-glyph-bg": "radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.78), transparent 34%), radial-gradient(circle at 72% 80%, rgba(255, 221, 117, 0.42), transparent 30%), linear-gradient(135deg, rgba(255, 119, 200, 0.82), rgba(255, 221, 117, 0.44))",
"--menu-title-shadow": "0 2px 0 rgba(96, 24, 24, 0.86), 1px 0 0 rgba(96, 24, 24, 0.86), -1px 0 0 rgba(96, 24, 24, 0.86), 0 0 12px rgba(255, 221, 117, 0.48)",
"--menu-active-outline": "linear-gradient(90deg, rgba(255, 221, 117, 0.96), rgba(255, 246, 253, 0.92), rgba(255, 119, 200, 0.9), rgba(255, 221, 117, 0.96))",
"--panel-material": "var(--sugar-dust), var(--glass-wash), linear-gradient(180deg, rgba(106, 22, 60, 0.3), rgba(72, 12, 42, 0.3))",
"--panel-shadow": "var(--jelly-inset), inset 0 0 0 1px rgba(255, 217, 239, 0.5), 0 18px 42px rgba(72, 0, 34, 0.28), 0 0 30px rgba(255, 119, 200, 0.2), 0 0 16px rgba(255, 221, 117, 0.1)"
} as const;
export const themePalettes: ThemePalette[] = [
{
id: "mecha-black",
label: "黑色机甲",
summary: "黑钢面板、青蓝扫描线、琥珀能量灯",
swatches: ["#05090f", "#111c28", "#48e6ff", "#ffb84d", "#e7f7ff"],
variables: {
"--ink": "#e7f7ff",
"--ink-soft": "#b9d2dc",
"--ink-faint": "#84a1ad",
...mechaMaterialVariables,
"--line": "rgba(116, 236, 255, 0.32)",
"--line-strong": "rgba(132, 243, 255, 0.7)",
"--accent": "#48e6ff",
"--accent-deep": "#9af7ff",
"--accent-soft": "rgba(72, 230, 255, 0.16)",
"--teal": "#4fffd7",
"--teal-soft": "rgba(79, 255, 215, 0.14)",
"--pink": "#ff4d6d",
"--pink-soft": "rgba(255, 77, 109, 0.13)",
"--gold": "#ffb84d",
"--gold-soft": "rgba(255, 184, 77, 0.18)",
"--danger": "#ff5a6e",
"--danger-soft": "rgba(255, 90, 110, 0.14)",
"--success": "#4fffd7",
"--success-soft": "rgba(79, 255, 215, 0.18)",
"--background-base": "#05070b",
"--background-glow-1": "rgba(72, 230, 255, 0.16)",
"--background-glow-2": "rgba(255, 184, 77, 0.11)",
"--background-glow-3": "rgba(255, 77, 109, 0.1)",
"--rim-light": "rgba(180, 248, 255, 0.64)",
"--candy-glow": "rgba(72, 230, 255, 0.32)",
"--ultimate-effect-alpha": "0.86",
"--custom-background-overlay": "transparent",
"--code-surface": "#05090f",
"--code-ink": "#dffcff",
"--code-muted": "#87afba"
}
},
{
id: "magical-girl",
label: "魔法少女",
summary: "粉色舞台、金色星框、显眼魔法阵",
swatches: ["#8b0a40", "#ff77c8", "#ffd66d", "#fff6fb", "#8cf0ff"],
variables: {
"--ink": "#fff8fd",
"--ink-soft": "#ffe8f5",
"--ink-faint": "#ffd0e9",
...magicalMaterialVariables,
"--line": "rgba(255, 201, 230, 0.42)",
"--line-strong": "rgba(255, 225, 139, 0.76)",
"--accent": "#ff77c8",
"--accent-deep": "#fff6fb",
"--accent-soft": "rgba(255, 119, 200, 0.18)",
"--teal": "#8cf0ff",
"--teal-soft": "rgba(140, 240, 255, 0.16)",
"--pink": "#ff8fd2",
"--pink-soft": "rgba(255, 143, 210, 0.22)",
"--gold": "#ffd66d",
"--gold-soft": "rgba(255, 214, 109, 0.24)",
"--danger": "#ff8a9d",
"--danger-soft": "rgba(255, 138, 157, 0.18)",
"--success": "#8cf0ff",
"--success-soft": "rgba(140, 240, 255, 0.2)",
"--background-base": "#7a0b39",
"--background-glow-1": "rgba(255, 119, 200, 0.42)",
"--background-glow-2": "rgba(255, 214, 109, 0.28)",
"--background-glow-3": "rgba(140, 240, 255, 0.18)",
"--rim-light": "rgba(255, 255, 255, 0.92)",
"--candy-glow": "rgba(255, 119, 200, 0.48)",
"--ultimate-effect-alpha": "0.96",
"--custom-background-overlay": "transparent",
"--code-surface": "#4b092a",
"--code-ink": "#fff4fb",
"--code-muted": "#ffc6e4"
}
}
];
const themePaletteVariableNames = Array.from(new Set(themePalettes.flatMap((palette) => Object.keys(palette.variables))));
export const defaultThemePaletteId: ThemePaletteId = "mecha-black";
export const defaultThemeBackgroundId: ThemeBackgroundId = "mecha-grid";
export const themeBackgroundPresets: ThemeBackgroundPreset[] = [
{
id: "mecha-grid",
label: "机甲格纳库",
summary: "黑钢格栅、雷达圆环、青蓝扫描光",
preview:
"radial-gradient(circle at 76% 18%, rgba(72, 230, 255, 0.86), transparent 18%), repeating-linear-gradient(90deg, rgba(72, 230, 255, 0.34) 0 1px, transparent 1px 18px), linear-gradient(135deg, #05090f, #111c28)",
variables: {
"--workspace-background-pattern-image":
"radial-gradient(circle at 76% 18%, transparent 0 68px, rgba(72, 230, 255, 0.3) 69px 71px, transparent 72px), radial-gradient(circle at 76% 18%, transparent 0 34px, rgba(255, 184, 77, 0.24) 35px 36px, transparent 37px), conic-gradient(from 0deg at 76% 18%, transparent 0 8%, rgba(72, 230, 255, 0.24) 8% 9%, transparent 9% 28%, rgba(72, 230, 255, 0.18) 28% 29%, transparent 29% 100%), linear-gradient(90deg, rgba(72, 230, 255, 0.08) 1px, transparent 1px), linear-gradient(180deg, rgba(72, 230, 255, 0.07) 1px, transparent 1px), linear-gradient(135deg, transparent 0 48%, rgba(255, 184, 77, 0.1) 49% 50%, transparent 51% 100%), radial-gradient(circle at 20% 78%, rgba(255, 77, 109, 0.12), transparent 28%)",
"--workspace-background-pattern-size": "auto, auto, auto, 42px 42px, 42px 42px, 180px 180px, auto",
"--workspace-background-pattern-position": "center, center, center, 0 0, 0 0, 0 0, center",
"--workspace-background-pattern-repeat": "no-repeat, no-repeat, no-repeat, repeat, repeat, repeat, no-repeat",
"--workspace-background-pattern-opacity": "0.92",
"--workspace-background-pattern-filter": "saturate(1.18) contrast(1.08)"
}
},
{
id: "magic-stage",
label: "粉月魔法阵",
summary: "粉色舞台、星星边框、底部大型魔法阵",
preview:
"radial-gradient(circle at 86% 86%, transparent 0 28%, rgba(255, 255, 255, 0.72) 29% 30%, transparent 31%), radial-gradient(circle at 25% 22%, #ff77c8, transparent 34%), linear-gradient(180deg, #f08bb4, #8b0a40)",
variables: {
"--workspace-background-pattern-image":
"radial-gradient(circle, rgba(255, 255, 255, 0.9) 0 1.4px, transparent 2.2px), radial-gradient(circle, rgba(255, 221, 117, 0.86) 0 1.8px, transparent 2.8px), radial-gradient(circle at 88% 84%, transparent 0 112px, rgba(255, 255, 255, 0.28) 113px 115px, transparent 116px), radial-gradient(circle at 88% 84%, transparent 0 72px, rgba(255, 119, 200, 0.28) 73px 75px, transparent 76px), conic-gradient(from 30deg at 88% 84%, transparent 0 8%, rgba(255, 221, 117, 0.18) 8% 9%, transparent 9% 24%, rgba(255, 255, 255, 0.18) 24% 25%, transparent 25% 100%), linear-gradient(180deg, rgba(255, 203, 226, 0.26), rgba(122, 11, 57, 0.12)), radial-gradient(circle at 18% 20%, rgba(255, 143, 210, 0.28), transparent 28%)",
"--workspace-background-pattern-size": "76px 76px, 118px 118px, auto, auto, auto, auto, auto",
"--workspace-background-pattern-position": "12px 18px, 42px 58px, center, center, center, center, center",
"--workspace-background-pattern-repeat": "repeat, repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat",
"--workspace-background-pattern-opacity": "0.96",
"--workspace-background-pattern-filter": "saturate(1.24)"
}
}
];
export function getThemePalette(id: string | null | undefined): ThemePalette {
return themePalettes.find((palette) => palette.id === id) ?? themePalettes[0];
}
export function getThemeBackgroundPreset(id: string | null | undefined): ThemeBackgroundPreset {
return themeBackgroundPresets.find((preset) => preset.id === id) ?? themeBackgroundPresets[0];
}
export function loadThemeState(): WorkspaceThemeState {
if (typeof window === "undefined") {
return { paletteId: defaultThemePaletteId, backgroundPresetId: defaultThemeBackgroundId, backgroundImage: null };
}
try {
return {
paletteId: getThemePalette(window.localStorage.getItem(paletteStorageKey)).id,
backgroundPresetId: getThemeBackgroundPreset(window.localStorage.getItem(backgroundPresetStorageKey)).id,
backgroundImage: window.localStorage.getItem(backgroundStorageKey)
};
} catch {
return { paletteId: defaultThemePaletteId, backgroundPresetId: defaultThemeBackgroundId, backgroundImage: null };
}
}
export function persistThemePalette(paletteId: ThemePaletteId): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(paletteStorageKey, paletteId);
} catch {
// storage may be unavailable; the palette stays session-only
}
}
export function persistThemeBackgroundPreset(backgroundPresetId: ThemeBackgroundId): void {
if (typeof window === "undefined") {
return;
}
try {
window.localStorage.setItem(backgroundPresetStorageKey, backgroundPresetId);
} catch {
// storage may be unavailable; the preset stays session-only
}
}
export function persistBackgroundImage(dataUrl: string | null): void {
if (typeof window === "undefined") {
return;
}
try {
if (dataUrl) {
window.localStorage.setItem(backgroundStorageKey, dataUrl);
} else {
window.localStorage.removeItem(backgroundStorageKey);
}
} catch {
// storage may be unavailable (private mode); the background stays session-only
}
}
export function applyThemePalette(paletteId: ThemePaletteId): void {
if (typeof document === "undefined") {
return;
}
const palette = getThemePalette(paletteId);
for (const name of themePaletteVariableNames) {
document.documentElement.style.removeProperty(name);
}
for (const [name, value] of Object.entries(palette.variables)) {
document.documentElement.style.setProperty(name, value);
}
document.documentElement.dataset.themePalette = palette.id;
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent<ThemePaletteChangeDetail>(themePaletteChangeEvent, { detail: { paletteId: palette.id } }));
}
}
export function applyThemeBackgroundPreset(backgroundPresetId: ThemeBackgroundId): void {
if (typeof document === "undefined") {
return;
}
const preset = getThemeBackgroundPreset(backgroundPresetId);
for (const [name, value] of Object.entries(preset.variables)) {
document.documentElement.style.setProperty(name, value);
}
document.documentElement.dataset.themeBackground = preset.id;
}
export function applyBackgroundImage(dataUrl: string | null): void {
if (typeof document === "undefined") {
return;
}
if (dataUrl) {
document.documentElement.style.setProperty("--workspace-background-image", `url(${JSON.stringify(dataUrl)})`);
document.documentElement.dataset.customBackground = "true";
} else {
document.documentElement.style.removeProperty("--workspace-background-image");
delete document.documentElement.dataset.customBackground;
}
}
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": [
"api",
"app",
"components",
"contracts",
"pages",
"routes",
"schemas",
"stores",
"theme",
"utils",
"vite.config.ts"
]
}
+3
View File
@@ -0,0 +1,3 @@
export function cx(...tokens: Array<string | false | null | undefined>): string {
return tokens.filter(Boolean).join(" ");
}
+56
View File
@@ -0,0 +1,56 @@
import type { ConfigDiffView, DiffLine } from "../contracts/workspace";
export function computeLineDiff(previous: string, next: string): DiffLine[] {
const previousLines = previous.split("\n");
const nextLines = next.split("\n");
const m = previousLines.length;
const n = nextLines.length;
// classic LCS table; config files are small enough for O(m*n)
const lcs: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
for (let i = m - 1; i >= 0; i -= 1) {
for (let j = n - 1; j >= 0; j -= 1) {
lcs[i][j] = previousLines[i] === nextLines[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
const lines: DiffLine[] = [];
let i = 0;
let j = 0;
while (i < m && j < n) {
if (previousLines[i] === nextLines[j]) {
lines.push({ kind: "same", text: previousLines[i] });
i += 1;
j += 1;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
lines.push({ kind: "removed", text: previousLines[i] });
i += 1;
} else {
lines.push({ kind: "added", text: nextLines[j] });
j += 1;
}
}
while (i < m) {
lines.push({ kind: "removed", text: previousLines[i] });
i += 1;
}
while (j < n) {
lines.push({ kind: "added", text: nextLines[j] });
j += 1;
}
return lines;
}
export function buildConfigDiff(serverInstanceId: string, previous: string, next: string): ConfigDiffView {
const lines = computeLineDiff(previous, next);
const added = lines.filter((line) => line.kind === "added").length;
const removed = lines.filter((line) => line.kind === "removed").length;
return {
serverInstanceId,
summary: `+${added} / -${removed} 行变更`,
lines,
nextContent: next
};
}
export function diffHasChanges(diff: ConfigDiffView): boolean {
return diff.lines.some((line) => line.kind !== "same");
}
+171
View File
@@ -0,0 +1,171 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginBridgeManifestContract } from "../contracts/pluginBridge";
import {
createPluginBridgeDispatcher,
createPluginBridgeHostContext,
filterAllowedPermissions,
parsePluginArtifactReference,
validateBridgeExecutionRequest
} from "./pluginBridgeHost";
const plugin: PluginBridgeManifestContract = {
id: "game.example",
declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "ai.invoke"],
pages: [
{
key: "logs",
title: "Logs",
path: "/logs",
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"]
}
],
aiPurposes: ["logs.diagnose"]
};
describe("plugin bridge host utilities", () => {
it("filters page permissions against manifest permissions", () => {
expect(filterAllowedPermissions(plugin.declaredPermissions, ["server.logs.read", "server.artifacts.read"])).toEqual(["server.logs.read", "server.artifacts.read"]);
});
it("creates safe host context without secret-bearing fields", () => {
const context = createPluginBridgeHostContext({
plugin,
routeKey: "logs",
serverInstanceId: "server-1",
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
});
expect(context).toEqual({
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" },
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"],
aiPurposes: ["logs.diagnose"]
});
expect(context).not.toHaveProperty("apiKey");
expect(context).not.toHaveProperty("runCredential");
expect(context).not.toHaveProperty("hostPath");
});
it("builds bridge execution requests through the platform client", async () => {
const context = createPluginBridgeHostContext({
plugin,
routeKey: "logs",
serverInstanceId: "server-1",
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
});
const client = {
executePluginBridge: vi.fn(async () => ({
requestId: "req-1",
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
action: "logs.query",
status: "ok",
result: { entryCount: "0" }
}))
};
const dispatch = createPluginBridgeDispatcher(context, client);
await expect(dispatch({ requestId: "req-1", action: "logs.query", payload: { logStreamId: "log-1" } })).resolves.toMatchObject({
status: "ok",
result: { entryCount: "0" }
});
expect(client.executePluginBridge).toHaveBeenCalledWith({
requestId: "req-1",
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
action: "logs.query",
aiPurpose: undefined,
payload: { logStreamId: "log-1" }
});
});
it("rejects denied, unsafe, and cancelled bridge execution locally", async () => {
const context = createPluginBridgeHostContext({
plugin,
routeKey: "logs",
serverInstanceId: "server-1",
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
});
expect(validateBridgeExecutionRequest(context, { requestId: "req-denied", action: "server.instances.read" })).toMatchObject({ code: "unsupported_action" });
expect(
validateBridgeExecutionRequest(context, { requestId: "req-unsafe", action: "files.request", payload: { key: "/Users/tasia/.ssh/id_rsa" } })
).toMatchObject({ code: "unsafe_payload" });
const client = { executePluginBridge: vi.fn() };
const controller = new AbortController();
controller.abort();
const dispatch = createPluginBridgeDispatcher(context, client);
await expect(dispatch({ requestId: "req-cancel", action: "logs.query", payload: { logStreamId: "log-1" } }, controller.signal)).resolves.toMatchObject({
status: "cancelled",
error: { code: "cancelled" }
});
expect(client.executePluginBridge).not.toHaveBeenCalled();
});
it("dispatches mediated AI requests without provider configuration", async () => {
const context = createPluginBridgeHostContext({
plugin,
routeKey: "logs",
serverInstanceId: "server-1",
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }
});
expect(validateBridgeExecutionRequest(context, { requestId: "ai-denied", action: "ai.invoke", aiPurpose: "config.suggest" })).toMatchObject({ code: "ai_purpose_denied" });
const client = {
executePluginBridge: vi.fn(async () => ({
requestId: "ai-1",
pluginId: "game.example",
routeKey: "logs",
serverInstanceId: "server-1",
action: "ai.invoke",
status: "ok",
result: { recommendation: "Mock AI recommendation", mocked: "true" }
}))
};
const dispatch = createPluginBridgeDispatcher(context, client);
const response = await dispatch({ requestId: "ai-1", action: "ai.invoke", aiPurpose: "logs.diagnose", payload: { prompt: "Summarize logs" } });
expect(response).toMatchObject({ status: "ok", result: { mocked: "true" } });
const serialized = JSON.stringify(response);
expect(serialized).not.toContain("apiKeyRef");
expect(serialized).not.toContain("rawApiKey");
expect(serialized).not.toContain("baseUrl");
});
it("parses safe artifact bridge references and rejects backend internals", () => {
expect(
parsePluginArtifactReference({
artifactId: "artifact-1",
filename: "artifact-1.bin",
contentType: "application/octet-stream",
sizeBytes: "128",
checksum: "sha256:abc",
downloadUrl: "/api/v1/artifacts/artifact-1/content",
expiresAt: "2026-07-03T00:15:00Z",
rangeSupported: "true",
chunkSizeBytes: "1048576",
storageBehavior: "platform-memory-transfer-session"
})
).toMatchObject({ artifactId: "artifact-1", downloadUrl: "/api/v1/artifacts/artifact-1/content", rangeSupported: true });
expect(
parsePluginArtifactReference({
artifactId: "artifact-1",
filename: "/Users/tasia/artifact.bin",
contentType: "application/octet-stream",
sizeBytes: "128",
checksum: "sha256:abc",
downloadUrl: "storage://bucket/artifact-1",
expiresAt: "2026-07-03T00:15:00Z",
rangeSupported: "true",
chunkSizeBytes: "1048576"
})
).toBeNull();
});
});
+209
View File
@@ -0,0 +1,209 @@
import type {
PluginBridgeAction,
PluginBridgeExecuteEnvelope,
PluginBridgeExecutionResult,
PluginBridgeHostContext,
PluginBridgeManifestContract,
PluginBridgeThemeTokens,
PluginPermission
} from "../contracts/pluginBridge";
import type { PlatformApiClient } from "../api/client";
export function createPluginBridgeHostContext(input: {
plugin: PluginBridgeManifestContract;
routeKey: string;
serverInstanceId?: string;
themeTokens: PluginBridgeThemeTokens;
}): PluginBridgeHostContext {
const page = input.plugin.pages.find((candidate) => candidate.key === input.routeKey);
const permissions = filterAllowedPermissions(input.plugin.declaredPermissions, page?.permissions);
const bridgeActions =
page?.bridgeActions === undefined
? [...input.plugin.bridgeActions]
: page.bridgeActions.filter((action) => input.plugin.bridgeActions.includes(action));
return {
pluginId: input.plugin.id,
routeKey: input.routeKey,
serverInstanceId: input.serverInstanceId,
themeTokens: input.themeTokens,
permissions,
bridgeActions,
aiPurposes: [...(input.plugin.aiPurposes ?? [])]
};
}
export function filterAllowedPermissions(
declaredPermissions: PluginPermission[],
pagePermissions: PluginPermission[] | undefined
): PluginPermission[] {
if (pagePermissions === undefined) {
return [...declaredPermissions];
}
return pagePermissions.filter((permission) => declaredPermissions.includes(permission));
}
export function createPluginBridgeDispatcher(context: PluginBridgeHostContext, client: Pick<PlatformApiClient, "executePluginBridge">) {
return async function dispatchBridgeRequest(envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal): Promise<PluginBridgeExecutionResult> {
const localError = validateBridgeExecutionRequest(context, envelope);
if (localError) {
return {
requestId: envelope.requestId,
action: envelope.action,
status: "denied",
error: localError
};
}
if (signal?.aborted) {
return {
requestId: envelope.requestId,
action: envelope.action,
status: "cancelled",
error: { code: "cancelled", message: "bridge request was cancelled" }
};
}
try {
const response = await client.executePluginBridge({
requestId: envelope.requestId,
pluginId: context.pluginId,
routeKey: context.routeKey,
serverInstanceId: context.serverInstanceId,
action: envelope.action,
aiPurpose: envelope.aiPurpose,
payload: envelope.payload
});
return {
requestId: response.requestId,
action: response.action as PluginBridgeAction,
status: response.status,
result: response.result,
error: response.error
};
} catch (error) {
return {
requestId: envelope.requestId,
action: envelope.action,
status: "error",
error: { code: "platform_error", message: error instanceof Error ? error.message : "bridge execution failed" }
};
}
};
}
export function validateBridgeExecutionRequest(context: PluginBridgeHostContext, envelope: PluginBridgeExecuteEnvelope): PluginBridgeExecutionResult["error"] | null {
if (!envelope.requestId.trim()) {
return { code: "validation", message: "requestId is required" };
}
if (!context.bridgeActions.includes(envelope.action)) {
return { code: "unsupported_action", message: "bridge action is not allowed for this page" };
}
for (const permission of requiredPermissions(envelope.action)) {
if (!context.permissions.includes(permission)) {
return { code: "permission_denied", message: "bridge action is missing required permission" };
}
}
if (envelope.action === "ai.invoke" && (!envelope.aiPurpose || !context.aiPurposes.includes(envelope.aiPurpose))) {
return { code: "ai_purpose_denied", message: "AI purpose is not allowed for this plugin" };
}
const payload = envelope.payload ?? {};
if (Object.keys(payload).length > 16) {
return { code: "payload_too_large", message: "bridge payload has too many keys" };
}
const encodedSize = Object.entries(payload).reduce((sum, [key, value]) => sum + key.length + value.length, 0);
if (encodedSize > 4096) {
return { code: "payload_too_large", message: "bridge payload is too large" };
}
for (const [key, value] of Object.entries(payload)) {
if (!key.trim() || key !== key.trim()) {
return { code: "validation", message: "bridge payload key is invalid" };
}
if (containsUnsafeBridgeContent(key) || containsUnsafeBridgeContent(value)) {
return { code: "unsafe_payload", message: "bridge payload contains unsafe content" };
}
}
return null;
}
export interface PluginArtifactReference {
artifactId: string;
filename: string;
contentType: string;
sizeBytes: number;
checksum: string;
downloadUrl: string;
expiresAt: string;
rangeSupported: boolean;
chunkSizeBytes: number;
storageBehavior?: string;
}
export function parsePluginArtifactReference(result: Record<string, string> | undefined): PluginArtifactReference | null {
if (!result) {
return null;
}
const sizeBytes = Number(result.sizeBytes);
const chunkSizeBytes = Number(result.chunkSizeBytes);
const reference: PluginArtifactReference = {
artifactId: result.artifactId ?? "",
filename: result.filename ?? "artifact.bin",
contentType: result.contentType ?? "application/octet-stream",
sizeBytes,
checksum: result.checksum ?? "",
downloadUrl: result.downloadUrl ?? "",
expiresAt: result.expiresAt ?? "",
rangeSupported: result.rangeSupported === "true",
chunkSizeBytes,
storageBehavior: result.storageBehavior
};
if (!reference.artifactId || !Number.isFinite(sizeBytes) || sizeBytes <= 0 || !Number.isFinite(chunkSizeBytes) || chunkSizeBytes <= 0) {
return null;
}
if (!reference.downloadUrl.startsWith(`/api/v1/artifacts/${encodeURIComponent(reference.artifactId)}/content`)) {
return null;
}
for (const value of Object.values(reference)) {
if (typeof value === "string" && containsUnsafeBridgeContent(value)) {
return null;
}
}
return reference;
}
function requiredPermissions(action: PluginBridgeAction): PluginPermission[] {
switch (action) {
case "server.instances.read":
return ["server.read"];
case "jobs.dispatch":
return ["server.lifecycle"];
case "logs.query":
return ["server.logs.read"];
case "artifacts.open":
return ["server.artifacts.read"];
case "files.request":
return ["server.files.read"];
case "ai.invoke":
return ["ai.invoke"];
default:
return [];
}
}
function containsUnsafeBridgeContent(value: string): boolean {
const lowered = value.trim().toLowerCase();
if (!lowered) {
return false;
}
return (
lowered.includes("/users/") ||
lowered.includes("/private/") ||
lowered.includes("unix://") ||
lowered.includes("tcp://") ||
lowered.includes("bearer ") ||
lowered.startsWith("sk-") ||
lowered.includes("password=") ||
lowered.includes("api_key=") ||
lowered.includes("apikey=") ||
lowered.includes("host path") ||
lowered.includes("rawapikey")
);
}
+26
View File
@@ -0,0 +1,26 @@
import { loadEnv } from "vite";
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, ".", "");
const platformProxyTarget = env.PLATFORM_API_PROXY || "http://127.0.0.1:8080";
return {
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
proxy: {
"/api/v1": platformProxyTarget,
"/healthz": platformProxyTarget
}
},
preview: {
host: "127.0.0.1",
port: 4173
},
test: {
environment: "node"
}
};
});