first commit
This commit is contained in:
@@ -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 ");
|
||||
});
|
||||
});
|
||||
@@ -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 "错误";
|
||||
}
|
||||
}
|
||||
@@ -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\"");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("本地演示数据仅用于前端开发");
|
||||
});
|
||||
});
|
||||
@@ -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" ? "停用" : "启用";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
@@ -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(" · ") : "暂无联系方式";
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user