功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
+23 -1
View File
@@ -2,13 +2,14 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AiProvidersPage } from "./AiProvidersPage";
import aiProvidersPageSource from "./AiProvidersPage.tsx?raw";
import type { AiProviderResponse } from "../api/types";
const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
baseUrlConfigured: true,
apiKeyConfigured: true,
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
@@ -38,6 +39,16 @@ describe("AiProvidersPage", () => {
expect(html).not.toContain("OpenAI Relay");
});
it("renders provider API failure with scoped retry instead of a browser reload", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ listState: "error", listError: "backend unavailable", source: "error" }} />);
expect(html).toContain("AI 提供商加载失败");
expect(html).toContain("backend unavailable");
expect(html).toContain("重试");
expect(aiProvidersPageSource).toContain("refreshProviders");
expect(aiProvidersPageSource).not.toContain("window.location.reload");
});
it("renders local-development fixtures with an explicit status label", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "local-development" }} />);
@@ -65,6 +76,7 @@ describe("AiProvidersPage", () => {
expect(html).toContain("secret://providers/...");
expect(html).not.toContain('name="id"');
expect(html).not.toContain("api.example.test");
expect(html).not.toContain("relay.example.test");
});
it("does not render raw key field names", () => {
@@ -77,4 +89,14 @@ describe("AiProvidersPage", () => {
expect(html).not.toContain("api_key=");
expect(html).not.toContain("Bearer ");
});
it("keeps provider state actions confirmed and provider-scoped busy", () => {
expect(aiProvidersPageSource).toContain("confirmStatus");
expect(aiProvidersPageSource).toContain("busyProviderIds.has(provider.id)");
expect(aiProvidersPageSource).toContain("确认提供商状态变更");
expect(aiProvidersPageSource).toContain("只读模式");
expect(aiProvidersPageSource).toContain("operations?.begin");
expect(aiProvidersPageSource).toContain("operations?.succeed");
expect(aiProvidersPageSource).toContain("operations?.fail");
});
});
+140 -67
View File
@@ -1,11 +1,13 @@
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { AIConfigDiffReviewPanel } from "../components/AIConfigDiffReviewPanel";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { isPlatformAdmin } from "../contracts/workspace";
import {
aiProviderKindDefaults,
applyAiProviderKindDefaults,
@@ -44,7 +46,7 @@ const providerPresets: ProviderPreset[] = [
{ kind: "custom", label: "自定义" }
];
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
export function AiProvidersPage({ initialState, session, operations }: AiProvidersPageProps = {}) {
const initialSelectedProvider = initialState?.selectedId ? initialState.providers?.find((provider) => provider.id === initialState.selectedId) : undefined;
const [providers, setProviders] = useState<AiProviderResponse[]>(initialState?.providers ?? []);
const [listState, setListState] = useState<AiProviderListState>(initialState?.listState ?? "loading");
@@ -56,59 +58,59 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const [viewState, setViewState] = useState<AiProviderViewState>(initialState?.source ?? "local-development");
const [action, setAction] = useState<AiProviderActionState | null>(initialState?.action ?? null);
const [confirmRetire, setConfirmRetire] = useState<AiProviderResponse | null>(null);
const [confirmStatus, setConfirmStatus] = useState<AiProviderResponse | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [busyProviderIds, setBusyProviderIds] = useState<Set<string>>(() => new Set());
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
useEffect(() => {
let cancelled = false;
const refreshProviders = useCallback(async () => {
if (initialState?.providers) {
setListState(initialState.listState ?? "ready");
setViewState(initialState.source ?? "local-development");
return () => {
cancelled = true;
};
return;
}
platformApiClient
.listAiProviders()
.then((response) => {
if (cancelled) {
return;
}
const nextSelectedId =
initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId)
? initialState.selectedId
: "";
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
setProviders(response.items);
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
});
setListState("loading");
setListError("");
try {
const response = await platformApiClient.listAiProviders();
const nextSelectedId = initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId) ? initialState.selectedId : "";
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
setProviders(response.items);
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
} catch (error: unknown) {
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
}
}, [initialState]);
return () => {
cancelled = true;
};
}, [initialState?.listState, initialState?.providers, initialState?.selectedId, initialState?.source]);
useEffect(() => {
void refreshProviders();
}, [refreshProviders]);
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
const canManage = Boolean(session && isPlatformAdmin(session));
function setProviderBusy(providerId: string, busy: boolean) {
setBusyProviderIds((current) => {
const next = new Set(current);
if (busy) next.add(providerId);
else next.delete(providerId);
return next;
});
}
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
setForm((current) => ({ ...current, [key]: value }));
@@ -120,6 +122,8 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
} else if (key === "apiKeyRef") {
setForm((current) => ({ ...current, apiKeyRef: event.target.value, apiKeyConfigured: current.apiKeyConfigured || Boolean(event.target.value.trim()) }));
} else if (key === "baseUrl") {
setForm((current) => ({ ...current, baseUrl: event.target.value, baseUrlConfigured: current.baseUrlConfigured || Boolean(event.target.value.trim()) }));
} else {
updateForm(key, event.target.value);
}
@@ -127,6 +131,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function selectProvider(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setSelectedId(provider.id);
setForm(aiProviderToForm(provider));
setFormMode("edit");
@@ -136,6 +143,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function startCreate() {
if (!canManage) {
return;
}
setSelectedId("");
setForm(emptyAiProviderForm());
setFormMode("create");
@@ -165,7 +175,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
if (!completed.name.trim()) {
missing.push("名称");
}
if (!completed.baseUrl.trim()) {
if (!completed.baseUrl.trim() && !completed.baseUrlConfigured) {
missing.push("Base URL");
}
if (completed.relayMode !== "local" && !completed.apiKeyConfigured && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
@@ -186,10 +196,15 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!canManage) {
return;
}
setViewState("saving");
const completed = completeAiProviderForm(form);
const providerId = generatedAiProviderId(completed);
const existing = providers.some((provider) => provider.id === providerId);
const intent = existing ? "更新 AI 提供商" : "创建 AI 提供商";
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: providerId, requester: session?.displayName });
try {
const saved = existing
@@ -202,6 +217,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
setListState("ready");
setListError("");
setAction({ providerId: saved.id, label: "save", success: true, message: "已保存" });
if (operationId) operations?.succeed(operationId, `${saved.name} 已保存`);
setFormCheck(null);
setFormMode(null);
} catch (error) {
@@ -209,55 +225,98 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const message = errorMessage(error, "保存失败");
setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message });
if (operationId) operations?.fail(operationId, message, operationId);
}
}
async function handleStatus(provider: AiProviderResponse) {
async function handleStatus(provider: AiProviderResponse): Promise<boolean> {
if (!canManage) {
return false;
}
setExpandedProviderId(null);
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
const intent = nextStatus === "active" ? "启用 AI 提供商" : "停用 AI 提供商";
const operationId = operations?.begin({ intent, targetKind: "llm", targetId: provider.id, requester: session?.displayName });
setProviderBusy(provider.id, true);
try {
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
if (operationId) operations?.succeed(operationId, `${updated.name}${updated.status === "disabled" ? "停用" : "启用"}`);
return true;
} catch (error) {
setAction({ providerId: provider.id, label: "status", success: false, message: errorMessage(error, "状态更新失败") });
const message = errorMessage(error, "状态更新失败");
setAction({ providerId: provider.id, label: "status", success: false, message });
if (operationId) operations?.fail(operationId, message, operationId);
return false;
} finally {
setProviderBusy(provider.id, false);
}
}
async function handleRetire(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setConfirmBusy(true);
const operationId = operations?.begin({ intent: "退役 AI 提供商", targetKind: "llm", targetId: provider.id, requester: session?.displayName });
setProviderBusy(provider.id, true);
try {
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
upsertProvider(retired);
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
if (operationId) operations?.succeed(operationId, `${retired.name} 已退役`);
setConfirmRetire(null);
} catch (error) {
setAction({ providerId: provider.id, label: "retire", success: false, message: errorMessage(error, "退役失败,可能仍被引用") });
const message = errorMessage(error, "退役失败,可能仍被引用");
setAction({ providerId: provider.id, label: "retire", success: false, message });
if (operationId) operations?.fail(operationId, message, operationId);
} finally {
setConfirmBusy(false);
setProviderBusy(provider.id, false);
}
}
async function handleTest(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setExpandedProviderId(null);
setProviderBusy(provider.id, true);
try {
const result = await platformApiClient.testAiProvider(provider.id);
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
} catch (error) {
setAction({ providerId: provider.id, label: "test", success: false, message: errorMessage(error, "测试失败") });
} finally {
setProviderBusy(provider.id, false);
}
}
async function handleModels(provider: AiProviderResponse) {
if (!canManage) {
return;
}
setExpandedProviderId(null);
setProviderBusy(provider.id, true);
try {
const result = await platformApiClient.listAiProviderModels(provider.id);
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length} 个模型` });
} catch (error) {
setAction({ providerId: provider.id, label: "models", success: false, message: errorMessage(error, "模型刷新失败") });
} finally {
setProviderBusy(provider.id, false);
}
}
function requestStatusChange(provider: AiProviderResponse) {
if (!canManage || busyProviderIds.has(provider.id)) {
return;
}
setExpandedProviderId(null);
setConfirmStatus(provider);
}
async function handleSavedFormTest() {
const providerId = form.id.trim() || selectedId;
if (formMode !== "edit" || !providerId) {
@@ -319,21 +378,21 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
AI
</h1>
</div>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewStateLabel(viewState)}</span>
<span className={cx("page-status", viewState === "api" && canManage && "page-status-ready")}>{!canManage ? (viewState === "local-development" ? "本地开发 / 只读" : "只读模式") : viewStateLabel(viewState)}</span>
</header>
<dl className="page-summary-strip ai-provider-summary" aria-label="AI 提供商快速状态">
<div className="page-summary-chip summary-tone-neutral">
<dt></dt>
<dd>{metrics.total}</dd>
<dd>{listState === "ready" ? metrics.total : "--"}</dd>
</div>
<div className="page-summary-chip summary-tone-success">
<dt></dt>
<dd>{metrics.active}</dd>
<dd>{listState === "ready" ? metrics.active : "--"}</dd>
</div>
<div className="page-summary-chip summary-tone-warning">
<dt></dt>
<dd>{metrics.models}</dd>
<dd>{listState === "ready" ? metrics.models : "--"}</dd>
</div>
</dl>
@@ -343,15 +402,16 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
{filterLabel(item)}
</button>
))}
<button type="button" className="icon-command" title="新增提供商" onClick={startCreate}>
<button type="button" className="icon-command" disabled={!canManage} title={canManage ? "新增提供商" : "当前账号没有管理权限"} onClick={startCreate}>
<Candy size={16} />
<span></span>
</button>
</div>
{!canManage && <ResultBadge status="pending" label="当前账号为只读模式,保存、状态和测试动作需要平台管理员权限" />}
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
{listState === "loading" && <LoadingState label="正在加载 AI 提供商…" />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => window.location.reload()} />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => void refreshProviders()} />}
{listState === "ready" && providers.length === 0 && (
<EmptyState icon={<FlaskConical size={26} />} title="暂无 AI 提供商" description="平台还没有返回任何提供商。创建第一个平台托管的 AI 提供商后,这里会显示 API 数据。" actionLabel="新增提供商" onAction={startCreate} />
@@ -375,7 +435,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
{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)}>
<button type="button" className="table-link-button" disabled={!canManage} onClick={() => selectProvider(provider)}>
{provider.name}
</button>
<span className="provider-id">{provider.id}</span>
@@ -391,15 +451,15 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</td>
<td className="provider-actions-cell">
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<FlaskConical size={15} />
<span></span>
<span>{busyProviderIds.has(provider.id) ? "处理中" : "测试"}</span>
</button>
<button type="button" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<Sparkles size={15} />
<span></span>
<span>{busyProviderIds.has(provider.id) ? "处理中" : "模型"}</span>
</button>
<button type="button" aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<button type="button" disabled={!canManage || busyProviderIds.has(provider.id)} aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<WandSparkles size={15} />
<span></span>
</button>
@@ -408,6 +468,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
className={cx(expandedProviderId === provider.id && "row-action-button-active")}
aria-expanded={expandedProviderId === provider.id}
aria-label={`${provider.name} 更多操作`}
disabled={!canManage || busyProviderIds.has(provider.id)}
onClick={() => setExpandedProviderId((current) => (current === provider.id ? null : provider.id))}
>
<MoreHorizontal size={15} />
@@ -416,7 +477,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
{expandedProviderId === provider.id && (
<div className="inline-action-menu" role="menu" aria-label={`${provider.name} 更多操作`}>
<button type="button" role="menuitem" onClick={() => void handleStatus(provider)}>
<button type="button" role="menuitem" onClick={() => requestStatusChange(provider)}>
<Power size={14} />
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
</button>
@@ -426,7 +487,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
className="danger-command"
onClick={() => {
setExpandedProviderId(null);
setConfirmRetire(provider);
if (canManage) setConfirmRetire(provider);
}}
>
<UserRoundMinus size={14} />
@@ -443,7 +504,9 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
)}
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={closeForm}>
<AIConfigDiffReviewPanel />
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={() => { if (viewState !== "saving") closeForm(); }}>
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
<ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设">
@@ -496,7 +559,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
<label>
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} placeholder={form.baseUrlConfigured ? "已配置;留空保持不变" : formDefaults.baseUrl} />
<small className="field-help">{formDefaults.advancedNote}</small>
</label>
<label>
@@ -533,12 +596,12 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
</div>
{formCheck && <ResultBadge status={formCheck.status} label={formCheck.message} />}
<div className="confirm-actions">
<button type="button" onClick={closeForm}>
<button type="button" disabled={viewState === "saving"} onClick={closeForm}>
</button>
<button type="submit" className="confirm-primary">
<button type="submit" className="confirm-primary" disabled={!canManage || viewState === "saving"}>
<WandSparkles size={16} />
<span></span>
<span>{viewState === "saving" ? "保存中…" : "保存配置"}</span>
</button>
</div>
</form>
@@ -551,9 +614,19 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
confirmLabel="确认退役"
danger
busy={confirmBusy}
onCancel={() => setConfirmRetire(null)}
onCancel={() => { if (!confirmBusy) setConfirmRetire(null); }}
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
/>
<ConfirmDialog
open={confirmStatus !== null}
title="确认提供商状态变更"
description={`确认将 ${confirmStatus?.name ?? "该提供商"} ${confirmStatus?.status === "active" ? "停用" : "启用"}?平台会返回持久状态,失败时保持当前状态。`}
confirmLabel={confirmStatus?.status === "active" ? "确认停用" : "确认启用"}
danger={confirmStatus?.status === "active"}
busy={confirmStatus ? busyProviderIds.has(confirmStatus.id) : false}
onCancel={() => { if (!confirmStatus || !busyProviderIds.has(confirmStatus.id)) setConfirmStatus(null); }}
onConfirm={() => { if (confirmStatus) { void handleStatus(confirmStatus).then((succeeded) => { if (succeeded) setConfirmStatus(null); }); } }}
/>
</section>
);
}
+82 -1
View File
@@ -69,6 +69,75 @@ describe("first-party console pages", () => {
expect(html).not.toContain("sk-");
});
it("keeps unavailable overview modules visible instead of claiming an empty healthy state", () => {
const html = renderToStaticMarkup(
<HomePage
{...pageProps()}
initialState={{
core: {
status: "ready",
refreshedAt: "2026-07-18T10:00:00Z",
data: {
instances: [],
endpoints: [],
jobs: [
{
id: "job-failed-1",
serverInstanceId: "server-1",
runEndpointId: "endpoint-1",
capability: "server.lifecycle.start",
idempotencyKey: "idem-1",
state: "failed",
progress: { percent: 42 },
retryPolicy: { maxAttempts: 3, initialBackoffSeconds: 1, maxBackoffSeconds: 10 },
attempt: 3,
reconcileCount: 0,
createdAt: "2026-07-18T09:59:00Z",
updatedAt: "2026-07-18T10:00:00Z"
}
]
}
},
metrics: { status: "error", reason: "metrics unavailable", diagnosticId: "metrics-1" },
usage: { status: "error", reason: "usage unavailable", diagnosticId: "usage-1" },
providers: { status: "error", reason: "providers unavailable", diagnosticId: "providers-1" },
signals: { status: "error", reason: "audit unavailable", diagnosticId: "audit-1" }
}}
/>
);
expect(html).toContain("4 个模块不可用");
expect(html).toContain("资源指标不可用");
expect(html).toContain("审计信号不可用");
expect(html).toContain("AI 提供商信号不可用");
expect(html).toContain("server.lifecycle.start");
expect(html).not.toContain("暂无异常信号");
});
it("explains read-only overview access when the session cannot create servers", () => {
const readOnlySession: CurrentUserView = {
...adminUser,
roles: ["serverAdmin"],
capabilities: ["platform.overview.read", "servers.read"]
};
const html = renderToStaticMarkup(
<HomePage
{...pageProps()}
session={readOnlySession}
initialState={{
core: { status: "ready", data: { instances: [], endpoints: [], jobs: [] }, refreshedAt: "2026-07-18T10:00:00Z" },
metrics: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
usage: { status: "error", reason: "unavailable", diagnosticId: "usage" },
providers: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" },
signals: { status: "ready", data: [], refreshedAt: "2026-07-18T10:00:00Z" }
}}
/>
);
expect(html).toContain("没有创建服务器的权限");
expect(html).toContain("前往服务器管理");
});
it("renders the server list workspace with search and status filters", () => {
const html = renderToStaticMarkup(<ServersPage {...pageProps()} />);
@@ -80,7 +149,19 @@ describe("first-party console pages", () => {
expect(html).not.toContain("/Users/");
});
it("keeps server metrics unavailable and runtime commands permission-gated", () => {
expect(serversPageSource).toContain("metricsUnavailable");
expect(serversPageSource).toContain("指标不可用");
expect(serversPageSource).toContain("canManageServers");
expect(serversPageSource).toContain("当前账号没有运行操作权限");
expect(serversPageSource).toContain("failedJobs");
expect(serversPageSource).toContain("refreshMetrics");
expect(serversPageSource).toContain("onClick={() => void refreshMetrics()}");
});
it("submits declared runtime profiles and logical bindings from the create workflow", () => {
expect(serversPageSource).toContain("<ManagementDialog");
expect(serversPageSource).toContain('className="provider-form dialog-form"');
expect(serversPageSource).toContain('name="profileKey"');
expect(serversPageSource).toContain("runtimeBindingFields");
expect(serversPageSource).toContain("updateBinding(field.key");
@@ -136,7 +217,7 @@ describe("first-party console pages", () => {
expect(html).toContain("用户管理");
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("needs approval");
expect(html).toContain("本地开发样例 / 禁止假成功");
expect(html).toContain("本地开发样例 / 仅查看");
});
it("renders maintenance triage entry points", () => {
+240 -144
View File
@@ -1,4 +1,17 @@
import { Activity, AlertTriangle, CakeSlice, Candy, Info, MoonStar, Sparkles } from "lucide-react";
import {
Activity,
AlertTriangle,
Bot,
CakeSlice,
Candy,
CircleGauge,
Info,
MoonStar,
RotateCw,
ServerCog,
Sparkles,
Workflow
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
@@ -12,26 +25,38 @@ import type {
ServerMetricsResponse
} from "../api/types";
import { UsageMeter } from "../components/OperationControls";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import { jobBuckets, moduleFreshnessLabel, summarizeEndpointOperations, type OperationsModuleState } from "../contracts/operationsConsole";
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" });
export interface HomePageInitialState {
core?: OperationsModuleState<OverviewData>;
metrics?: OperationsModuleState<ServerMetricsResponse[]>;
usage?: OperationsModuleState<PlatformResourceUsageResponse>;
providers?: OperationsModuleState<AiProviderResponse[]>;
signals?: OperationsModuleState<AuditEventResponse[]>;
}
interface HomePageProps extends PageComponentProps {
initialState?: HomePageInitialState;
}
export function HomePage({ session, onNavigate, initialState }: HomePageProps) {
const [core, setCore] = useState<OperationsModuleState<OverviewData>>(initialState?.core ?? { status: "loading" });
const [metrics, setMetrics] = useState<OperationsModuleState<ServerMetricsResponse[]>>(initialState?.metrics ?? { status: "loading" });
const [usage, setUsage] = useState<OperationsModuleState<PlatformResourceUsageResponse>>(initialState?.usage ?? { status: "loading" });
const [providers, setProviders] = useState<OperationsModuleState<AiProviderResponse[]>>(initialState?.providers ?? { status: "loading" });
const [signals, setSignals] = useState<OperationsModuleState<AuditEventResponse[]>>(initialState?.signals ?? { status: "loading" });
const refreshCore = useCallback(async () => {
setCore({ status: "loading" });
@@ -41,9 +66,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
platformApiClient.listRunEndpoints(),
platformApiClient.listJobs()
]);
setCore({ status: "ready", data: { instances: instances.items, endpoints: endpoints.items, jobs: jobs.items } });
setCore({ status: "ready", data: { instances: instances.items, endpoints: endpoints.items, jobs: jobs.items }, refreshedAt: refreshedNow() });
} catch (error) {
setCore({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setCore({ status: "error", reason: errorMessage(error, "服务器、节点或任务加载失败"), diagnosticId: "overview-core" });
}
}, []);
@@ -51,9 +76,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setMetrics({ status: "loading" });
try {
const response = await platformApiClient.listServerMetrics();
setMetrics({ status: "ready", data: response.items });
} catch {
setMetrics({ status: "ready", data: [] });
setMetrics({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
} catch (error) {
setMetrics({ status: "error", reason: errorMessage(error, "服务器指标加载失败"), diagnosticId: "overview-server-metrics" });
}
}, []);
@@ -61,9 +86,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setUsage({ status: "loading" });
try {
const response = await platformApiClient.getPlatformResourceUsage();
setUsage({ status: "ready", data: response });
} catch {
setUsage({ status: "error", reason: "平台资源指标接口尚未提供" });
setUsage({ status: "ready", data: response, refreshedAt: refreshedNow() });
} catch (error) {
setUsage({ status: "error", reason: errorMessage(error, "平台资源指标加载失败"), diagnosticId: "overview-platform-usage" });
}
}, []);
@@ -71,9 +96,9 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setProviders({ status: "loading" });
try {
const response = await platformApiClient.listAiProviders();
setProviders({ status: "ready", data: response.items });
setProviders({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
} catch (error) {
setProviders({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setProviders({ status: "error", reason: errorMessage(error, "AI 提供商状态加载失败"), diagnosticId: "overview-ai-providers" });
}
}, []);
@@ -81,19 +106,26 @@ export function HomePage({ onNavigate }: PageComponentProps) {
setSignals({ status: "loading" });
try {
const response = await platformApiClient.listAuditEvents();
setSignals({ status: "ready", data: response.items });
} catch {
setSignals({ status: "ready", data: [] });
setSignals({ status: "ready", data: response.items, refreshedAt: refreshedNow() });
} catch (error) {
setSignals({ status: "error", reason: errorMessage(error, "审计事件加载失败"), diagnosticId: "overview-audit-events" });
}
}, []);
useEffect(() => {
const refreshAll = useCallback(() => {
void refreshCore();
void refreshMetrics();
void refreshUsage();
void refreshProviders();
void refreshSignals();
}, [refreshCore, refreshMetrics, refreshUsage, refreshProviders, refreshSignals]);
}, [refreshCore, refreshMetrics, refreshProviders, refreshSignals, refreshUsage]);
useEffect(() => {
if (initialState) {
return;
}
refreshAll();
}, [initialState, refreshAll]);
const distribution = useMemo<GameTypeDistributionEntry[]>(() => {
if (core.status !== "ready") {
@@ -106,73 +138,19 @@ export function HomePage({ onNavigate }: PageComponentProps) {
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 overviewSignals = useMemo<PlatformOverviewSignal[]>(() => buildOverviewSignals(core, providers, signals), [core, providers, signals]);
const jobs = core.status === "ready" ? jobBuckets(core.data.jobs) : null;
const endpointSummary = core.status === "ready" ? summarizeEndpointOperations(core.data.endpoints) : null;
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 moduleFailureCount = [core, metrics, usage, providers, signals].filter((module) => module.status === "error").length;
const canManageServers = session.capabilities.includes("servers.manage");
const metricAverages = useMemo(() => {
if (usage.status === "ready") {
return { cpu: usage.data.cpuPercent, memory: usage.data.memoryPercent, disk: usage.data.diskPercent, source: "平台指标" };
return { cpu: usage.data.cpuPercent, memory: usage.data.memoryPercent, disk: usage.data.diskPercent, source: "平台指标", refreshedAt: usage.refreshedAt };
}
if (metrics.status === "ready" && metrics.data.length > 0) {
const average = (values: Array<number | undefined>) => {
@@ -183,38 +161,45 @@ export function HomePage({ onNavigate }: PageComponentProps) {
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: "服务器均值"
source: "服务器指标均值",
refreshedAt: metrics.refreshedAt
};
}
return { cpu: undefined, memory: undefined, disk: undefined, source: "暂无数据" };
return { cpu: undefined, memory: undefined, disk: undefined, source: metrics.status === "error" || usage.status === "error" ? "指标不可用" : "等待指标", refreshedAt: undefined };
}, [usage, metrics]);
return (
<div className="console-page">
<div className="console-page operations-overview-page">
<header className="page-header">
<div>
<p className="page-kicker"></p>
<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>
<div className="page-header-actions">
<span className={cx("page-status", moduleFailureCount === 0 && core.status === "ready" && "page-status-ready")}>
{moduleFailureCount > 0 ? `${moduleFailureCount} 个模块不可用` : core.status === "ready" ? "运营数据已同步" : "正在同步"}
</span>
<button type="button" className="icon-command" onClick={refreshAll}>
<RotateCw size={14} />
</button>
</div>
</header>
{core.status === "loading" && <LoadingState label="正在加载服务器与任务概况…" />}
{core.status === "error" && <ErrorState title="平台概况加载失败" reason={core.reason} diagnosticId="overview-core" onRetry={() => void refreshCore()} />}
{core.status === "loading" && <LoadingState label="正在加载服务器、节点与任务概况…" />}
{core.status === "error" && <ErrorState title="平台核心概况不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} />}
{core.status === "ready" && core.data.instances.length === 0 && (
<EmptyState
icon={<CakeSlice size={26} />}
title="还没有服务器实例"
description="平台尚未创建任何服务器。前往服务器管理创建第一个实例,或检查运行节点是否在线。"
description={canManageServers ? "平台尚未创建服务器。先确认运行节点在线,再创建第一个实例。" : "当前账号可查看概览,但没有创建服务器的权限。"}
actionLabel="前往服务器管理"
onAction={() => onNavigate("servers")}
/>
)}
{core.status === "ready" && core.data.instances.length > 0 && (
<section className="console-grid" aria-label="platform health">
{core.status === "ready" && (
<section className="console-grid operations-summary-grid" aria-label="平台运营摘要">
<article className="overview-card metric-tone-success">
<span className="metric-label">线</span>
<strong className="metric-value">{onlineCount}</strong>
@@ -225,53 +210,128 @@ export function HomePage({ onNavigate }: PageComponentProps) {
<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">
<article className={cx("overview-card", endpointSummary && endpointSummary.degraded + endpointSummary.offline > 0 ? "metric-tone-warning" : "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>
<strong className="metric-value">{endpointSummary?.online ?? 0} 线</strong>
<p>{endpointSummary ? `${endpointSummary.degraded} 降级,${endpointSummary.offline} 离线` : "节点状态不可用"}</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 className={cx("overview-card", providers.status === "error" || errorProviders > 0 ? "metric-tone-warning" : "metric-tone-success")}>
<span className="metric-label">AI </span>
<strong className="metric-value">{providers.status === "ready" ? `${activeProviders} 可用` : providers.status === "loading" ? "加载中" : "不可用"}</strong>
<p>{providers.status === "error" ? "提供商状态接口失败" : errorProviders > 0 ? `${errorProviders} 个连接异常` : "连接状态正常"}</p>
</article>
</section>
)}
<section className="operations-command-grid" aria-label="平台运营模块">
<article className="console-panel operations-module" aria-label="任务脉冲">
<div className="panel-header">
<h2><Workflow size={16} /> </h2>
<span className="page-status">{moduleFreshnessLabel(core.status === "ready" ? core.refreshedAt : core.refreshedAt)}</span>
</div>
{core.status === "loading" && <LoadingState label="正在汇总任务…" compact />}
{core.status === "error" && <ErrorState title="任务状态不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />}
{core.status === "ready" && jobs && (
<>
<dl className="operations-pulse-strip">
<div><dt></dt><dd>{jobs.active.length}</dd></div>
<div><dt></dt><dd>{jobs.retrying.length}</dd></div>
<div><dt></dt><dd>{jobs.failed.length}</dd></div>
</dl>
{jobs.active.length === 0 && jobs.failed.length === 0 ? (
<p className="operations-module-empty"></p>
) : (
<div className="operations-job-list">
{[...jobs.active.slice(0, 3), ...jobs.failed.slice(0, 2)].map((job) => (
<button
key={job.id}
type="button"
className={cx("operations-job-row", job.state === "failed" && "operations-job-row-failed")}
onClick={() => onNavigate(job.serverInstanceId ? "serverDetail" : "maintenance", job.serverInstanceId ? { serverId: job.serverInstanceId } : undefined)}
>
<span><strong>{job.capability}</strong><small>{job.serverInstanceId ? `服务器 ${job.serverInstanceId}` : "平台任务"}</small></span>
<span>{job.state === "retrying" ? `重试 ${job.attempt}/${job.retryPolicy.maxAttempts}` : jobStateLabel(job.state)}</span>
<span>{Math.round(job.progress.percent)}%</span>
</button>
))}
</div>
)}
</>
)}
</article>
<article className="console-panel operations-module" aria-label="运行节点状态">
<div className="panel-header">
<h2><ServerCog size={16} /> </h2>
<button type="button" className="icon-command" onClick={() => onNavigate("maintenance")}><CircleGauge size={14} /> </button>
</div>
{core.status === "loading" && <LoadingState label="正在汇总节点…" compact />}
{core.status === "error" && <ErrorState title="节点状态不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} compact />}
{core.status === "ready" && endpointSummary && (
<>
<dl className="operations-pulse-strip">
<div><dt>线</dt><dd>{endpointSummary.online}</dd></div>
<div><dt></dt><dd>{endpointSummary.activeJobs}</dd></div>
<div><dt></dt><dd>{endpointSummary.queuedJobs}</dd></div>
</dl>
{core.data.endpoints.length === 0 ? (
<p className="operations-module-empty">Platform </p>
) : (
<div className="operations-endpoint-list">
{core.data.endpoints.slice(0, 4).map((endpoint) => (
<div key={endpoint.id} className="operations-endpoint-row">
<span><strong>{endpoint.displayName}</strong><small>{endpoint.version}</small></span>
<span className={cx("status-pill", `status-${endpoint.status}`)}>{endpointStatusLabel(endpoint.status)}</span>
<span>{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} </span>
</div>
))}
</div>
)}
</>
)}
</article>
</section>
<ProductionGovernancePanel compact title="生产容量与告警" />
<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>
<h2><Activity size={16} /> </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>
{usage.status === "loading" && metrics.status === "loading" ? (
<LoadingState label="正在读取资源指标…" compact />
) : usage.status === "error" && metrics.status === "error" ? (
<ErrorState title="资源指标不可用" reason={`${usage.reason}${metrics.reason}`} diagnosticId="overview-resource-usage" onRetry={() => { void refreshUsage(); void refreshMetrics(); }} compact />
) : (
<>
<div className="server-card-meters">
<UsageMeter label="CPU" percent={metricAverages.cpu} />
<UsageMeter label="内存" percent={metricAverages.memory} />
<UsageMeter label="磁盘" percent={metricAverages.disk} />
</div>
<span className="operations-module-freshness">{moduleFreshnessLabel(metricAverages.refreshedAt)}</span>
{(usage.status === "error" || metrics.status === "error") && (
<button type="button" className="operations-inline-warning" onClick={() => { void refreshUsage(); void refreshMetrics(); }}>
<AlertTriangle size={14} />
</button>
)}
</>
)}
</article>
<article className="console-panel" aria-label="game type distribution">
<div className="panel-header">
<h2>
<Candy size={16} style={{ verticalAlign: "-2px" }} />
</h2>
</div>
<div className="panel-header"><h2><Candy size={16} /> </h2></div>
{core.status === "loading" ? (
<LoadingState label="统计中…" compact />
) : core.status === "error" ? (
<ErrorState title="分布数据不可用" reason={core.reason} diagnosticId={core.diagnosticId} onRetry={() => void refreshCore()} 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>
))}
{distribution.map((entry) => <span key={entry.serverType}><strong>{entry.label}</strong>{entry.count} </span>)}
</div>
)}
</article>
@@ -279,17 +339,16 @@ export function HomePage({ onNavigate }: PageComponentProps) {
<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>
<h2><MoonStar size={16} /> </h2>
<div className="panel-header-actions">
<span className="page-status">{moduleFreshnessLabel(signals.status === "ready" ? signals.refreshedAt : signals.refreshedAt)}</span>
<button type="button" className="icon-command" onClick={() => void refreshSignals()}><Sparkles size={14} /> </button>
</div>
</div>
{signals.status === "loading" && core.status === "loading" ? (
<LoadingState label="正在收集信号…" compact />
) : overviewSignals.length === 0 ? (
{signals.status === "loading" && core.status === "loading" && <LoadingState label="正在收集信号…" compact />}
{signals.status === "error" && <ErrorState title="审计信号不可用" reason={signals.reason} diagnosticId={signals.diagnosticId} onRetry={() => void refreshSignals()} compact />}
{providers.status === "error" && <ErrorState title="AI 提供商信号不可用" reason={providers.reason} diagnosticId={providers.diagnosticId} onRetry={() => void refreshProviders()} compact />}
{overviewSignals.length === 0 && signals.status === "ready" && core.status === "ready" && providers.status === "ready" ? (
<EmptyState title="暂无异常信号" description="最近没有故障、失败任务或需要关注的审计事件。" />
) : (
<div className="signal-list">
@@ -298,18 +357,10 @@ export function HomePage({ onNavigate }: PageComponentProps) {
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
)
}
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>
{signal.tone === "error" || signal.tone === "warning" ? <AlertTriangle size={18} /> : signal.kind === "aiProvider" ? <Bot 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>
))}
@@ -319,3 +370,48 @@ export function HomePage({ onNavigate }: PageComponentProps) {
</div>
);
}
function buildOverviewSignals(
core: OperationsModuleState<OverviewData>,
providers: OperationsModuleState<AiProviderResponse[]>,
signals: OperationsModuleState<AuditEventResponse[]>
): 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} 需要排查`, targetPage: "servers", targetId: instance.id, tone: "error", at: instance.updatedAt });
}
for (const job of jobBuckets(core.data.jobs).failed.slice(0, 5)) {
collected.push({ id: `job-${job.id}`, kind: "job", summary: `任务 ${job.capability} 失败`, detail: job.serverInstanceId ? `服务器 ${job.serverInstanceId} 的任务执行失败` : "平台任务执行失败", targetPage: job.serverInstanceId ? "servers" : "maintenance", 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: "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.resourceKind}/${event.resourceId}${event.result}`, targetPage: event.resourceKind === "server-instance" ? "servers" : "maintenance", targetId: event.resourceId, tone: event.result === "failure" ? "warning" : "info", at: event.createdAt });
}
}
return collected.sort((left, right) => Date.parse(right.at || "") - Date.parse(left.at || "")).slice(0, 8);
}
function refreshedNow(): string {
return new Date().toISOString();
}
function errorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function jobStateLabel(state: JobResponse["state"]): string {
const labels: Record<JobResponse["state"], string> = { queued: "排队", accepted: "已领取", running: "执行中", retrying: "等待重试", succeeded: "完成", failed: "失败", cancelled: "已取消" };
return labels[state];
}
function endpointStatusLabel(status: RunEndpointResponse["status"]): string {
const labels: Record<RunEndpointResponse["status"], string> = { online: "在线", offline: "离线", degraded: "降级", disabled: "停用" };
return labels[status];
}
+3
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import type { PageComponentProps } from "../contracts/page";
import { cx } from "../utils/classes";
@@ -145,6 +146,8 @@ export function MaintenancePage({ session, operations, onNavigate }: PageCompone
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
<ProductionGovernancePanel title="容量治理与告警闭环" />
<section className="console-panel" aria-label="run endpoints">
<div className="panel-header">
<h2></h2>
@@ -0,0 +1,91 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { GamePluginResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
import { PluginPageHostPage } from "./PluginPageHostPage";
const operations: OperationTracker = {
operations: [],
begin: () => "operation-test",
update: () => undefined,
succeed: () => undefined,
fail: () => undefined,
isPending: () => false
};
const plugin: GamePluginResponse = {
id: "game.scum",
name: "SCUM Server",
version: "1.0.0",
serverType: "scum",
serverDisplayName: "SCUM Server",
manifestRef: "artifact://manifests/game.scum/1.0.0",
createFormSchemaRef: "schemas/create-form.schema.json",
requiredRunCapabilities: [],
declaredPermissions: ["server.read", "server.logs.read", "server.remote.access", "server.game-client.read", "server.game-client.command"],
permissions: { ai: false, logs: true, files: false, jobs: true, artifacts: false, remoteAccess: true },
lifecycleActions: {},
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"],
pages: [{
key: "operations",
title: "SCUM 运维",
path: "/operations",
permissions: ["server.game-client.read", "server.game-client.command", "server.logs.read", "server.remote.access"],
bridgeActions: ["server.instances.read", "logs.query", "remote.access.request"]
}],
tags: ["scum"],
aiPurposes: [],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "required", approvalRequired: ["disable", "rollback", "retire"] },
gameClientBridge: {
commands: [{ type: "announcement.send", title: "Send announcement", permission: "server.game-client.command", approvalLevel: "operator", payloadSchemaRef: "schemas/bridge/announcement.json", timeoutSeconds: 30, maxPayloadBytes: 4096 }],
snapshots: [{ type: "companion.health", schemaVersion: "1", schemaRef: "schemas/bridge/health.json", keepForSeconds: 3600, maxRecords: 24 }],
queryTemplates: [{ key: "scum.player.search", title: "Search player", permission: "server.game-client.read", engine: "sqlite", transportKey: "sqlite-db", targetKey: "db/sqlite", parameterSchemaRef: "schemas/bridge/player-search.parameters.json", resultSchemaRef: "schemas/bridge/player-search.result.json", maxRows: 50, timeoutSeconds: 10 }],
commandRetentionSeconds: 86400,
maxCommands: 1000,
pages: [{ pageKey: "operations", commandTypes: ["announcement.send"], snapshotTypes: ["companion.health"], queryTemplateKeys: ["scum.player.search"] }]
},
status: "installed"
};
function props(serverId = "server-1"): PageComponentProps {
const session = {
id: "operator-1",
displayName: "Operator",
status: "active" as const,
roles: ["platformAdmin" as const],
capabilities: capabilitiesForRoles(["platformAdmin"]),
profile: {},
source: "local" as const
};
return {
session,
params: { pluginId: "game.scum", routeKey: "operations", serverId },
operations,
onNavigate: () => undefined,
onLogout: async () => undefined,
onProfileSave: async () => session,
onThemePreferenceSave: async () => ({ userId: session.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-07-20T00:00:00Z" })
};
}
describe("PluginPageHostPage", () => {
it("renders SCUM operations from manifest-owned declarations", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props()} initialPlugin={plugin} />);
expect(html).toContain("SCUM 运维");
expect(html).toContain("平台托管上下文");
expect(html).toContain("命令目录");
expect(html).toContain("快照目录");
expect(html).toContain("查询模板");
expect(html).toContain("返回服务器");
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
});
it("shows a declared availability reason when server context is missing", () => {
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
expect(html).toContain("SCUM 运维声明不可用");
expect(html).toContain("缺少服务器实例上下文");
});
});
+111
View File
@@ -0,0 +1,111 @@
import { ArrowLeft, PlugZap } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse } from "../api/types";
import { PageFrame } from "../components/PageFrame";
import { ScumOperationsPanel } from "../components/ScumOperationsPanel";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { pluginBridgeManifestContractFromResponse } from "../contracts/pluginBridge";
import { resolveScumOperationsPageContract, scumOperationsPluginId, scumOperationsRouteKey } from "../contracts/scumOperations";
import { createPluginBridgeHostContext } from "../utils/pluginBridgeHost";
type PluginPageState =
| { status: "loading" }
| { status: "error"; reason: string }
| { status: "ready"; plugin: GamePluginResponse };
interface PluginPageHostPageProps extends PageComponentProps {
initialPlugin?: GamePluginResponse;
}
export function PluginPageHostPage({ params, onNavigate, initialPlugin }: PluginPageHostPageProps) {
const pluginId = params.pluginId ?? "";
const routeKey = params.routeKey ?? "";
const serverId = params.serverId ?? "";
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
const load = useCallback(async () => {
if (!pluginId || !routeKey) {
setState({ status: "error", reason: "插件页面路由缺少 pluginId 或 routeKey。" });
return;
}
setState({ status: "loading" });
try {
const response = await platformApiClient.listGamePlugins();
const plugin = response.items.find((candidate) => candidate.id === pluginId);
setState(plugin ? { status: "ready", plugin } : { status: "error", reason: "未找到已注册的插件声明。" });
} catch (error) {
setState({ status: "error", reason: error instanceof Error ? error.message : "插件页面声明加载失败。" });
}
}, [pluginId, routeKey]);
useEffect(() => {
if (!initialPlugin) {
void load();
}
}, [initialPlugin, load]);
if (state.status === "loading") {
return <LoadingState label="正在加载插件页面声明…" />;
}
if (state.status === "error") {
return <ErrorState title="插件页面不可用" reason={state.reason} onRetry={() => void load()} />;
}
const page = state.plugin.pages.find((candidate) => candidate.key === routeKey);
if (!page) {
return <ErrorState title="插件页面不可用" reason="当前插件没有声明该 routeKey。" />;
}
const manifestContract = pluginBridgeManifestContractFromResponse(state.plugin);
const hostContext = createPluginBridgeHostContext({
plugin: manifestContract,
routeKey,
serverInstanceId: serverId || undefined,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
});
const isScumOperations = state.plugin.id === scumOperationsPluginId && routeKey === scumOperationsRouteKey;
const scumResolution = isScumOperations ? resolveScumOperationsPageContract(state.plugin, serverId) : null;
return (
<div className="console-page">
<PageFrame
kicker={`${state.plugin.name} / PLUGIN PAGE`}
title={page.title}
status={serverId ? `服务器 ${serverId}` : "未绑定服务器"}
metrics={[
{ label: "有效权限", value: String(hostContext.permissions.length), tone: hostContext.permissions.length > 0 ? "success" : "warning" },
{ label: "桥接动作", value: String(hostContext.bridgeActions.length), tone: hostContext.bridgeActions.length > 0 ? "success" : "warning" },
{ label: "页面路由", value: routeKey, tone: "neutral" }
]}
/>
<section className="console-panel" aria-label="plugin page host context">
<div className="panel-header">
<h2><PlugZap size={16} aria-hidden="true" /> </h2>
<button type="button" className="icon-command" onClick={() => onNavigate(serverId ? "serverDetail" : "plugins", serverId ? { serverId } : {})}>
<ArrowLeft size={14} aria-hidden="true" />
<span>{serverId ? "返回服务器" : "返回插件市场"}</span>
</button>
</div>
{scumResolution && !scumResolution.available && <ErrorState title="SCUM 运维声明不可用" reason={scumResolution.reason} compact />}
{scumResolution?.available && (
<div className="action-list" aria-label="SCUM operations declarations">
<span><strong></strong> {scumResolution.contract.commands.length} operations </span>
<span><strong></strong> {scumResolution.contract.snapshots.length} schemaVersion </span>
<span><strong></strong> {scumResolution.contract.queryTemplates.length} SQLite </span>
</div>
)}
{!scumResolution && (
<EmptyState
title="插件页面已接入 Host Bridge"
description="当前页面只接收 manifest 声明与平台安全上下文;具体操作由对应插件页面实现。"
/>
)}
</section>
{scumResolution?.available && <ScumOperationsPanel contract={scumResolution.contract} />}
</div>
);
}
+13
View File
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PluginsPage } from "./PluginsPage";
import pluginsPageSource from "./PluginsPage.tsx?raw";
import type { MarketplacePluginResponse } from "../api/types";
const marketplacePlugin: MarketplacePluginResponse = {
@@ -22,6 +23,7 @@ const marketplacePlugin: MarketplacePluginResponse = {
pages: [{ key: "logs", title: "Logs", path: "/logs", permissions: ["server.logs.read"], bridgeActions: ["logs.query"] }],
tags: ["example"],
aiPurposes: ["logs.diagnose"],
productionLifecycle: { operations: ["install", "enable", "disable", "upgrade", "rollback", "retire", "dependency-check"], dependencyPolicy: "optional", approvalRequired: ["disable", "rollback", "retire"] },
status: "installed",
source: "platform-registry"
};
@@ -82,4 +84,15 @@ describe("PluginsPage", () => {
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain("manifest validated");
});
it("keeps state actions confirmed, permission-gated, and detail retryable", () => {
expect(pluginsPageSource).toContain("ConfirmDialog");
expect(pluginsPageSource).toContain("retryDetail");
expect(pluginsPageSource).toContain("当前账号为只读模式");
expect(pluginsPageSource).toContain("平台会返回持久状态");
expect(pluginsPageSource).toContain("actionPending !== null");
expect(pluginsPageSource).toContain("operations?.begin");
expect(pluginsPageSource).toContain("operations?.succeed");
expect(pluginsPageSource).toContain("operations?.fail");
});
});
+60 -10
View File
@@ -3,10 +3,12 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from "rea
import { platformApiClient } from "../api/client";
import type { GamePluginStatus, MarketplacePluginFilterRequest, MarketplacePluginResponse, MarketplacePluginStateAction } from "../api/types";
import { ManagementDialog } from "../components/OperationControls";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { isPlatformAdmin } from "../contracts/workspace";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
@@ -34,7 +36,7 @@ const statusFilters: Array<{ id: StatusFilter; label: string }> = [
{ id: "updating", label: "更新中" }
];
export function PluginsPage({ initialState }: PluginsPageProps = {}) {
export function PluginsPage({ initialState, session, operations }: PluginsPageProps = {}) {
const [listState, setListState] = useState<ListState>(initialState?.listState ?? "loading");
const [listError, setListError] = useState(initialState?.listError ?? "");
const [plugins, setPlugins] = useState<MarketplacePluginResponse[]>(initialState?.plugins ?? []);
@@ -47,6 +49,7 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
const [serverType, setServerType] = useState("");
const [capability, setCapability] = useState("");
const [actionPending, setActionPending] = useState<MarketplacePluginStateAction | null>(null);
const [confirmAction, setConfirmAction] = useState<MarketplacePluginStateAction | null>(null);
const [actionResult, setActionResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(initialState?.actionResult ?? null);
const [usingFallback, setUsingFallback] = useState(initialState?.usingFallback ?? false);
@@ -110,6 +113,22 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
.finally(() => setDetailPending(false));
}, [plugins, selectedId, usingFallback]);
const retryDetail = useCallback(async () => {
if (!selectedId || usingFallback) {
return;
}
setDetailPending(true);
setDetailError("");
try {
const plugin = await platformApiClient.getMarketplacePlugin(selectedId);
setDetail(plugin);
} catch (error) {
setDetailError(error instanceof Error ? error.message : "插件详情加载失败");
} finally {
setDetailPending(false);
}
}, [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;
@@ -129,6 +148,8 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
if (!detail || usingFallback) {
return;
}
const intent = `${stateActionLabel(action)}插件`;
const operationId = operations?.begin({ intent, targetKind: "plugin", targetId: detail.id, requester: session?.displayName });
setActionPending(action);
setActionResult(null);
try {
@@ -136,23 +157,37 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
setDetail(updated);
setPlugins((current) => current.map((plugin) => (plugin.id === updated.id ? updated : plugin)));
setActionResult({ status: "succeeded", label: `${updated.name}${stateActionLabel(action)}` });
if (operationId) operations?.succeed(operationId, `${updated.name}${stateActionLabel(action)}`);
setConfirmAction(null);
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "状态更新失败" });
const reason = error instanceof Error ? error.message : "状态更新失败";
setActionResult({ status: "failed", label: reason });
if (operationId) operations?.fail(operationId, reason, operationId);
} finally {
setActionPending(null);
}
}
function requestStateChange(action: MarketplacePluginStateAction) {
if (!canManage || usingFallback || actionPending !== null) {
return;
}
setActionResult(null);
setConfirmAction(action);
}
const canManage = Boolean(session && isPlatformAdmin(session));
return (
<div className="console-page">
<PageFrame
kicker="扩展"
title="插件市场"
status={usingFallback ? "本地演示数据" : "平台 API"}
status={usingFallback ? "本地演示数据" : listState === "loading" ? "正在连接平台 API" : listState === "error" ? "平台 API 不可用" : "平台 API"}
metrics={[
{ label: "已安装", value: `${installedCount}`, tone: "success" },
{ label: "桥接动作", value: `${bridgeActionCount}`, tone: "success" },
{ label: "校验失败", value: `${invalidCount}`, tone: invalidCount > 0 ? "warning" : "success" }
{ label: "已安装", value: listState === "ready" ? `${installedCount}` : "--", tone: "success" },
{ label: "桥接动作", value: listState === "ready" ? `${bridgeActionCount}` : "--", tone: "success" },
{ label: "校验失败", value: listState === "ready" ? `${invalidCount}` : "--", tone: invalidCount > 0 ? "warning" : "success" }
]}
/>
@@ -196,6 +231,7 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
</div>
{usingFallback && <ResultBadge status="pending" label="本地演示数据仅用于前端开发,连接平台 API 后会自动替换" />}
{!canManage && !usingFallback && <ResultBadge status="pending" label="当前账号为只读模式,插件状态动作需要平台管理员权限" />}
{actionResult && <ResultBadge status={actionResult.status} label={actionResult.label} />}
{listState === "loading" && <LoadingState label="正在加载插件市场…" />}
@@ -248,13 +284,23 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
</section>
)}
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { setSelectedId(""); setDetail(null); setDetailError(""); }}>
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { if (actionPending === null) { setSelectedId(""); setDetail(null); setDetailError(""); setConfirmAction(null); } }}>
<div className="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)} />}
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} onRetry={() => void retryDetail()} compact />}
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback || !canManage} onAction={requestStateChange} />}
</div>
</ManagementDialog>
<ConfirmDialog
open={confirmAction !== null}
title="确认插件状态变更"
description={detail ? `将对 ${detail.name} 执行“${stateActionLabel(confirmAction ?? "disable")}”。平台会返回持久状态,失败时保留当前状态并允许重试。` : "请确认插件状态变更。"}
confirmLabel={stateActionLabel(confirmAction ?? "disable")}
danger={confirmAction === "disable"}
busy={actionPending !== null}
onCancel={() => { if (actionPending === null) setConfirmAction(null); }}
onConfirm={() => { if (confirmAction) void changeState(confirmAction); }}
/>
</div>
);
}
@@ -282,6 +328,7 @@ function PluginDetail({ plugin, actionPending, actionsDisabled, onAction }: Plug
<DetailStat label="权限" value={`${plugin.declaredPermissions.length}`} />
<DetailStat label="AI 用途" value={plugin.aiPurposes.length ? plugin.aiPurposes.join(", ") : "--"} />
</div>
<PluginLifecycleWorkbench pluginId={plugin.id} pluginName={plugin.name} operations={plugin.productionLifecycle?.operations} disabled={actionsDisabled} />
<dl className="detail-list">
<div>
<dt></dt>
@@ -345,5 +392,8 @@ function statusLabel(status: string): string {
}
function stateActionLabel(action: MarketplacePluginStateAction): string {
if (action === "install") {
return "安装";
}
return action === "disable" ? "停用" : "启用";
}
+3
View File
@@ -24,3 +24,6 @@ Pages must use the shared black-mecha / magical-girl visual system from `../them
- 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.
# Server Detail lifecycle behavior
The Server Detail Client Manager section is a real operations surface: it consumes safe Platform projections, polls only while a lifecycle job is active, shows current job attempts/progress, and provides recovery actions for retryable failure, stale key generation, failed update rollback, and offline health. Action buttons remain compact and disabled with the Platform-provided reason when declaration, permission, endpoint, artifact, target, or lifecycle state is not ready.
+20 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import clientManagerLifecyclePanelSource from "../components/ClientManagerLifecyclePanel.tsx?raw";
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types";
@@ -81,7 +82,18 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).toContain("installDependencies");
expect(serverDetailPageSource).toContain("listServerLiveLogs");
expect(serverDetailPageSource).toContain("requestLogBackfill");
expect(serverDetailPageSource).toContain("safeRuntimeRef");
expect(serverDetailPageSource).toContain("ClientManagerLifecyclePanel");
expect(clientManagerLifecyclePanelSource).toContain("listClientManagerLifecycles");
expect(clientManagerLifecyclePanelSource).toContain("deployClientManager");
expect(clientManagerLifecyclePanelSource).toContain("controlClientManager");
expect(clientManagerLifecyclePanelSource).toContain("updateClientManager");
expect(clientManagerLifecyclePanelSource).toContain("retryClientManagerLifecycle");
expect(clientManagerLifecyclePanelSource).toContain("revokeClientManagerSession");
expect(clientManagerLifecyclePanelSource).toContain("uninstallClientManager");
expect(clientManagerLifecyclePanelSource).toContain("expectedDeploymentGeneration");
expect(clientManagerLifecyclePanelSource).not.toContain("secretRef");
expect(clientManagerLifecyclePanelSource).not.toContain("hostPath");
expect(clientManagerLifecyclePanelSource).not.toContain("process.pid");
expect(serverDetailPageSource).not.toContain("authKey");
expect(serverDetailPageSource).not.toContain("password=");
expect(serverDetailPageSource).not.toContain("unix://");
@@ -90,6 +102,13 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("sqlite://");
});
it("loads the dependency catalog only after runtime actions expose dependency operations", () => {
const runtimeDistributionSectionSource = serverDetailPageSource.split("function RuntimeDistributionSection")[1]?.split("function RuntimeBindingFields")[0] ?? "";
expect(runtimeDistributionSectionSource).toContain('action.key === "dependencies-check" || action.key === "dependencies-install"');
expect(runtimeDistributionSectionSource).toContain("dependencyActions.some((action) => action.available)");
expect(runtimeDistributionSectionSource).toContain("getDependencyCatalog(instance.id)");
});
it("reviews and updates only redacted runtime binding metadata", () => {
const runtimeBindingSectionSource = serverDetailPageSource.split("function RuntimeBindingSection")[1]?.split("function RuntimeDistributionSection")[0] ?? "";
expect(serverDetailPageSource).toContain("getServerRuntimeBinding");
+81 -103
View File
@@ -26,6 +26,9 @@ import type {
RemoteAdapterDeclarationResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { ClientManagerLifecyclePanel } from "../components/ClientManagerLifecyclePanel";
import { ProductionGovernancePanel } from "../components/ProductionGovernancePanel";
import { PluginLifecycleWorkbench } from "../components/PluginLifecycleWorkbench";
import {
RuntimeTaskProgressDialog,
runtimeBuildStages,
@@ -61,7 +64,7 @@ import {
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
@@ -300,6 +303,9 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
onChanged={() => void refresh()}
/>
)}
{section === "overview" && (
<ClientManagerLifecyclePanel serverId={instance.data.id} serverName={instance.data.name} session={session} operations={operations} />
)}
{section === "overview" && (
<ServerMetadataSection
instance={instance.data}
@@ -312,7 +318,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} />}
{section === "plugins" && <PluginControlsSection serverId={serverId} instance={instance.data} plugins={plugins} artifacts={artifacts} session={session} operations={operations} onNavigate={onNavigate} />}
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
{section === "history" && <HistorySection serverId={serverId} serverOperations={serverOperations} jobs={jobs} artifacts={artifacts} metricHistory={metricHistory} backups={backups} remoteAdapters={remoteAdapters} />}
</>
@@ -778,11 +784,25 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const refreshRuntimeProjections = useCallback(async () => {
const dependencyActions = runtimeActions.status === "ready"
? runtimeActions.data.actions.filter((action) => action.key === "dependencies-check" || action.key === "dependencies-install")
: [];
const dependencyActionReason = dependencyActions.find((action) => action.reason)?.reason ?? "依赖操作未开放";
const catalogRequest: Promise<LoadState<DependencyCatalogResponse>> =
runtimeActions.status === "ready" && dependencyActions.some((action) => action.available)
? platformApiClient
.getDependencyCatalog(instance.id)
.then((data): LoadState<DependencyCatalogResponse> => ({ status: "ready", data }))
.catch((error): LoadState<DependencyCatalogResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" }))
: Promise.resolve(
runtimeActions.status === "error"
? { status: "error", reason: runtimeActions.reason }
: runtimeActions.status === "ready"
? { status: "error", reason: dependencyActionReason }
: { status: "loading" }
);
const [catalog, updates] = await Promise.all([
platformApiClient
.getDependencyCatalog(instance.id)
.then((data): LoadState<DependencyCatalogResponse> => ({ status: "ready", data }))
.catch((error): LoadState<DependencyCatalogResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "依赖目录加载失败" })),
catalogRequest,
platformApiClient
.listRunUpdates(instance.id)
.then((data): LoadState<RunUpdateJobResponse[]> => ({ status: "ready", data: data.items }))
@@ -790,7 +810,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
]);
setDependencyCatalog(catalog);
setRunUpdates(updates);
}, [instance.id]);
}, [instance.id, runtimeActions]);
useEffect(() => {
void refreshRuntimeProjections();
@@ -1134,7 +1154,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器二进制已构建,artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`,
(distribution) => `客户端管理器二进制已构建,artifact ${distribution.artifactId}组件密钥仅由 Platform/Run 受控使用`,
{
description: `${profileKey} profile 拉取客户端代码、安装环境、编译并生成可下载 artifact。`,
stages: runtimeBuildStages,
@@ -1341,13 +1361,6 @@ function runUpdatePhaseLabel(phase: RunUpdateJobResponse["phase"]): string {
}
}
function safeRuntimeRef(ref: string): string {
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
return ref;
}
return "[redacted-ref]";
}
interface LogsSectionProps {
serverId: string;
}
@@ -1680,6 +1693,7 @@ interface PluginControlsSectionProps {
artifacts: ArtifactResponse[];
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onNavigate: PageComponentProps["onNavigate"];
}
function controlsForPlugin(plugin: GamePluginResponse): PluginControlDescriptor[] {
@@ -1746,7 +1760,7 @@ function lifecycleControlLabel(action: string): string {
}
}
function PluginControlsSection({ serverId, instance, plugins, artifacts, session, operations }: PluginControlsSectionProps) {
function PluginControlsSection({ serverId, instance, plugins, artifacts, session, operations, onNavigate }: PluginControlsSectionProps) {
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [confirmControl, setConfirmControl] = useState<null | { plugin: PluginControlGroupView; control: PluginControlDescriptor }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -1829,12 +1843,16 @@ function PluginControlsSection({ serverId, instance, plugins, artifacts, session
{!isCollapsed && (
<div className="plugin-group-body">
{plugins.find((plugin) => plugin.id === group.pluginId) && (
<PluginBridgeExecutionPanel
plugin={plugins.find((plugin) => plugin.id === group.pluginId)!}
serverId={serverId}
serverInstance={instance}
artifacts={artifacts}
/>
<>
<PluginLifecycleWorkbench pluginId={group.pluginId} pluginName={group.pluginName} operations={plugins.find((plugin) => plugin.id === group.pluginId)?.productionLifecycle?.operations} serverId={serverId} />
<PluginBridgeExecutionPanel
plugin={plugins.find((plugin) => plugin.id === group.pluginId)!}
serverId={serverId}
serverInstance={instance}
artifacts={artifacts}
onNavigate={onNavigate}
/>
</>
)}
{group.controls.length === 0 && <span className="provider-id"></span>}
{group.controls.map((control) => {
@@ -1904,12 +1922,14 @@ interface PluginBridgeExecutionPanelProps {
serverId: string;
serverInstance: ServerInstanceResponse;
artifacts: ArtifactResponse[];
onNavigate: PageComponentProps["onNavigate"];
}
function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifacts }: PluginBridgeExecutionPanelProps) {
function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifacts, onNavigate }: PluginBridgeExecutionPanelProps) {
const [pendingAction, setPendingAction] = useState<PluginBridgeAction | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const page = plugin.pages[0];
const declaredPageKey = plugin.gameClientBridge?.pages?.[0]?.pageKey;
const page = plugin.pages.find((candidate) => candidate.key === declaredPageKey) ?? plugin.pages[0];
if (!page || plugin.bridgeActions.length === 0) {
return null;
}
@@ -1962,6 +1982,15 @@ function PluginBridgeExecutionPanel({ plugin, serverId, serverInstance, artifact
{result && <ResultBadge status={result.status} label={result.label} />}
</span>
<div className="action-strip">
<button
type="button"
className="icon-command"
onClick={() => onNavigate("pluginPage", { pluginId: plugin.id, routeKey: page.key, serverId })}
title={`打开 ${page.title}`}
>
<PackageOpen size={14} />
<span></span>
</button>
{executableActions.slice(0, 3).map((action) => (
<button
key={action}
@@ -2027,27 +2056,11 @@ interface LlmSectionProps {
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
const [prompt, setPrompt] = useState("");
const [currentConfig, setCurrentConfig] = useState<string>("");
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
void platformApiClient
.getServerConfig(serverId)
.then((response) => {
if (!cancelled) {
setCurrentConfig(response.content);
}
})
.catch(() => {
setCurrentConfig("");
});
return () => {
cancelled = true;
};
}, [serverId]);
const [approvalBusy, setApprovalBusy] = useState(false);
const [suggestionError, setSuggestionError] = useState("");
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -2056,50 +2069,53 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
}
setBusy(true);
setSuggestion(null);
setSuggestionError("");
try {
const response = await platformApiClient.suggestServerConfig({ serverInstanceId: serverId, prompt: prompt.trim(), currentConfig });
const preview = response.suggestedConfig
const response = await platformApiClient.invokeAI({ requestId: `web:ai.config:${serverId}:${Date.now()}`, serverInstanceId: serverId, purpose: "config.suggest", prompt: prompt.trim() });
if (response.status !== "ok") {
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
}
const recommendation = response.configRecommendation;
const preview = recommendation?.suggestedConfig
? await platformApiClient.previewServerConfigDiff(serverId, {
expectedConfigVersion: instance.configVersion,
expectedChecksum: instance.configChecksum,
key: defaultConfigKey,
proposedContent: response.suggestedConfig
key: recommendation.key,
proposedContent: recommendation.suggestedConfig
})
: undefined;
setSuggestion({
serverInstanceId: serverId,
source: "api",
recommendation: response.recommendation,
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
diffId: recommendation?.diffId,
expiresAt: recommendation?.expiresAt,
diff: preview ? configDiffViewFromPreview(preview) : undefined
});
} catch {
setSuggestion(buildLocalSuggestion(serverId, prompt.trim(), currentConfig));
} catch (caught) {
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
} finally {
setBusy(false);
}
}
async function applySuggestion() {
if (!suggestion?.diff) {
if (!suggestion?.diff || !suggestion.diffId || approvalBusy) {
return;
}
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
setApprovalBusy(true);
try {
const dispatch = await platformApiClient.approveServerConfigWrite(serverId, {
expectedConfigVersion: suggestion.diff.configVersion ?? instance.configVersion,
expectedChecksum: suggestion.diff.checksum ?? instance.configChecksum,
key: suggestion.diff.key ?? defaultConfigKey,
proposedContent: suggestion.diff.nextContent,
proposedContentInputRef: suggestion.diff.proposedContentInputRef,
idempotencyKey: `web:config.write.llm:${serverId}:${Date.now()}`
});
const job = dispatch.job;
const approved = await platformApiClient.approveAIConfigDiff(suggestion.diffId, `web:ai.config.approve:${suggestion.diffId}`);
const job = approved.dispatch.job;
operations.succeed(operationId, `AI 建议已确认,写入任务 ${job.id} 已派发`, job);
setSuggestion(null);
setConfirming(false);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "写入任务派发失败", operationId);
setConfirming(false);
} finally {
setApprovalBusy(false);
}
}
@@ -2130,6 +2146,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
/>
</div>
)}
{suggestionError && <ErrorState title="AI 建议不可用" reason={suggestionError} diagnosticId={`ai-config:${serverId}`} onRetry={() => setSuggestionError("")} compact />}
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void requestSuggestion(event)}>
<label>
AI
@@ -2149,7 +2166,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
<div className="panel-header" style={{ marginBottom: 0 }}>
<h3>AI </h3>
<span className="page-status">{suggestion.source === "api" ? "平台 LLM" : "本地建议(LLM 接口未提供)"}</span>
<span className="page-status"> AI Provider</span>
</div>
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
{suggestion.diff ? (
@@ -2175,7 +2192,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
title="确认应用 AI 配置建议"
description={`即将向服务器 ${instance.name}${serverId})派发配置写入任务。写入内容以上方差异为准。`}
confirmLabel="确认写入"
busy={llmOperation?.status === "pending"}
busy={approvalBusy || llmOperation?.status === "pending"}
onCancel={() => setConfirming(false)}
onConfirm={() => void applySuggestion()}
/>
@@ -2183,48 +2200,6 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
);
}
function buildLocalSuggestion(serverId: string, prompt: string, currentConfig: string): LlmSuggestionView {
const lines = currentConfig.split("\n");
const next = [...lines];
const changed: string[] = [];
const playerMatch = prompt.match(/(\d+)\s*(?:人|名玩家|players?)/i) ?? prompt.match(/玩家[^\d]*(\d+)/);
if (playerMatch) {
const index = next.findIndex((line) => line.startsWith("max-players="));
if (index >= 0) {
next[index] = `max-players=${playerMatch[1]}`;
changed.push(`max-players 调整为 ${playerMatch[1]}`);
}
}
if (/关闭\s*pvp|禁用\s*pvp|pvp.*(off|false|关)/i.test(prompt)) {
const index = next.findIndex((line) => line.startsWith("pvp="));
if (index >= 0) {
next[index] = "pvp=false";
changed.push("pvp 关闭");
}
}
if (/开启\s*pvp|pvp.*(on|true|开)/i.test(prompt)) {
const index = next.findIndex((line) => line.startsWith("pvp="));
if (index >= 0) {
next[index] = "pvp=true";
changed.push("pvp 开启");
}
}
const nextContent = next.join("\n");
if (changed.length === 0) {
return {
serverInstanceId: serverId,
source: "local",
recommendation: `暂时无法为“${prompt}”生成配置差异。平台 LLM 建议接口尚未提供;本地建议引擎只支持常见字段(如 max-players、pvp)。`
};
}
return {
serverInstanceId: serverId,
source: "local",
recommendation: `根据请求“${prompt}”,建议:${changed.join("")}。请确认差异后再写入。`,
diff: buildConfigDiff(serverId, currentConfig, nextContent)
};
}
export function configDiffViewFromPreview(preview: ServerConfigDiffPreviewResponse): ConfigDiffView {
const lines = preview.diff.map(configDiffLineFromPreviewLine);
const added = lines.filter((line) => line.kind === "added").length;
@@ -2261,6 +2236,8 @@ interface HistorySectionProps {
function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHistory, backups, remoteAdapters }: HistorySectionProps) {
return (
<>
<ProductionGovernancePanel compact title={`服务器 ${serverId} 的容量与告警`} />
<div className="overview-two-col" aria-label="operation history">
<article className="console-panel">
<div className="panel-header">
@@ -2370,6 +2347,7 @@ function HistorySection({ serverId, serverOperations, jobs, artifacts, metricHis
</div>
</article>
</div>
</>
);
}
+61 -25
View File
@@ -1,4 +1,4 @@
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { AlertTriangle, CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
@@ -14,17 +14,17 @@ import {
runtimeUpdateStages,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { UsageMeter } from "../components/OperationControls";
import { ManagementDialog, UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
defaultServerCreateForm,
endpointLabel,
pendingJobsForServer,
pluginLabel,
runtimeBindingFields,
type ServerCreateFormState
} from "../contracts/serverManagement";
import { summarizeServerOperations } from "../contracts/operationsConsole";
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
import {
clientManagerBuildRequest,
@@ -56,6 +56,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [metrics, setMetrics] = useState<Map<string, ServerMetricsResponse>>(new Map());
const [metricsPending, setMetricsPending] = useState(true);
const [metricsError, setMetricsError] = useState("");
const [keyword, setKeyword] = useState("");
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
@@ -63,7 +64,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const refresh = useCallback(async () => {
const refreshList = useCallback(async () => {
setListState("loading");
try {
const [pluginResponse, endpointResponse, instanceResponse, jobResponse] = await Promise.all([
@@ -97,33 +98,46 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
setListState("error");
setListError(error instanceof Error ? error.message : "加载失败");
}
}, []);
const refreshMetrics = useCallback(async () => {
setMetricsPending(true);
try {
const metricsResponse = await platformApiClient.listServerMetrics();
setMetrics(new Map(metricsResponse.items.map((item) => [item.serverInstanceId, item])));
} catch {
setMetricsError("");
} catch (error) {
setMetrics(new Map());
setMetricsError(error instanceof Error ? error.message : "服务器指标加载失败");
} finally {
setMetricsPending(false);
}
}, []);
const refresh = useCallback(async () => {
await Promise.all([refreshList(), refreshMetrics()]);
}, [refreshList, refreshMetrics]);
useEffect(() => {
void refresh();
}, [refresh]);
const cards = useMemo<ServerCardView[]>(
() =>
instances.map((instance) => ({
instance,
metrics: metrics.get(instance.id),
pendingJobs: pendingJobsForServer(jobs, instance.id).length
summarizeServerOperations(instances, metrics, jobs).map((summary) => ({
instance: summary.instance,
metrics: summary.metrics,
pendingJobs: summary.activeJobs,
activeJobs: summary.activeJobs,
failedJobs: summary.failedJobs,
latestJob: summary.latestJob
})),
[instances, jobs, metrics]
);
const visibleCards = useMemo(() => filterServerCards(cards, keyword, statusFilter), [cards, keyword, statusFilter]);
const createPending = operations.isPending("platform", "创建服务器");
const canManageServers = session.capabilities.includes("servers.manage");
const selectedCreatePlugin = plugins.find((plugin) => plugin.id === form.pluginId);
const createBindingFields = runtimeBindingFields(selectedCreatePlugin, form.profileKey);
@@ -356,13 +370,23 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
<Sparkles size={16} />
<span></span>
</button>
<button type="button" className="icon-command" title="创建服务器" onClick={() => setShowCreate((current) => !current)}>
<button type="button" className="icon-command" title={canManageServers ? "创建服务器" : "当前账号没有创建权限"} disabled={!canManageServers} onClick={() => setShowCreate((current) => !current)}>
<Candy size={16} />
<span></span>
</button>
</div>
</header>
{metricsError && (
<div className="operations-inline-warning" role="status">
<AlertTriangle size={14} />
<span>{metricsError}</span>
<button type="button" className="inline-link-command" onClick={() => void refreshMetrics()}>
</button>
</div>
)}
{latestCreate && (
<div className="inline-result-strip" aria-live="polite">
<ResultBadge
@@ -378,11 +402,14 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</div>
)}
{showCreate && (
<form className="provider-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
<div className="form-header">
<h2></h2>
</div>
<ManagementDialog
open={showCreate && canManageServers}
title="创建服务器"
description="选择插件声明的运行配置和安全逻辑绑定。提交后以 Platform 返回的实例与安装任务为准。"
wide
onClose={() => { if (!createPending) setShowCreate(false); }}
>
<form className="provider-form dialog-form" onSubmit={(event) => void handleCreate(event)} aria-label="创建服务器">
<div className="form-grid">
<label>
ID
@@ -436,12 +463,15 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</label>
))}
</div>
<button type="submit" className="primary-command" disabled={createPending || !form.profileKey} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span>
</button>
<div className="confirm-actions">
<button type="button" disabled={createPending} onClick={() => setShowCreate(false)}></button>
<button type="submit" className="confirm-primary" disabled={createPending || !form.profileKey} title="创建服务器">
<Sparkles size={16} />
<span>{createPending ? "创建中…" : "创建并安装"}</span>
</button>
</div>
</form>
)}
</ManagementDialog>
<div className="server-toolbar" role="search">
<Search size={16} aria-hidden="true" />
@@ -465,7 +495,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
</div>
{listState === "loading" && <LoadingState label="正在加载服务器列表…" />}
{listState === "error" && <ErrorState title="服务器列表加载失败" reason={listError} diagnosticId="server-list" onRetry={() => void refresh()} />}
{listState === "error" && <ErrorState title="服务器列表加载失败" reason={listError} diagnosticId="server-list" onRetry={() => void refreshList()} />}
{listState === "ready" && cards.length === 0 && (
<EmptyState
icon={<CakeSlice size={26} />}
@@ -493,6 +523,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
key={card.instance.id}
card={card}
metricsPending={metricsPending}
metricsUnavailable={Boolean(metricsError)}
canManage={canManageServers}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
/>
@@ -517,12 +549,14 @@ type ServerQuickRuntimeAction =
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;
metricsUnavailable: boolean;
canManage: boolean;
onOpen: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void;
}
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
function ServerCard({ card, metricsPending, metricsUnavailable, canManage, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs, failedJobs = 0 } = card;
const online = serverIsOnline(instance.state);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
@@ -622,7 +656,7 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
</span>
<span className="server-card-stat">
<span></span>
<strong>{pendingJobs > 0 ? `${pendingJobs} 进行中` : online ? "空闲" : "--"}</strong>
<strong>{failedJobs > 0 ? `${failedJobs} 失败` : pendingJobs > 0 ? `${pendingJobs} 进行中` : online ? "空闲" : "--"}</strong>
</span>
</div>
<div className="server-card-meters">
@@ -630,12 +664,14 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
{metricsUnavailable && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
{failedJobs > 0 && <span className="server-card-warning"><AlertTriangle size={13} /></span>}
<div className="action-strip" style={{ justifyContent: "space-between" }}>
<button type="button" className="icon-command" onClick={onOpen}>
<Sparkles size={14} />
<span></span>
</button>
<button ref={menuButtonRef} type="button" className="icon-command" aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<button ref={menuButtonRef} type="button" className="icon-command" disabled={!canManage} title={canManage ? "运行操作" : "当前账号没有运行操作权限"} aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span>
</button>
</div>
+30 -1
View File
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UsersPage } from "./UsersPage";
import usersPageSource from "./UsersPage.tsx?raw";
import type { UserResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
@@ -66,11 +67,22 @@ describe("UsersPage", () => {
expect(html).not.toContain("Plugin Reviewer");
});
it("renders API failure without substituting actionable sample users", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [], loading: false, source: "api", loadError: "backend unavailable" }} />);
expect(html).toContain("用户 API 暂不可用");
expect(html).toContain("backend unavailable");
expect(html).toContain("重试");
expect(html).not.toContain("local.example.test");
expect(usersPageSource).toContain("setUsers([])");
expect(usersPageSource).not.toContain("fallbackUsers");
});
it("renders explicit local-development fixture state with a single status flow", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "local-development" }} />);
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("本地开发样例 / 禁止假成功");
expect(html).toContain("本地开发样例 / 仅查看");
expect(html).toContain("编辑");
expect(html).toContain("状态");
expect(html).toContain("应用状态");
@@ -91,6 +103,23 @@ describe("UsersPage", () => {
expect(html).not.toContain('aria-label="编辑用户"');
});
it("renders full-width user search and role/status filters", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
expect(html).toContain("搜索名称、邮箱或用户 ID");
expect(html).toContain("按角色筛选");
expect(html).toContain("按状态筛选");
expect(html).toContain("平台管理员");
expect(html).toContain("服务器管理员");
});
it("prevents duplicate create and edit submissions while session operations are pending", () => {
expect(usersPageSource).toContain('operations.isPending("users", "邀请用户")');
expect(usersPageSource).toContain('operations.isPending(editingUserId, "编辑用户")');
expect(usersPageSource).toContain("createPending ? \"发送中…\"");
expect(usersPageSource).toContain("editPending ? \"保存中…\"");
});
it("does not render account maintenance controls for non-admins", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps(serverUser)} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
+75 -67
View File
@@ -1,5 +1,5 @@
import { HeartHandshake, Sparkles, UserPen, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
@@ -7,7 +7,6 @@ import { ConfirmDialog, ManagementDialog } from "../components/OperationControls
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 { type UserEditFormState, type UserListSource, type UserRemovalConfirmationState, userEditFormFromResponse } from "../contracts/users";
import { isPlatformAdmin } from "../contracts/workspace";
import { userCreateRequestFromDraft, userDeactivateRequest, userUpdateRequestFromEditForm } from "../schemas/users";
@@ -25,21 +24,6 @@ const statusOptions: Array<{ value: UserStatus; label: string }> = [
{ 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"
}));
interface UsersPageInitialState {
users?: UserResponse[];
loading?: boolean;
@@ -63,6 +47,9 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
const [confirmRemoval, setConfirmRemoval] = useState<UserRemovalConfirmationState | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [statusDrafts, setStatusDrafts] = useState<Record<string, UserStatus>>({});
const [keyword, setKeyword] = useState("");
const [roleFilter, setRoleFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<UserStatus | "all">("all");
const [draft, setDraft] = useState<UserCreateRequest>({
displayName: "",
email: "",
@@ -71,43 +58,33 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
profile: { phone: "", qq: "", contactNote: "" }
});
useEffect(() => {
let cancelled = false;
const loadUsers = useCallback(async () => {
if (initialState?.users) {
setUsers(initialState.users);
setLoading(initialState.loading ?? false);
setSource(initialState.source ?? "api");
setLoadError(initialState.loadError ?? "");
return () => {
cancelled = true;
};
return;
}
setLoading(true);
setLoadError("");
try {
const response = await platformApiClient.listUsers();
setUsers(response.items);
setSource("api");
} catch (error) {
setUsers([]);
setSource("api");
setLoadError(error instanceof Error ? error.message : "账号 API 加载失败,未显示本地样例数据。");
} finally {
setLoading(false);
}
void platformApiClient
.listUsers()
.then((response) => {
if (!cancelled) {
setUsers(response.items);
setSource("api");
setLoadError("");
}
})
.catch(() => {
if (!cancelled) {
setUsers(import.meta.env.DEV ? fallbackUsers : []);
setSource(import.meta.env.DEV ? "local-development" : "api");
setLoadError(import.meta.env.DEV ? "账号 API 加载失败,当前显示本地开发样例;状态变更会被平台 API 拒绝或写入。" : "账号 API 加载失败,未显示本地样例数据。");
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [initialState]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => {
setStatusDrafts(Object.fromEntries(users.map((user) => [user.id, user.status])));
}, [users]);
@@ -121,6 +98,23 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
[users]
);
const persistenceDisabled = source === "local-development";
const filteredUsers = useMemo(() => {
const query = keyword.trim().toLowerCase();
return users.filter((user) => {
if (query && !`${user.displayName} ${user.email ?? ""} ${user.id}`.toLowerCase().includes(query)) {
return false;
}
if (roleFilter && !user.roles.some((role) => role === roleFilter || role.replace("-", "") === roleFilter.replace("-", ""))) {
return false;
}
return statusFilter === "all" || user.status === statusFilter;
});
}, [keyword, roleFilter, statusFilter, users]);
const hasActiveFilters = Boolean(keyword.trim() || roleFilter || statusFilter !== "all");
const mutationDisabled = persistenceDisabled || loading || Boolean(loadError);
const createPending = operations.isPending("users", "邀请用户");
const editPending = editingUserId ? operations.isPending(editingUserId, "编辑用户") : false;
const summaryAvailable = !loading && !loadError;
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -267,34 +261,48 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<PageFrame
kicker="身份"
title="用户管理"
status={source === "api" ? "账号 API 已连接" : "本地开发数据"}
status={loadError ? "账号 API 不可用" : source === "api" ? "账号 API 已连接" : "本地开发数据"}
metrics={[
{ label: "用户", value: `${users.length}`, tone: "success" },
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
{ label: "待审核", value: `${counts.pending}`, tone: "warning" }
{ label: "用户", value: summaryAvailable ? `${users.length}` : "--", tone: "success" },
{ label: "角色", value: summaryAvailable ? `${counts.roleCount}` : "--", tone: "neutral" },
{ label: "待审核", value: summaryAvailable ? `${counts.pending}` : "--", tone: "warning" }
]}
/>
{loading && <LoadingState label="正在加载用户列表…" />}
{loadError && <ErrorState title="用户 API 暂不可用" reason={loadError} diagnosticId="user-management:fallback" compact />}
{loadError && <ErrorState title="用户 API 暂不可用" reason={loadError} diagnosticId="user-management:list" onRetry={() => void loadUsers()} compact />}
<section className="console-panel">
<div className="panel-header">
<h2>访</h2>
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
<span className="page-status">{source === "api" ? "平台数据" : "本地开发样例 / 禁止假成功"}</span>
<button type="button" className="primary-command" disabled={persistenceDisabled} onClick={() => setCreateOpen(true)}>
<span className="page-status">{source === "local-development" ? "本地开发样例 / 仅查看" : loadError ? "API 不可用" : "平台数据"}</span>
<button type="button" className="primary-command" disabled={mutationDisabled} onClick={() => setCreateOpen(true)}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "邀请用户"}</span>
<span>{mutationDisabled ? "等待 API" : "邀请用户"}</span>
</button>
</div>
<AccessFlowGuide />
<RoleImpactGuide />
{users.length === 0 ? (
<div className="resource-filter-bar" role="search" aria-label="用户筛选">
<input type="search" value={keyword} placeholder="搜索名称、邮箱或用户 ID" aria-label="搜索用户" onChange={(event) => setKeyword(event.target.value)} />
<select value={roleFilter} aria-label="按角色筛选" onChange={(event) => setRoleFilter(event.target.value)}>
<option value=""></option>
{roleOptions.map((role) => <option key={role.value} value={role.value}>{role.label}</option>)}
</select>
<select value={statusFilter} aria-label="按状态筛选" onChange={(event) => setStatusFilter(event.target.value as UserStatus | "all")}>
<option value="all"></option>
{statusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
{hasActiveFilters && <button type="button" className="inline-link-command" onClick={() => { setKeyword(""); setRoleFilter(""); setStatusFilter("all"); }}></button>}
</div>
{!loading && !loadError && users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。邀请用户后会在这里显示 API 连接结果。" actionLabel="邀请用户" onAction={() => setCreateOpen(true)} />
) : !loading && !loadError && filteredUsers.length === 0 ? (
<EmptyState title="没有匹配用户" description="调整名称、角色或状态筛选后再试。" actionLabel="清除筛选" onAction={() => { setKeyword(""); setRoleFilter(""); setStatusFilter("all"); }} />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
{filteredUsers.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
@@ -307,7 +315,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
<button type="button" className="theme-upload" disabled={persistenceDisabled} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
<button type="button" className="theme-upload" disabled={mutationDisabled || operations.isPending(user.id, "编辑用户")} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
<UserPen size={13} />
</button>
@@ -315,7 +323,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<span></span>
<select
value={statusDrafts[user.id] ?? user.status}
disabled={persistenceDisabled}
disabled={mutationDisabled || operations.isPending(user.id, "更新用户状态")}
aria-label={`选择 ${user.displayName} 状态`}
onChange={(event) => setStatusDrafts((current) => ({ ...current, [user.id]: event.target.value as UserStatus }))}
>
@@ -329,7 +337,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
<button
type="button"
className="theme-upload"
disabled={persistenceDisabled || (statusDrafts[user.id] ?? user.status) === user.status}
disabled={mutationDisabled || operations.isPending(user.id, "更新用户状态") || (statusDrafts[user.id] ?? user.status) === user.status}
aria-label={`应用 ${user.displayName} 状态变更`}
onClick={() => applyUserStatus(user)}
>
@@ -343,7 +351,7 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
)}
</section>
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => setCreateOpen(false)}>
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => { if (!createPending) setCreateOpen(false); }}>
<form className="management-form dialog-form" onSubmit={createUser}>
<div className="form-guidance management-form-wide">
<strong></strong>
@@ -400,18 +408,18 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
</div>
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={() => setCreateOpen(false)}>
<button type="button" disabled={createPending} onClick={() => setCreateOpen(false)}>
</button>
<button type="submit" className="confirm-primary" disabled={persistenceDisabled}>
<button type="submit" className="confirm-primary" disabled={mutationDisabled || createPending}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "发送邀请"}</span>
<span>{mutationDisabled ? "等待 API" : createPending ? "发送中…" : "发送邀请"}</span>
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={closeEdit}>
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={() => { if (!editPending) closeEdit(); }}>
{editDraft && (
<form className="management-form dialog-form" onSubmit={(event) => void saveUserEdit(event)}>
<label>
@@ -454,12 +462,12 @@ export function UsersPage({ session, operations, initialState }: UsersPageProps)
</div>
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={closeEdit}>
<button type="button" disabled={editPending} onClick={closeEdit}>
</button>
<button type="submit" className="confirm-primary">
<button type="submit" className="confirm-primary" disabled={editPending}>
<UserPen size={14} />
<span></span>
<span>{editPending ? "保存中…" : "保存用户"}</span>
</button>
</div>
</form>
+2
View File
@@ -5,6 +5,7 @@ import { AiProvidersPage } from "./AiProvidersPage";
import { HomePage } from "./HomePage";
import { MaintenancePage } from "./MaintenancePage";
import { PluginsPage } from "./PluginsPage";
import { PluginPageHostPage } from "./PluginPageHostPage";
import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage";
@@ -14,6 +15,7 @@ export const pageRegistry: Record<PageId, ComponentType<PageComponentProps>> = {
home: HomePage,
servers: ServersPage,
serverDetail: ServerDetailPage,
pluginPage: PluginPageHostPage,
plugins: PluginsPage,
profileSettings: ProfileSettingsPage,
users: UsersPage,