Complete platform management workflows

This commit is contained in:
npc0-hue
2026-07-14 16:39:37 +08:00
parent 7e05d0a4e7
commit 4f33f761a3
106 changed files with 11313 additions and 460 deletions
+53 -6
View File
@@ -2,21 +2,68 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AiProvidersPage } from "./AiProvidersPage";
import type { AiProviderResponse } from "../api/types";
const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
timeoutMs: 30000,
status: "active",
redactionPolicy: "default"
};
describe("AiProvidersPage", () => {
it("renders the AI provider management workflow", () => {
const html = renderToStaticMarkup(<AiProvidersPage />);
it("renders the loading state before API data arrives", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ listState: "loading" }} />);
expect(html).toContain("AI 提供商管理");
expect(html).toContain("正在加载 AI 提供商");
});
it("renders an empty API state when the platform returns no providers", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [], listState: "ready", source: "api" }} />);
expect(html).toContain("暂无 AI 提供商");
expect(html).toContain("page-summary-chip");
expect(html).not.toContain("metric-card");
expect(html).toContain("平台还没有返回任何提供商");
expect(html).toContain("新增提供商");
expect(html).not.toContain('value="secret://providers/"');
expect(html).not.toContain("OpenAI Relay");
});
it("renders local-development fixtures with an explicit status label", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "local-development" }} />);
expect(html).toContain("OpenAI Relay");
expect(html).toContain("Local Ollama");
expect(html).toContain("新增");
expect(html).toContain("保存");
expect(html).toContain("本地开发");
expect(html).toContain("secret://providers/openai");
expect(html).toContain("测试");
expect(html).toContain("模型");
expect(html).toContain("编辑");
expect(html).toContain("更多");
});
it("renders guided provider setup for an existing provider", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], selectedId: provider.id, listState: "ready", source: "api" }} />);
expect(html).toContain("配置流程");
expect(html).toContain("提供商预设");
expect(html).toContain("保存前检查");
expect(html).toContain("测试已保存配置");
expect(html).toContain("发现模型并填入");
expect(html).toContain("secret://providers/...");
expect(html).not.toContain("api.example.test");
});
it("does not render raw key field names", () => {
const html = renderToStaticMarkup(<AiProvidersPage />);
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "api" }} />);
expect(html).not.toContain("apiKey=");
expect(html).not.toContain("rawApiKey");
+412 -124
View File
@@ -1,88 +1,170 @@
import { Candy, CheckCircle2, FlaskConical, Power, Sparkles, WandSparkles } from "lucide-react";
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
import { EmptyState } from "../components/StateViews";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
aiProviderToForm,
emptyAiProviderForm,
type AiProviderActionState,
type AiProviderFilter,
type AiProviderFormState,
summarizeAiProviders,
type AiProviderViewState
type AiProviderListState,
type AiProviderPageInitialState,
type AiProviderViewState,
summarizeAiProviders
} from "../contracts/aiProviders";
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
import { aiProviderCreateRequestFromForm, aiProviderRetireRequest, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
import { cx } from "../utils/classes";
const seedProviders: AiProviderResponse[] = [
interface AiProvidersPageProps extends Partial<PageComponentProps> {
initialState?: AiProviderPageInitialState;
}
interface ProviderPreset {
id: string;
label: string;
summary: string;
draft: Partial<AiProviderFormState>;
}
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
const providerPresets: ProviderPreset[] = [
{
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
timeoutMs: 30000,
status: "active",
redactionPolicy: "default"
id: "openai-relay",
label: "OpenAI Relay",
summary: "平台中转,适合公网 OpenAI 兼容网关。",
draft: {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-4.1, gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ai.local",
name: "Local Ollama",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "env://OLLAMA_API_KEY",
models: ["llama3.1", "qwen2.5-coder"],
defaultModel: "qwen2.5-coder",
relayMode: "local",
timeoutMs: 20000,
status: "disabled",
redactionPolicy: "local"
id: "claude-relay",
label: "Claude Relay",
summary: "平台托管 Anthropic 兼容配置,密钥只留引用。",
draft: {
id: "ai.claude",
name: "Claude Relay",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet, claude-haiku",
defaultModel: "claude-sonnet",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "gemini-relay",
label: "Gemini Relay",
summary: "平台托管 Google Gemini 配置,保存后发现模型。",
draft: {
id: "ai.gemini",
name: "Gemini Relay",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-pro",
defaultModel: "gemini-pro",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ollama-local",
label: "Ollama Local",
summary: "本机或内网模型服务,默认走本地模式。",
draft: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "secret://providers/ollama-local",
modelsText: "llama3.1, qwen2.5",
defaultModel: "llama3.1",
relayMode: "local",
redactionPolicy: "default"
}
}
];
export function AiProvidersPage() {
const [providers, setProviders] = useState<AiProviderResponse[]>(seedProviders);
const [selectedId, setSelectedId] = useState(seedProviders[0]?.id ?? "");
export function AiProvidersPage({ initialState }: 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");
const [listError, setListError] = useState(initialState?.listError ?? "");
const [selectedId, setSelectedId] = useState(initialState?.selectedId ?? "");
const [filter, setFilter] = useState<AiProviderFilter>("all");
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(seedProviders[0]));
const [viewState, setViewState] = useState<AiProviderViewState>("local");
const [action, setAction] = useState<AiProviderActionState | null>(null);
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(initialSelectedProvider));
const [formMode, setFormMode] = useState<"create" | "edit" | null>(initialSelectedProvider ? "edit" : null);
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 [confirmBusy, setConfirmBusy] = useState(false);
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
useEffect(() => {
let cancelled = false;
if (initialState?.providers) {
setListState(initialState.listState ?? "ready");
setViewState(initialState.source ?? "local-development");
return () => {
cancelled = true;
};
}
platformApiClient
.listAiProviders()
.then((response) => {
if (cancelled || response.items.length === 0) {
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(response.items[0].id);
setForm(aiProviderToForm(response.items[0]));
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
})
.catch(() => {
if (!cancelled) {
setViewState("local");
.catch((error: unknown) => {
if (cancelled) {
return;
}
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
});
return () => {
cancelled = true;
};
}, []);
}, [initialState?.listState, initialState?.providers, initialState?.selectedId, initialState?.source]);
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
const filteredProviders = useMemo(
() => providers.filter((provider) => filter === "all" || provider.status === filter),
[filter, providers]
);
const selectedProvider = providers.find((provider) => provider.id === selectedId);
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
setForm((current) => ({ ...current, [key]: value }));
@@ -90,18 +172,71 @@ export function AiProvidersPage() {
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
setFormCheck(null);
}
function selectProvider(provider: AiProviderResponse) {
setSelectedId(provider.id);
setForm(aiProviderToForm(provider));
setFormMode("edit");
setAction(null);
setFormCheck(null);
setExpandedProviderId(null);
}
function startCreate() {
setSelectedId("");
setForm(emptyAiProviderForm());
setFormMode("create");
setAction(null);
setConfirmRetire(null);
setFormCheck(null);
setExpandedProviderId(null);
}
function closeForm() {
setFormMode(null);
setFormCheck(null);
}
function applyProviderPreset(preset: ProviderPreset) {
setForm((current) => ({
...current,
...preset.draft,
id: formMode === "edit" ? current.id : preset.draft.id ?? current.id
}));
setFormCheck({ status: "pending", message: `已套用 ${preset.label} 预设。保存前请确认 Base URL、secret 引用和默认模型。` });
}
function runFormPreflight() {
const missing: string[] = [];
const models = form.modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
if (!form.id.trim()) {
missing.push("ID");
}
if (!form.name.trim()) {
missing.push("名称");
}
if (!form.baseUrl.trim()) {
missing.push("Base URL");
}
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) {
missing.push("secret://providers/... 密钥引用");
}
if (models.length === 0) {
missing.push("至少一个模型");
}
if (!Number.isFinite(Number.parseInt(form.timeoutMs, 10)) || Number.parseInt(form.timeoutMs, 10) <= 0) {
missing.push("有效超时");
}
setFormCheck(
missing.length > 0
? { status: "failed", message: `保存前检查未通过:请补充 ${missing.join("、")}` }
: { status: "succeeded", message: "保存前检查通过。保存后先测试配置,测试成功再启用。" }
);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -117,46 +252,103 @@ export function AiProvidersPage() {
setSelectedId(saved.id);
setForm(aiProviderToForm(saved));
setViewState("api");
setAction({ providerId: saved.id, label: "save", success: true, message: "saved" });
} catch {
const local = localProviderFromForm(form, existing ? selectedProvider?.status ?? "active" : "active");
upsertProvider(local);
setSelectedId(local.id);
setForm(aiProviderToForm(local));
setViewState("local");
setAction({ providerId: local.id, label: "save", success: true, message: "saved locally" });
setListState("ready");
setListError("");
setAction({ providerId: saved.id, label: "save", success: true, message: "已保存" });
setFormCheck(null);
setFormMode(null);
} catch (error) {
setViewState("error");
const message = errorMessage(error, "保存失败");
setAction({ providerId: form.id.trim() || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message });
}
}
async function handleStatus(provider: AiProviderResponse) {
setExpandedProviderId(null);
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
try {
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status });
} catch {
const updated = { ...provider, status: nextStatus };
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: nextStatus });
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
} catch (error) {
setAction({ providerId: provider.id, label: "status", success: false, message: errorMessage(error, "状态更新失败") });
}
}
async function handleRetire(provider: AiProviderResponse) {
setConfirmBusy(true);
try {
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
upsertProvider(retired);
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
setConfirmRetire(null);
} catch (error) {
setAction({ providerId: provider.id, label: "retire", success: false, message: errorMessage(error, "退役失败,可能仍被引用") });
} finally {
setConfirmBusy(false);
}
}
async function handleTest(provider: AiProviderResponse) {
setExpandedProviderId(null);
try {
const result = await platformApiClient.testAiProvider(provider.id);
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
} catch {
const success = provider.status === "active";
setAction({ providerId: provider.id, label: "test", success, message: success ? "metadata validation passed" : "provider must be active" });
} catch (error) {
setAction({ providerId: provider.id, label: "test", success: false, message: errorMessage(error, "测试失败") });
}
}
async function handleModels(provider: AiProviderResponse) {
setExpandedProviderId(null);
try {
const result = await platformApiClient.listAiProviderModels(provider.id);
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length}` });
} catch {
setAction({ providerId: provider.id, label: "models", success: true, message: `${provider.models.length}` });
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, "模型刷新失败") });
}
}
async function handleSavedFormTest() {
const providerId = form.id.trim() || selectedId;
if (formMode !== "edit" || !providerId) {
setFormCheck({ status: "failed", message: "请先保存提供商,再测试已保存配置。" });
return;
}
setFormCheck({ status: "pending", message: "正在测试已保存配置…" });
try {
const result = await platformApiClient.testAiProvider(providerId);
setAction({ providerId, label: "test", success: result.success, message: result.message });
setFormCheck({ status: result.success ? "succeeded" : "failed", message: result.success ? `${result.message}。测试成功后可在更多菜单启用。` : result.message });
} catch (error) {
const message = errorMessage(error, "测试失败");
setAction({ providerId, label: "test", success: false, message });
setFormCheck({ status: "failed", message });
}
}
async function handleDiscoverModelsForForm() {
const providerId = form.id.trim() || selectedId;
if (formMode !== "edit" || !providerId) {
setFormCheck({ status: "failed", message: "请先保存提供商,再从平台发现模型。" });
return;
}
setFormCheck({ status: "pending", message: "正在发现模型…" });
try {
const result = await platformApiClient.listAiProviderModels(providerId);
setForm((current) => ({
...current,
modelsText: result.models.join(", "),
defaultModel: result.defaultModel ?? result.models[0] ?? current.defaultModel
}));
setAction({ providerId, label: "models", success: true, message: `${result.models.length} 个模型` });
setFormCheck({ status: "succeeded", message: `已填入 ${result.models.length} 个模型;保存后生效。` });
} catch (error) {
const message = errorMessage(error, "模型发现失败");
setAction({ providerId, label: "models", success: false, message });
setFormCheck({ status: "failed", message });
}
}
@@ -176,23 +368,23 @@ export function AiProvidersPage() {
AI
</h1>
</div>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewState === "api" ? "已连接" : viewState === "saving" ? "保存中" : "本地视图"}</span>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewStateLabel(viewState)}</span>
</header>
<div className="metric-grid ai-provider-metrics">
<article className="metric-card metric-tone-neutral">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.total}</strong>
</article>
<article className="metric-card metric-tone-success">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.active}</strong>
</article>
<article className="metric-card metric-tone-warning">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.models}</strong>
</article>
</div>
<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>
</div>
<div className="page-summary-chip summary-tone-success">
<dt></dt>
<dd>{metrics.active}</dd>
</div>
<div className="page-summary-chip summary-tone-warning">
<dt></dt>
<dd>{metrics.models}</dd>
</div>
</dl>
<div className="ai-provider-toolbar" aria-label="provider filters">
{(["all", "active", "disabled", "error"] as AiProviderFilter[]).map((item) => (
@@ -205,8 +397,16 @@ export function AiProvidersPage() {
<span></span>
</button>
</div>
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
<div className="ai-provider-workspace">
{listState === "loading" && <LoadingState label="正在加载 AI 提供商…" />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => window.location.reload()} />}
{listState === "ready" && providers.length === 0 && (
<EmptyState icon={<FlaskConical size={26} />} title="暂无 AI 提供商" description="平台还没有返回任何提供商。创建第一个平台托管的 AI 提供商后,这里会显示 API 数据。" actionLabel="新增提供商" onAction={startCreate} />
)}
{listState === "ready" && providers.length > 0 && (
<div className="provider-table-wrap">
<table className="provider-table">
<thead>
@@ -238,50 +438,83 @@ export function AiProvidersPage() {
<td>
<code className="secret-ref">{provider.apiKeyRef}</code>
</td>
<td>
<div className="row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" title="启用或禁用" aria-label={`${provider.name} 启用或禁用`} onClick={() => void handleStatus(provider)}>
<Power size={15} />
</button>
<button type="button" title="测试配置" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<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)}>
<FlaskConical size={15} />
<span></span>
</button>
<button type="button" title="刷新模型" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<button type="button" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<Sparkles size={15} />
<span></span>
</button>
<button type="button" aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<WandSparkles size={15} />
<span></span>
</button>
<button
type="button"
className={cx(expandedProviderId === provider.id && "row-action-button-active")}
aria-expanded={expandedProviderId === provider.id}
aria-label={`${provider.name} 更多操作`}
onClick={() => setExpandedProviderId((current) => (current === provider.id ? null : provider.id))}
>
<MoreHorizontal size={15} />
<span></span>
</button>
</div>
{expandedProviderId === provider.id && (
<div className="inline-action-menu" role="menu" aria-label={`${provider.name} 更多操作`}>
<button type="button" role="menuitem" onClick={() => void handleStatus(provider)}>
<Power size={14} />
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
</button>
<button
type="button"
role="menuitem"
className="danger-command"
onClick={() => {
setExpandedProviderId(null);
setConfirmRetire(provider);
}}
>
<UserRoundMinus size={14} />
<span>退</span>
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredProviders.length === 0 && (
<EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />
)}
{filteredProviders.length === 0 && <EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />}
</div>
)}
<form className="provider-form" onSubmit={(event) => void handleSubmit(event)}>
<div className="form-header">
<h2>{selectedProvider ? "编辑提供商" : "新增提供商"}</h2>
{action && (
<span className={cx("action-result", action.success ? "action-result-success" : "action-result-failed")}>
<CheckCircle2 size={14} />
{action.message}
</span>
)}
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={closeForm}>
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
<ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设">
{providerPresets.map((preset) => (
<button key={preset.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(preset)}>
<strong>{preset.label}</strong>
<span>{preset.summary}</span>
</button>
))}
</div>
<label>
ID
<input name="id" value={form.id} onChange={handleInput} disabled={Boolean(selectedProvider)} />
<span>ID</span>
<input name="id" value={form.id} onChange={handleInput} disabled={formMode === "edit"} />
<small className="field-help"> ID ai.openai</small>
</label>
<label>
<span></span>
<input name="name" value={form.name} onChange={handleInput} />
</label>
<div className="form-grid">
<label>
<span></span>
<select name="kind" value={form.kind} onChange={handleInput}>
<option value="openai-compatible">OpenAI Compatible</option>
<option value="openai">OpenAI</option>
@@ -292,7 +525,7 @@ export function AiProvidersPage() {
</select>
</label>
<label>
<span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
@@ -301,48 +534,103 @@ export function AiProvidersPage() {
</label>
</div>
<label>
Base URL
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<small className="field-help"></small>
</label>
<label>
<span></span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
<small className="field-help"> secret://providers/... 引用,不要粘贴 raw API key。</small>
</label>
<label>
<span></span>
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
<small className="field-help"></small>
</label>
<div className="form-grid">
<label>
<span></span>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
</label>
<label>
ms
<span> ms</span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
</label>
</div>
<label>
<span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
<small className="field-help"> default Bearer token </small>
</label>
<button type="submit" className="primary-command" title="保存提供商">
<WandSparkles size={16} />
<span></span>
</button>
<div className="form-helper-actions">
<button type="button" className="theme-upload" onClick={runFormPreflight}>
</button>
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleSavedFormTest()}>
{formMode === "edit" ? "测试已保存配置" : "保存后可测试"}
</button>
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleDiscoverModelsForForm()}>
{formMode === "edit" ? "发现模型并填入" : "保存后可发现模型"}
</button>
</div>
{formCheck && <ResultBadge status={formCheck.status} label={formCheck.message} />}
<div className="confirm-actions">
<button type="button" onClick={closeForm}>
</button>
<button type="submit" className="confirm-primary">
<WandSparkles size={16} />
<span></span>
</button>
</div>
</form>
</div>
</ManagementDialog>
<ConfirmDialog
open={confirmRetire !== null}
title="退役 AI 提供商"
description={`确认将 ${confirmRetire?.name ?? ""}${confirmRetire?.id ?? ""})退役为停用状态?如果仍被引用,平台会拒绝这次变更。`}
confirmLabel="确认退役"
danger
busy={confirmBusy}
onCancel={() => setConfirmRetire(null)}
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
/>
</section>
);
}
function localProviderFromForm(form: AiProviderFormState, status: AiProviderStatus): AiProviderResponse {
const request = aiProviderCreateRequestFromForm(form);
return {
...request,
defaultModel: request.defaultModel,
status
};
function ProviderSetupGuide() {
return (
<div className="form-guidance provider-setup-guide">
<strong></strong>
<span> secret </span>
</div>
);
}
function errorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error)) {
return fallback;
}
if (error.message.toLowerCase().includes("reference")) {
return `${fallback}${error.message}`;
}
return error.message || fallback;
}
function viewStateLabel(viewState: AiProviderViewState): string {
switch (viewState) {
case "api":
return "平台 API";
case "local-development":
return "本地开发";
case "saving":
return "保存中";
default:
return "连接失败";
}
}
function filterLabel(filter: AiProviderFilter): string {
+25 -1
View File
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { HomePage } from "./HomePage";
import { MaintenancePage } from "./MaintenancePage";
import { PluginsPage } from "./PluginsPage";
import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
@@ -10,6 +11,7 @@ import { UsersPage } from "./UsersPage";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
import type { UserResponse } from "../api/types";
const adminUser: CurrentUserView = {
id: "user-admin",
@@ -30,6 +32,17 @@ const noopOperations: OperationTracker = {
isPending: () => false
};
const reviewUser: UserResponse = {
id: "user-reviewer",
displayName: "Plugin Reviewer",
email: "reviewer@example.test",
status: "pending",
roles: ["server-admin"],
profile: { contactNote: "needs approval" },
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
function pageProps(params: PageComponentProps["params"] = {}): PageComponentProps {
return {
session: adminUser,
@@ -86,11 +99,22 @@ describe("first-party console pages", () => {
});
it("renders user access review context", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} />);
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [reviewUser], loading: false, source: "local-development" }} />);
expect(html).toContain("用户管理");
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("needs approval");
expect(html).toContain("本地开发样例 / 禁止假成功");
});
it("renders maintenance triage entry points", () => {
const html = renderToStaticMarkup(<MaintenancePage {...pageProps()} />);
expect(html).toContain("系统维护");
expect(html).toContain("维护排障入口");
expect(html).toContain("节点详情");
expect(html).toContain("最近失败任务");
expect(html).toContain("审计异常");
});
it("renders profile settings as a full page", () => {
+281 -32
View File
@@ -1,16 +1,20 @@
import { Sparkles, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Activity, ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AuditEventResponse, RunEndpointResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { cx } from "../utils/classes";
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
export function MaintenancePage() {
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const refreshEndpoints = useCallback(async () => {
setEndpoints({ status: "loading" });
@@ -32,10 +36,71 @@ export function MaintenancePage() {
}
}, []);
useEffect(() => {
const refreshJobs = useCallback(async () => {
setJobs({ status: "loading" });
try {
const response = await platformApiClient.listJobs();
setJobs({ status: "ready", data: response.items });
} catch (error) {
setJobs({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshServers = useCallback(async () => {
setServers({ status: "loading" });
try {
const response = await platformApiClient.listServerInstances();
setServers({ status: "ready", data: response.items });
} catch (error) {
setServers({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshAll = useCallback(() => {
void refreshEndpoints();
void refreshEvents();
}, [refreshEndpoints, refreshEvents]);
void refreshJobs();
void refreshServers();
}, [refreshEndpoints, refreshEvents, refreshJobs, refreshServers]);
useEffect(() => {
refreshAll();
}, [refreshAll]);
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
const eventItems = events.status === "ready" ? events.data : [];
const jobItems = jobs.status === "ready" ? jobs.data : [];
const serverItems = servers.status === "ready" ? servers.data : [];
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
const failedAuditCount = eventItems.filter((event) => event.result !== "success").length;
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
async function retryJob(job: JobResponse) {
const retryStamp = Date.now();
const operationId = operations.begin({ intent: "重试任务", targetKind: "platform", targetId: job.id, requester: session.displayName });
setTriageResult({ status: "pending", label: `正在重试 ${job.id}` });
try {
const retried = await platformApiClient.createJob({
id: `${job.id}-retry-${retryStamp}`,
serverInstanceId: job.serverInstanceId,
runEndpointId: job.runEndpointId,
capability: job.capability,
targetKey: job.targetKey,
inputRef: job.inputRef,
idempotencyKey: `web-retry-${job.id}-${retryStamp}`,
progress: { percent: 0, message: `retry of ${job.id}` }
});
operations.succeed(operationId, `已创建重试任务:${retried.id}`, retried);
setTriageResult({ status: "succeeded", label: `已创建重试任务 ${retried.id}` });
void refreshJobs();
} catch (error) {
const message = error instanceof Error ? error.message : "任务重试失败";
operations.fail(operationId, message);
setTriageResult({ status: "failed", label: message });
}
}
return (
<div className="maintenance-page">
@@ -46,19 +111,40 @@ export function MaintenancePage() {
<WandSparkles size={22} style={{ verticalAlign: "-3px" }} />
</h1>
</div>
<button
type="button"
className="icon-command"
onClick={() => {
void refreshEndpoints();
void refreshEvents();
}}
>
<button type="button" className="icon-command" onClick={refreshAll}>
<Sparkles size={16} />
<span></span>
</button>
</header>
<div className="form-guidance maintenance-triage-intro">
<strong></strong>
<span> run </span>
</div>
<section className="maintenance-triage-grid" aria-label="维护排障入口">
<button type="button" className="triage-card" onClick={() => void refreshEndpoints()}>
<ServerCog size={18} />
<span></span>
<strong>{endpoints.status === "ready" ? `${unhealthyEndpointCount} 个需关注` : "加载中"}</strong>
<small></small>
</button>
<button type="button" className="triage-card" onClick={() => void refreshJobs()}>
<ListChecks size={18} />
<span></span>
<strong>{jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"}</strong>
<small></small>
</button>
<button type="button" className="triage-card" onClick={() => void refreshEvents()}>
<Activity size={18} />
<span></span>
<strong>{events.status === "ready" ? `${failedAuditCount} 条异常` : "加载中"}</strong>
<small></small>
</button>
</section>
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
<section className="console-panel" aria-label="run endpoints">
<div className="panel-header">
<h2></h2>
@@ -67,26 +153,104 @@ export function MaintenancePage() {
{endpoints.status === "error" && (
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} compact />
)}
{servers.status === "error" && <ErrorState title="相关服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
{endpoints.status === "ready" && endpoints.data.length === 0 && (
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
)}
{endpoints.status === "ready" && endpoints.data.length > 0 && (
<div className="resource-list">
{endpoints.data.map((endpoint) => (
<article key={endpoint.id} className="resource-list-item">
<div>
<strong>{endpoint.displayName}</strong>
<span className="provider-id">{endpoint.id}</span>
<div className="resource-list maintenance-node-list">
{endpoints.data.map((endpoint) => {
const relatedServers = serverItems.filter((server) => server.runEndpointId === endpoint.id);
const firstRelatedServer = relatedServers[0];
return (
<article key={endpoint.id} className="resource-list-item maintenance-node-item">
<div>
<strong>{endpoint.displayName}</strong>
<span className="provider-id">{endpoint.id}</span>
</div>
<span className={cx("status-pill", endpointStatusClass(endpoint))}>{endpointStatusLabel(endpoint)}</span>
<span>
{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} {endpoint.capacity.queuedJobs}
</span>
<span>{heartbeatReason(endpoint)}</span>
<details className="node-detail">
<summary></summary>
<span> {endpoint.version}</span>
<span> {endpoint.capabilities.slice(0, 4).join(" / ") || "未上报"}</span>
<span> {relatedServers.length}</span>
</details>
<div className="maintenance-actions">
<button type="button" className="theme-upload" onClick={() => void refreshEndpoints()}>
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
</button>
</div>
</article>
);
})}
</div>
)}
</section>
<section className="console-panel" aria-label="failed jobs">
<div className="panel-header">
<h2></h2>
</div>
{jobs.status === "loading" && <LoadingState label="正在加载任务…" compact />}
{jobs.status === "error" && <ErrorState title="任务加载失败" reason={jobs.reason} diagnosticId="maintenance-jobs" onRetry={() => void refreshJobs()} compact />}
{jobs.status === "ready" && failedJobs.length === 0 && (
<EmptyState title="暂无失败任务" description="最近任务没有失败记录;如果节点异常,请先查看运行节点心跳和容量。" actionLabel="刷新任务" onAction={() => void refreshJobs()} />
)}
{jobs.status === "ready" && failedJobs.length > 0 && (
<div className="operation-list">
{failedJobs.map((job) => {
const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined;
const endpoint = endpointById.get(job.runEndpointId);
return (
<div key={job.id} className="operation-item">
<div className="operation-item-head">
<strong>{job.capability}</strong>
<span className="status-pill status-error">{jobStateLabel(job.state)}</span>
</div>
<div className="operation-meta">
<span>
<code>{job.id}</code>
</span>
<span> {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
<span> {endpoint ? endpoint.displayName : job.runEndpointId}</span>
<span>{progressMessage(job)}</span>
<span>{formatTimestamp(job.updatedAt)}</span>
</div>
<div className="maintenance-actions">
<button type="button" className="theme-upload" onClick={() => void retryJob(job)}>
<RotateCcw size={13} />
</button>
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
</button>
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
</button>
</div>
</div>
<span className={cx("status-pill", endpoint.status === "online" ? "status-active" : endpoint.status === "offline" ? "status-error" : "status-disabled")}>
{endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status}
</span>
<span>
{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} {endpoint.capacity.queuedJobs}
</span>
<span> {new Date(endpoint.lastHeartbeatAt).toLocaleString()}</span>
</article>
))}
);
})}
</div>
)}
</section>
@@ -108,7 +272,7 @@ export function MaintenancePage() {
<div key={event.id} className="operation-item">
<div className="operation-item-head">
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
<span className={cx("status-pill", event.result === "success" ? "status-active" : "status-error")}>{event.result}</span>
<span className={cx("status-pill", auditResultClass(event))}>{event.result}</span>
</div>
<div className="operation-meta">
<span>
@@ -118,7 +282,20 @@ export function MaintenancePage() {
<span>
{event.resourceKind}/{event.resourceId}
</span>
<span>{new Date(event.createdAt).toLocaleString()}</span>
<span>{formatTimestamp(event.createdAt)}</span>
</div>
<div className="maintenance-actions">
<button type="button" className="theme-upload" onClick={() => void refreshEvents()}>
</button>
<button
type="button"
className="theme-upload"
disabled={event.resourceKind !== "server-instance"}
onClick={() => event.resourceKind === "server-instance" && onNavigate("serverDetail", { serverId: event.resourceId })}
>
</button>
</div>
</div>
))}
@@ -128,3 +305,75 @@ export function MaintenancePage() {
</div>
);
}
function endpointStatusClass(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) <= 5) {
return "status-active";
}
if (endpoint.status === "offline") {
return "status-error";
}
return "status-disabled";
}
function endpointStatusLabel(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5) {
return "心跳延迟";
}
return endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status;
}
function heartbeatReason(endpoint: RunEndpointResponse): string {
const age = heartbeatAgeMinutes(endpoint.lastHeartbeatAt);
if (!Number.isFinite(age)) {
return `心跳时间异常:${endpoint.lastHeartbeatAt}`;
}
if (endpoint.status === "offline") {
return `心跳异常:节点离线,最后 ${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
if (age > 5) {
return `心跳异常:${Math.round(age)} 分钟未更新`;
}
if (endpoint.capacity.runningJobs >= endpoint.capacity.maxJobs) {
return "容量已满:等待任务会继续排队";
}
return `心跳正常:${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
function heartbeatAgeMinutes(value: string): number {
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) {
return Number.POSITIVE_INFINITY;
}
return (Date.now() - timestamp) / 60000;
}
function auditResultClass(event: AuditEventResponse): string {
return event.result === "success" ? "status-active" : "status-error";
}
function jobStateLabel(state: JobResponse["state"]): string {
switch (state) {
case "queued":
return "排队";
case "accepted":
return "已接收";
case "running":
return "运行中";
case "succeeded":
return "成功";
case "cancelled":
return "已取消";
default:
return "失败";
}
}
function progressMessage(job: JobResponse): string {
return job.progress.message ? `原因 ${job.progress.message}` : `进度 ${job.progress.percent}%`;
}
function formatTimestamp(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
+11
View File
@@ -31,6 +31,8 @@ describe("PluginsPage", () => {
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "loading" }} />);
expect(html).toContain("插件市场");
expect(html).toContain("page-summary-chip");
expect(html).not.toContain("metric-card");
expect(html).toContain("正在加载插件市场");
});
@@ -54,6 +56,7 @@ describe("PluginsPage", () => {
expect(html).toContain("安装");
expect(html).toContain("启用");
expect(html).toContain("停用");
expect(html).toContain('role="dialog"');
expect(html).not.toContain("billing");
expect(html).not.toContain("/Users/");
expect(html).not.toContain("unix://");
@@ -71,4 +74,12 @@ describe("PluginsPage", () => {
expect(html).toContain("本地演示数据");
expect(html).toContain("本地演示数据仅用于前端开发");
});
it("does not open a plugin detail pane until a plugin is selected", () => {
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "ready", plugins: [marketplacePlugin] }} />);
expect(html).toContain("Example Server");
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain("manifest validated");
});
});
+16 -53
View File
@@ -3,10 +3,10 @@ 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 { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { pluginCatalog } from "../contracts/shell";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
@@ -59,28 +59,28 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
serverType: serverType.trim() || undefined,
capability: capability.trim() || undefined
};
if (initialState?.plugins) {
setListState(initialState.listState ?? "ready");
setListError(initialState.listError ?? "");
setPlugins(initialState.plugins);
setSelectedId(initialState.selectedId ?? "");
setDetail(initialState.detail ?? null);
setUsingFallback(initialState.usingFallback ?? false);
return;
}
try {
const response = await platformApiClient.listMarketplacePlugins(filter);
setPlugins(response.items);
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : response.items[0]?.id || ""));
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : ""));
setUsingFallback(false);
setListState("ready");
} catch (error) {
if (import.meta.env.DEV) {
const fallback = fallbackMarketplacePlugins(filter);
setPlugins(fallback);
setSelectedId((current) => (current && fallback.some((plugin) => plugin.id === current) ? current : fallback[0]?.id || ""));
setUsingFallback(true);
setListState("ready");
setListError("");
return;
}
setListState("error");
setListError(error instanceof Error ? error.message : "插件市场加载失败");
setPlugins([]);
setDetail(null);
}
}, [capability, keyword, serverType, statusFilter]);
}, [capability, initialState, keyword, serverType, statusFilter]);
useEffect(() => {
void refresh();
@@ -248,13 +248,13 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
</section>
)}
{selectedId && (
<section className="drawer-panel plugin-detail-panel" aria-label="plugin marketplace detail">
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { setSelectedId(""); setDetail(null); setDetailError(""); }}>
<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)} />}
</section>
)}
</div>
</ManagementDialog>
</div>
);
}
@@ -326,43 +326,6 @@ function DetailStat({ label, value }: { label: string; value: string }) {
);
}
function fallbackMarketplacePlugins(filter: MarketplacePluginFilterRequest): MarketplacePluginResponse[] {
const keyword = filter.keyword?.trim().toLowerCase() ?? "";
return pluginCatalog
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.validation,
version: plugin.version,
serverType: plugin.serverType,
serverDisplayName: plugin.serverType,
supportedOs: ["linux"],
manifestRef: `manifest://${plugin.id}/${plugin.version}`,
createFormSchemaRef: "schemas/create-form.schema.json",
capabilities: ["process.install", "process.start", "logs.read"],
declaredPermissions: plugin.permissions,
permissions: {
ai: plugin.permissions.includes("ai.invoke"),
logs: plugin.permissions.includes("server.logs.read"),
files: plugin.permissions.some((permission) => permission.includes("files")),
jobs: plugin.permissions.includes("server.lifecycle"),
artifacts: plugin.permissions.some((permission) => permission.includes("artifacts"))
},
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: plugin.bridgeActions,
pages: [{ key: "overview", title: "Overview", path: "/overview", permissions: plugin.permissions, bridgeActions: plugin.bridgeActions }],
tags: [plugin.serverType],
aiPurposes: plugin.permissions.includes("ai.invoke") ? ["logs.diagnose"] : [],
validationViolations: plugin.status === "invalid" ? [plugin.validation] : undefined,
status: plugin.status,
source: "local-development"
}))
.filter((plugin) => !filter.status || filter.status === "all" || plugin.status === filter.status)
.filter((plugin) => !filter.serverType || plugin.serverType === filter.serverType)
.filter((plugin) => !filter.capability || plugin.capabilities.includes(filter.capability) || plugin.bridgeActions.includes(filter.capability))
.filter((plugin) => !keyword || [plugin.id, plugin.name, plugin.serverType, ...plugin.tags, ...plugin.capabilities].some((value) => value.toLowerCase().includes(keyword)));
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right));
}
+1
View File
@@ -18,6 +18,7 @@ Do not put shared API clients, shared DTOs, route definitions, or bridge contrac
Pages must use the shared black-mecha / magical-girl visual system from `../theme/` instead of page-local card systems or unrelated palettes.
- Reuse shared shell, card, panel, table, drawer, dialog, status, command, log, diff, plugin group, and operation history classes from `theme/base.css`.
- Keep management lists, grids, and tables full-width. Create, edit, and detail workflows belong in modals, drawers, or detail routes, not in permanent right-side panes or inline form panels beside/above the list.
- Keep page surfaces translucent enough for the selected built-in or uploaded background desktop to remain visible.
- Ambient magical particles are global shell chrome. Pages must not add fixed decorative sparkles, hearts, moons, snowflakes, sigils, or custom backdrop layers.
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
@@ -45,6 +45,15 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
});
it("uses typed server metadata and archive APIs without local fallback mutation", () => {
expect(serverDetailPageSource).toContain("updateServerInstance(instance.id");
expect(serverDetailPageSource).toContain("archiveServerInstance(instance.id)");
expect(serverDetailPageSource).toContain("serverMetadataUpdateRequestFromForm");
expect(serverDetailPageSource).toContain("canArchiveServer(instance.state)");
expect(serverDetailPageSource).toContain("配置读取不可用");
expect(serverDetailPageSource).not.toContain("fallbackConfig");
});
it("does not locally mutate visible config after dispatching approval jobs", () => {
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
@@ -60,6 +69,26 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("Bearer ");
});
it("surfaces run distribution and client-manager workflows through platform-mediated APIs", () => {
expect(serverDetailPageSource).toContain("getServerRuntimeActions");
expect(serverDetailPageSource).toContain("generateRunDistribution");
expect(serverDetailPageSource).toContain("downloadLatestRunDistribution");
expect(serverDetailPageSource).toContain("pushRunUpdate");
expect(serverDetailPageSource).toContain("generateClientManager");
expect(serverDetailPageSource).toContain("resetClientManagerKey");
expect(serverDetailPageSource).toContain("checkDependencies");
expect(serverDetailPageSource).toContain("installDependencies");
expect(serverDetailPageSource).toContain("listServerLiveLogs");
expect(serverDetailPageSource).toContain("requestLogBackfill");
expect(serverDetailPageSource).toContain("safeRuntimeRef");
expect(serverDetailPageSource).not.toContain("authKey");
expect(serverDetailPageSource).not.toContain("password=");
expect(serverDetailPageSource).not.toContain("unix://");
expect(serverDetailPageSource).not.toContain("tcp://");
expect(serverDetailPageSource).not.toContain("mysql://");
expect(serverDetailPageSource).not.toContain("sqlite://");
});
it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => {
expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"');
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"');
+503 -18
View File
@@ -1,26 +1,29 @@
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
ConfigDiffLineResponse,
ArtifactDownloadReferenceResponse,
ArtifactResponse,
ClientManagerDistributionResponse,
GamePluginResponse,
JobResponse,
LogEntryBody,
LogStreamResponse,
RunDistributionResponse,
ServerConfigDiffPreviewResponse,
ServerConfigResponse,
ServerInstanceResponse,
ServerMemberResponse,
ServerMetricsResponse
ServerMetricsResponse,
ServerRuntimeActionsResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, pluginLabel } from "../contracts/serverManagement";
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
@@ -30,7 +33,16 @@ import {
type PluginControlGroupView,
type ServerDetailSection
} from "../contracts/workspace";
import { serverLifecycleCommandRequest } from "../schemas/serverManagement";
import {
clientManagerBuildRequest,
dependencyJobRequest,
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverArchiveConfirmation,
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { cx } from "../utils/classes";
@@ -38,7 +50,6 @@ import { stateLabel, statusClass } from "./ServersPage";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
const fallbackConfig = "# server.properties\nmax-players=20\nmotd=Welcome to the server\npvp=true\nview-distance=8\n";
const defaultConfigKey = "server.properties";
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
@@ -49,6 +60,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -59,14 +71,19 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse] = await Promise.all([
const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId)
platformApiClient.listJobs(serverId),
platformApiClient
.getServerRuntimeActions(serverId)
.then((data): LoadState<ServerRuntimeActionsResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerRuntimeActionsResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse);
const artifactLists = await Promise.all(
jobResponse.items.slice(0, 20).map((job) =>
platformApiClient
@@ -79,6 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
} catch (error) {
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setArtifacts([]);
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
}
try {
const metricsResponse = await platformApiClient.listServerMetrics();
@@ -224,6 +242,25 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
</nav>
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
{section === "overview" && (
<RuntimeDistributionSection
instance={instance.data}
runtimeActions={runtimeActions}
session={session}
operations={operations}
onOpenLogs={() => setSection("logs")}
onChanged={() => void refresh()}
/>
)}
{section === "overview" && (
<ServerMetadataSection
instance={instance.data}
session={session}
operations={operations}
onChanged={(next) => setInstance({ status: "ready", data: next })}
onArchived={() => onNavigate("servers")}
/>
)}
{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} />}
@@ -264,6 +301,99 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
return [...byID.values()];
}
interface ServerMetadataSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: (instance: ServerInstanceResponse) => void;
onArchived: () => void;
}
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
useEffect(() => {
setDraft(serverMetadataFormFromInstance(instance));
}, [instance.id, instance.name]);
async function saveMetadata(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const operationId = operations.begin({ intent: "更新服务器信息", targetKind: "server", targetId: instance.id, requester: session.displayName });
setResult({ status: "pending", label: "正在保存服务器信息" });
try {
const updated = await platformApiClient.updateServerInstance(instance.id, serverMetadataUpdateRequestFromForm(draft));
onChanged(updated);
operations.succeed(operationId, `服务器信息已更新:${updated.id}`);
setResult({ status: "succeeded", label: `已更新 ${updated.name}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器信息更新失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "服务器信息更新失败" });
}
}
async function archiveServer() {
setConfirmBusy(true);
const operationId = operations.begin({ intent: "归档服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.archiveServerInstance(instance.id);
operations.succeed(operationId, `服务器已归档:${instance.id}`);
setResult({ status: "succeeded", label: `${instance.name} 已归档` });
setConfirmArchive(null);
onArchived();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器归档失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "归档失败,平台拒绝当前状态" });
} finally {
setConfirmBusy(false);
}
}
return (
<article className="console-panel" aria-label="server metadata">
<div className="panel-header">
<h2>
<Pencil size={16} style={{ verticalAlign: "-2px" }} />
</h2>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveMetadata(event)}>
<label>
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<div className="action-strip">
<button type="submit" className="primary-command" disabled={draft.name.trim() === instance.name}>
<Pencil size={14} />
<span></span>
</button>
<button type="button" className="icon-command danger-command" disabled={!canArchiveServer(instance.state)} onClick={() => setConfirmArchive(serverArchiveConfirmation(instance))}>
<Archive size={14} />
<span></span>
</button>
</div>
</form>
{!canArchiveServer(instance.state) && (
<p className="provider-id" style={{ marginTop: 10 }}>
</p>
)}
<ConfirmDialog
open={confirmArchive !== null}
title="归档服务器"
description={`确认归档 ${confirmArchive?.name ?? ""}${confirmArchive?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
confirmLabel="确认归档"
danger
busy={confirmBusy}
onCancel={() => setConfirmArchive(null)}
onConfirm={() => void archiveServer()}
/>
</article>
);
}
interface ServerAdministratorsSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
@@ -451,6 +581,362 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio
);
}
interface RuntimeDistributionSectionProps {
instance: ServerInstanceResponse;
runtimeActions: LoadState<ServerRuntimeActionsResponse>;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onOpenLogs: () => void;
onChanged: () => void;
}
function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) {
const defaults = runtimeDefaultsForPlugin(instance.pluginId);
const [targetOs, setTargetOs] = useState(defaults.runOs);
const [targetArch, setTargetArch] = useState("amd64");
const [profileKey, setProfileKey] = useState(defaults.clientProfileKey);
const [repositoryUrl, setRepositoryUrl] = useState(defaults.repositoryUrl);
const [sourceRevision, setSourceRevision] = useState(defaults.sourceRevision);
const [probeKey, setProbeKey] = useState(defaults.probeKey);
const [installPlanKey, setInstallPlanKey] = useState(defaults.installPlanKey);
const [logSourceKey, setLogSourceKey] = useState(defaults.logSourceKey);
const [checkpointRef, setCheckpointRef] = useState("");
const [lastRun, setLastRun] = useState<RunDistributionResponse | null>(null);
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const actionByKey = useMemo(() => {
if (runtimeActions.status !== "ready") {
return new Map<string, { available: boolean; reason?: string }>();
}
return new Map(runtimeActions.data.actions.map((action) => [action.key, { available: action.available, reason: action.reason }]));
}, [runtimeActions]);
function canUse(key: string): boolean {
return actionByKey.get(key)?.available ?? false;
}
function reasonFor(key: string): string {
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
}
async function runOperation<T>(intent: string, execute: () => Promise<T>, summarize: (value: T) => string) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
setResult({ status: "pending", label: `${intent} 执行中` });
try {
const value = await execute();
const label = summarize(value);
operations.succeed(operationId, label);
setResult({ status: "succeeded", label });
onChanged();
} catch (error) {
const reason = error instanceof Error ? error.message : `${intent} 失败`;
operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason });
}
}
function latestRunArtifact(): { artifactId: string; checksum?: string } | null {
if (lastRun) {
return { artifactId: lastRun.artifactId, checksum: lastRun.checksum };
}
if (lastDownload) {
return { artifactId: lastDownload.artifactId, checksum: lastDownload.checksum };
}
return null;
}
return (
<article className="console-panel" aria-label="run distribution controls">
<div className="panel-header">
<h2>
<PackageOpen size={16} style={{ verticalAlign: "-2px" }} />
</h2>
{runtimeActions.status === "ready" ? (
<span className={cx("status-pill", runtimeActions.data.runStatus === "online" ? "status-active" : "status-disabled")}>
run {runtimeActions.data.runStatus}
</span>
) : runtimeActions.status === "error" ? (
<ResultBadge status="failed" label={runtimeActions.reason} />
) : (
<span className="page-status"></span>
)}
</div>
{result && (
<div style={{ marginBottom: 10 }}>
<ResultBadge status={result.status} label={result.label} />
</div>
)}
<div className="provider-form" style={{ marginBottom: 12 }}>
<div className="form-grid">
<label>
run
<select value={targetOs} onChange={(event) => setTargetOs(event.target.value)}>
<option value="linux">linux</option>
<option value="windows">windows</option>
<option value="darwin">darwin</option>
</select>
</label>
<label>
<select value={targetArch} onChange={(event) => setTargetArch(event.target.value)}>
<option value="amd64">amd64</option>
<option value="arm64">arm64</option>
</select>
</label>
<label>
profile
<input value={profileKey} onChange={(event) => setProfileKey(event.target.value)} />
</label>
<label>
<input value={repositoryUrl} onChange={(event) => setRepositoryUrl(event.target.value)} />
</label>
<label>
revision
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} />
</label>
<label>
probe
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
</label>
<label>
plan
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
</label>
<label>
<input value={logSourceKey} onChange={(event) => setLogSourceKey(event.target.value)} />
</label>
</div>
</div>
<div className="plugin-group-body">
<RuntimeActionRow
title="run 包"
description={`生成 ${targetOs}/${targetArch} run。包内含当前密钥,界面只显示 generation、artifact 和 secret ref。`}
disabled={!canUse("generate-run")}
reason={reasonFor("generate-run")}
actionLabel="生成 run"
onAction={() =>
void runOperation(
"生成 run",
async () => {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
setLastRun(distribution);
return distribution;
},
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 已生成,artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`
)
}
/>
<RuntimeActionRow
title="run 下载与更新"
description={lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
disabled={!canUse("download-run")}
reason={reasonFor("download-run")}
actionLabel="下载 run"
onAction={() =>
void runOperation(
"下载 run",
async () => {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
setLastDownload(reference);
return reference;
},
(reference) => `下载引用已创建,artifact ${reference.artifactId},有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}`
)
}
secondaryLabel="推送更新"
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
secondaryReason={latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
onSecondary={() =>
void runOperation(
"推送 run 更新",
async () => {
const artifact = latestRunArtifact();
if (!artifact) {
throw new Error("请先生成或下载 run 包");
}
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
},
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`
)
}
/>
<RuntimeActionRow
title="run 密钥"
description="重置后旧 run 包会失效,必须重新生成并重新部署。"
disabled={!canUse("reset-run-key")}
reason={reasonFor("reset-run-key")}
actionLabel="重置 run 密钥"
danger
onAction={() =>
void runOperation(
"重置 run 密钥",
() => platformApiClient.resetRunKey(instance.id),
(key) => `run 密钥已重置,generation ${key.generation}fingerprint ${key.fingerprint}`
)
}
/>
<RuntimeActionRow
title="客户端管理器"
description={lastClient ? `最近构建 ${lastClient.artifactId}generation ${lastClient.keyGeneration}` : "按插件声明的 profile 构建客户端管理器,使用独立密钥。"}
disabled={!canUse("generate-client-manager")}
reason={reasonFor("generate-client-manager")}
actionLabel="生成客户端"
onAction={() =>
void runOperation(
"生成客户端管理器",
async () => {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({ serverInstanceId: instance.id, profileKey, targetOs, targetArch, repositoryUrl, sourceRevision })
);
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器已生成,artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`
)
}
secondaryLabel="下载客户端"
secondaryDisabled={!canUse("download-client-manager")}
secondaryReason={reasonFor("download-client-manager")}
onSecondary={() =>
void runOperation(
"下载客户端管理器",
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }),
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}`
)
}
/>
<RuntimeActionRow
title="客户端密钥"
description="客户端管理器和 run 使用不同密钥。重置后旧客户端必须重新生成。"
disabled={!canUse("reset-client-manager-key")}
reason={reasonFor("reset-client-manager-key")}
actionLabel="重置客户端密钥"
danger
onAction={() =>
void runOperation(
"重置客户端密钥",
() => platformApiClient.resetClientManagerKey(instance.id, { componentKind: "client-manager", componentKey: profileKey }),
(key) => `客户端密钥已重置,generation ${key.generation}fingerprint ${key.fingerprint}`
)
}
/>
<RuntimeActionRow
title="依赖"
description={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
disabled={!canUse("dependencies-check")}
reason={reasonFor("dependencies-check")}
actionLabel="依赖检查"
onAction={() =>
void runOperation(
"依赖检查",
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
(job) => `依赖检查任务已排队,job ${job.id}`
)
}
secondaryLabel="依赖安装"
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
onSecondary={() =>
void runOperation(
"依赖安装",
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
(job) => `依赖安装任务已排队,job ${job.id}`
)
}
/>
<RuntimeActionRow
title="日志"
description="实时日志来自平台日志 API,历史日志通过 backfill job 返回 cursor/ref。"
disabled={!canUse("live-logs")}
reason={reasonFor("live-logs")}
actionLabel="实时日志"
onAction={onOpenLogs}
secondaryLabel="历史回填"
secondaryDisabled={!canUse("historical-logs")}
secondaryReason={reasonFor("historical-logs")}
onSecondary={() =>
void runOperation(
"历史日志回填",
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
(job) => `历史日志回填任务已排队,job ${job.id}`
)
}
>
<label>
checkpoint ref
<input value={checkpointRef} placeholder="可选 artifact://logs/checkpoint/..." onChange={(event) => setCheckpointRef(event.target.value)} />
</label>
</RuntimeActionRow>
</div>
</article>
);
}
interface RuntimeActionRowProps {
title: string;
description: string;
disabled: boolean;
reason: string;
actionLabel: string;
danger?: boolean;
onAction: () => void;
secondaryLabel?: string;
secondaryDisabled?: boolean;
secondaryReason?: string;
onSecondary?: () => void;
children?: ReactNode;
}
function RuntimeActionRow({ title, description, disabled, reason, actionLabel, danger, onAction, secondaryLabel, secondaryDisabled, secondaryReason, onSecondary, children }: RuntimeActionRowProps) {
return (
<div className="plugin-control-row">
<span>
<strong>{title}</strong>
<p>{description}</p>
{disabled && <span className="provider-id">{reason}</span>}
{children}
</span>
<div className="action-strip">
<button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : actionLabel} onClick={onAction}>
<Sparkles size={14} />
<span>{actionLabel}</span>
</button>
{secondaryLabel && onSecondary && (
<button type="button" className="icon-command" disabled={secondaryDisabled} title={secondaryDisabled ? secondaryReason : secondaryLabel} onClick={onSecondary}>
<Download size={14} />
<span>{secondaryLabel}</span>
</button>
)}
</div>
</div>
);
}
function runtimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {
runOs: isScum ? "windows" : "linux",
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
sourceRevision: "main",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
logSourceKey: isScum ? "server-log" : "latest-log"
};
}
function safeRuntimeRef(ref: string): string {
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
return ref;
}
return "[redacted-ref]";
}
interface LogsSectionProps {
serverId: string;
}
@@ -471,8 +957,8 @@ function LogsSection({ serverId }: LogsSectionProps) {
const refresh = useCallback(async () => {
setStreams({ status: "loading" });
try {
const response = await platformApiClient.listLogStreams();
const serverStreams = response.items.filter((stream) => stream.serverInstanceId === serverId);
const response = await platformApiClient.listServerLiveLogs(serverId);
const serverStreams = response.items;
setStreams({ status: "ready", data: serverStreams });
const collected: Array<LogEntryBody & { source: string }> = [];
for (const stream of serverStreams) {
@@ -659,9 +1145,9 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
setDraft(response.content);
} catch {
setConfig({ status: "ready", data: { content: fallbackConfig, source: "local" } });
setDraft(fallbackConfig);
} catch (error) {
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
setDraft("");
}
}, [serverId]);
@@ -717,9 +1203,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
<article className="console-panel" aria-label="server configuration">
<div className="panel-header">
<h2></h2>
{config.status === "ready" && (
<span className="page-status">{config.data.source === "api" ? `配置版本 v${instance.configVersion}` : "本地示例配置(配置读取接口未提供)"}</span>
)}
{config.status === "ready" && <span className="page-status"> v{instance.configVersion}</span>}
</div>
{writeOperation && (
<div style={{ marginBottom: 10 }}>
@@ -736,6 +1220,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
</div>
)}
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
{config.status === "ready" && (
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
@@ -1125,7 +1610,7 @@ interface LlmSectionProps {
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
const [prompt, setPrompt] = useState("");
const [currentConfig, setCurrentConfig] = useState<string>(fallbackConfig);
const [currentConfig, setCurrentConfig] = useState<string>("");
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
@@ -1140,7 +1625,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
}
})
.catch(() => {
// keep the local fallback config
setCurrentConfig("");
});
return () => {
cancelled = true;
+141 -5
View File
@@ -14,7 +14,14 @@ import {
type ServerCreateFormState
} from "../contracts/serverManagement";
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
import { serverCreateRequestFromForm } from "../schemas/serverManagement";
import {
clientManagerBuildRequest,
dependencyJobRequest,
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverCreateRequestFromForm
} from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace";
import { cx } from "../utils/classes";
@@ -114,6 +121,56 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
}
}
async function handleQuickRuntimeAction(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction) {
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
const intent = quickRuntimeActionLabel(action);
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
try {
let message = "运行操作已提交";
if (action === "generate-run") {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
message = `run 包已生成,artifact ${distribution.artifactId}`;
} else if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
message = `run 下载引用已创建,artifact ${reference.artifactId}`;
} else if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`;
} else if (action === "generate-client-manager") {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({
serverInstanceId: instance.id,
profileKey: defaults.clientProfileKey,
targetOs: defaults.clientOs,
targetArch: "amd64",
repositoryUrl: defaults.repositoryUrl,
sourceRevision: "main"
})
);
message = `客户端管理器已生成,artifact ${distribution.artifactId}`;
} else if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
message = `依赖检查任务已排队,job ${job.id}`;
} else if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
message = `依赖安装任务已排队,job ${job.id}`;
} else if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
message = "已打开服务器详情,可切换到日志页查看实时日志";
} else if (action === "historical-logs") {
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
message = `历史日志回填任务已排队,job ${job.id}`;
}
operations.succeed(operationId, message);
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId);
}
}
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
return (
@@ -240,7 +297,13 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
{visibleCards.length > 0 && (
<div className="server-card-grid" aria-label="server list">
{visibleCards.map((card) => (
<ServerCard key={card.instance.id} card={card} metricsPending={metricsPending} onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })} />
<ServerCard
key={card.instance.id}
card={card}
metricsPending={metricsPending}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
/>
))}
</div>
)}
@@ -248,17 +311,28 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
);
}
type ServerQuickRuntimeAction =
| "generate-run"
| "download-run"
| "push-run-update"
| "generate-client-manager"
| "dependencies-check"
| "dependencies-install"
| "live-logs"
| "historical-logs";
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;
onOpen: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void;
}
function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
const online = serverIsOnline(instance.state);
return (
<button type="button" className="server-card" onClick={onOpen} aria-label={`打开 ${instance.name} 详情`}>
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
<div className="server-card-head">
<span>
<strong>{instance.name}</strong>
@@ -289,10 +363,72 @@ function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
</button>
<div className="action-strip" style={{ justifyContent: "space-between" }}>
<button type="button" className="icon-command" onClick={onOpen}>
<Sparkles size={14} />
<span></span>
</button>
<details className="runtime-action-menu">
<summary className="icon-command"></summary>
<div className="action-list">
{serverQuickActions.map((action) => (
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
<Candy size={13} />
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</details>
</div>
</article>
);
}
const serverQuickActions: ServerQuickRuntimeAction[] = [
"generate-run",
"download-run",
"push-run-update",
"generate-client-manager",
"dependencies-check",
"dependencies-install",
"live-logs",
"historical-logs"
];
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
switch (action) {
case "generate-run":
return "生成 run";
case "download-run":
return "下载 run";
case "push-run-update":
return "推送更新";
case "generate-client-manager":
return "生成客户端";
case "dependencies-check":
return "依赖检查";
case "dependencies-install":
return "依赖安装";
case "live-logs":
return "实时日志";
case "historical-logs":
return "历史日志";
}
}
function quickRuntimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {
runOs: isScum ? "windows" : "linux",
clientOs: "windows",
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
logSourceKey: isScum ? "server-log" : "latest-log"
};
}
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
if (typeof value === "number" && Number.isFinite(value)) {
return format(value);
+101
View File
@@ -0,0 +1,101 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UsersPage } from "./UsersPage";
import type { UserResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
const adminUser: CurrentUserView = {
id: "user-admin",
displayName: "Operator",
status: "active",
roles: ["platformAdmin"],
capabilities: capabilitiesForRoles(["platformAdmin"]),
profile: {},
source: "local"
};
const serverUser: CurrentUserView = {
...adminUser,
id: "user-server",
roles: ["serverAdmin"],
capabilities: capabilitiesForRoles(["serverAdmin"])
};
const managedUser: UserResponse = {
id: "user-reviewer",
displayName: "Plugin Reviewer",
email: "reviewer@example.test",
status: "pending",
roles: ["server-admin"],
profile: { phone: "13800000000", qq: "10001", contactNote: "needs approval" },
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
const noopOperations: OperationTracker = {
operations: [],
begin: () => "op-test",
update: () => undefined,
succeed: () => undefined,
fail: () => undefined,
isPending: () => false
};
function pageProps(session = adminUser): PageComponentProps {
return {
session,
params: {},
operations: noopOperations,
onNavigate: () => undefined,
onLogout: async () => undefined,
onProfileSave: async () => session,
onThemePreferenceSave: async () => ({ userId: session.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-07-03T00:00:00Z" })
};
}
describe("UsersPage", () => {
it("renders an empty API-backed state without local users", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [], loading: false, source: "api" }} />);
expect(html).toContain("暂无用户");
expect(html).toContain("page-summary-chip");
expect(html).not.toContain("metric-card");
expect(html).not.toContain("Plugin Reviewer");
});
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("停用");
expect(html).toContain("邀请用户");
expect(html).toContain("审核申请");
expect(html).toContain("绑定服务器范围");
expect(html).toContain("角色影响");
expect(html).toContain("needs approval");
});
it("does not render create or edit forms inline on the list page", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
expect(html).toContain("访问列表");
expect(html).toContain("邀请用户");
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain('aria-label="编辑用户"');
});
it("does not render account maintenance controls for non-admins", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps(serverUser)} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
expect(html).toContain("当前账号不能管理用户");
expect(html).not.toContain("邀请用户");
expect(html).not.toContain("停用");
});
});
+312 -74
View File
@@ -1,13 +1,16 @@
import { HeartHandshake, Sparkles, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { HeartHandshake, Sparkles, UserPen, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
import { 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";
import { cx } from "../utils/classes";
const roleOptions = [
@@ -37,12 +40,29 @@ const fallbackUsers: UserResponse[] = userAccess.map((user, index) => ({
updatedAt: "2026-07-03T00:00:00Z"
}));
export function UsersPage({ session, operations }: PageComponentProps) {
const [users, setUsers] = useState<UserResponse[]>(fallbackUsers);
const [loading, setLoading] = useState(true);
const [source, setSource] = useState<"api" | "local">("local");
const [loadError, setLoadError] = useState("");
interface UsersPageInitialState {
users?: UserResponse[];
loading?: boolean;
source?: UserListSource;
loadError?: string;
}
interface UsersPageProps extends PageComponentProps {
initialState?: UsersPageInitialState;
}
export function UsersPage({ session, operations, initialState }: UsersPageProps) {
const [users, setUsers] = useState<UserResponse[]>(initialState?.users ?? []);
const [loading, setLoading] = useState(initialState?.loading ?? true);
const [source, setSource] = useState<UserListSource>(initialState?.source ?? "api");
const [loadError, setLoadError] = useState(initialState?.loadError ?? "");
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string }>();
const [createOpen, setCreateOpen] = useState(false);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<UserEditFormState | null>(null);
const [confirmRemoval, setConfirmRemoval] = useState<UserRemovalConfirmationState | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [statusDrafts, setStatusDrafts] = useState<Record<string, UserStatus>>({});
const [draft, setDraft] = useState<UserCreateRequest>({
displayName: "",
email: "",
@@ -53,6 +73,15 @@ export function UsersPage({ session, operations }: PageComponentProps) {
useEffect(() => {
let cancelled = false;
if (initialState?.users) {
setUsers(initialState.users);
setLoading(initialState.loading ?? false);
setSource(initialState.source ?? "api");
setLoadError(initialState.loadError ?? "");
return () => {
cancelled = true;
};
}
void platformApiClient
.listUsers()
.then((response) => {
@@ -64,9 +93,9 @@ export function UsersPage({ session, operations }: PageComponentProps) {
})
.catch(() => {
if (!cancelled) {
setUsers(fallbackUsers);
setSource("local");
setLoadError("账号 API 加载失败,当前显示本地样例;创建与状态更新仍会尝试平台 API。");
setUsers(import.meta.env.DEV ? fallbackUsers : []);
setSource(import.meta.env.DEV ? "local-development" : "api");
setLoadError(import.meta.env.DEV ? "账号 API 加载失败,当前显示本地开发样例;状态变更会被平台 API 拒绝或写入。" : "账号 API 加载失败,未显示本地样例数据。");
}
})
.finally(() => {
@@ -77,7 +106,11 @@ export function UsersPage({ session, operations }: PageComponentProps) {
return () => {
cancelled = true;
};
}, []);
}, [initialState]);
useEffect(() => {
setStatusDrafts(Object.fromEntries(users.map((user) => [user.id, user.status])));
}, [users]);
const counts = useMemo(
() => ({
@@ -87,34 +120,96 @@ export function UsersPage({ session, operations }: PageComponentProps) {
}),
[users]
);
const persistenceDisabled = source === "local-development";
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const request: UserCreateRequest = {
...draft,
displayName: draft.displayName.trim(),
email: draft.email?.trim(),
roles: draft.roles.length ? draft.roles : ["server-admin"],
profile: {
phone: draft.profile?.phone?.trim(),
qq: draft.profile?.qq?.trim(),
contactNote: draft.profile?.contactNote?.trim()
}
};
const operationId = operations.begin({ intent: "创建用户", targetKind: "platform", targetId: "users", requester: session.displayName });
setResult({ status: "pending", label: `正在创建用户 ${operationId}` });
const request = userCreateRequestFromDraft(draft);
const operationId = operations.begin({ intent: "邀请用户", targetKind: "platform", targetId: "users", requester: session.displayName });
setResult({ status: "pending", label: `正在发送邀请 ${operationId}` });
try {
const created = await platformApiClient.createUser(request);
setUsers((current) => [created, ...current.filter((user) => user.id !== created.id)]);
operations.succeed(operationId, `用户已创建:${created.id}`);
operations.succeed(operationId, `用户邀请已创建:${created.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `创建 ${created.displayName}` });
setResult({ status: "succeeded", label: `邀请 ${created.displayName}` });
setCreateOpen(false);
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户创建失败");
setResult({ status: "failed", label: "用户创建失败,未写入数据库" });
}
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
}
function startEdit(user: UserResponse) {
setEditingUserId(user.id);
setEditDraft(userEditFormFromResponse(user));
setResult(undefined);
}
function closeEdit() {
setEditingUserId(null);
setEditDraft(null);
}
function updateEdit<K extends keyof UserEditFormState>(key: K, value: UserEditFormState[K]) {
setEditDraft((current) => (current ? { ...current, [key]: value } : current));
}
function toggleEditRole(role: string) {
setEditDraft((current) => {
if (!current) {
return current;
}
const roles = current.roles.includes(role) ? current.roles.filter((item) => item !== role) : [...current.roles, role];
return { ...current, roles };
});
}
async function saveUserEdit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingUserId || !editDraft) {
return;
}
const operationId = operations.begin({ intent: "编辑用户", targetKind: "platform", targetId: editingUserId, requester: session.displayName });
setResult({ status: "pending", label: "正在保存用户" });
try {
const updated = await platformApiClient.updateUser(editingUserId, userUpdateRequestFromEditForm(editDraft));
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
setEditingUserId(null);
setEditDraft(null);
setSource("api");
setLoadError("");
operations.succeed(operationId, `用户已更新:${updated.id}`);
setResult({ status: "succeeded", label: `已更新 ${updated.displayName}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户更新失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "用户更新失败,未写入数据库" });
}
}
async function deactivateUser() {
if (!confirmRemoval) {
return;
}
setConfirmBusy(true);
const user = confirmRemoval.user;
const operationId = operations.begin({ intent: "停用用户", targetKind: "platform", targetId: user.id, requester: session.displayName });
try {
const updated = await platformApiClient.updateUser(user.id, userDeactivateRequest());
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
operations.succeed(operationId, `用户已停用:${updated.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `${updated.displayName} 已停用` });
setConfirmRemoval(null);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户停用失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "停用失败,平台拒绝变更" });
} finally {
setConfirmBusy(false);
}
}
async function setUserStatus(user: UserResponse, status: UserStatus) {
@@ -140,6 +235,18 @@ export function UsersPage({ session, operations }: PageComponentProps) {
});
}
function applyUserStatus(user: UserResponse) {
const nextStatus = statusDrafts[user.id] ?? user.status;
if (nextStatus === user.status) {
return;
}
if (nextStatus === "disabled") {
setConfirmRemoval({ action: "deactivate", user });
return;
}
void setUserStatus(user, nextStatus);
}
if (!isPlatformAdmin(session)) {
return (
<div className="console-page">
@@ -160,7 +267,7 @@ export function UsersPage({ session, operations }: PageComponentProps) {
<PageFrame
kicker="身份"
title="用户管理"
status={source === "api" ? "账号 API 已连接" : "本地回退"}
status={source === "api" ? "账号 API 已连接" : "本地开发数据"}
metrics={[
{ label: "用户", value: `${users.length}`, tone: "success" },
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
@@ -173,10 +280,75 @@ export function UsersPage({ session, operations }: PageComponentProps) {
<section className="console-panel">
<div className="panel-header">
<h2></h2>
<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)}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "邀请用户"}</span>
</button>
</div>
<form className="management-form" onSubmit={createUser}>
<AccessFlowGuide />
<RoleImpactGuide />
{users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。邀请用户后会在这里显示 API 连接结果。" actionLabel="邀请用户" onAction={() => setCreateOpen(true)} />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
<span className="provider-id">{user.email ?? user.id}</span>
</div>
<span className={cx("status-pill", statusClass(user.status))}>
<UserRoundCheck size={13} />
{statusLabel(user.status)}
</span>
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
<button type="button" className="theme-upload" disabled={persistenceDisabled} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
<UserPen size={13} />
</button>
<label className="status-update-control">
<span></span>
<select
value={statusDrafts[user.id] ?? user.status}
disabled={persistenceDisabled}
aria-label={`选择 ${user.displayName} 状态`}
onChange={(event) => setStatusDrafts((current) => ({ ...current, [user.id]: event.target.value as UserStatus }))}
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<button
type="button"
className="theme-upload"
disabled={persistenceDisabled || (statusDrafts[user.id] ?? user.status) === user.status}
aria-label={`应用 ${user.displayName} 状态变更`}
onClick={() => applyUserStatus(user)}
>
<UserRoundCheck size={13} />
</button>
</div>
</article>
))}
</div>
)}
</section>
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => setCreateOpen(false)}>
<form className="management-form dialog-form" onSubmit={createUser}>
<div className="form-guidance management-form-wide">
<strong></strong>
<span></span>
</div>
<label>
<span></span>
<input value={draft.displayName} required onChange={(event) => setDraft((current) => ({ ...current, displayName: event.target.value }))} />
@@ -226,53 +398,119 @@ export function UsersPage({ session, operations }: PageComponentProps) {
</button>
))}
</div>
<button type="submit" className="profile-save-button">
<UserRoundPlus size={14} />
<span></span>
</button>
</form>
</section>
<section className="console-panel">
<div className="panel-header">
<h2>访</h2>
<span className="page-status">{source === "api" ? "平台数据" : "本地样例 / 待同步"}</span>
</div>
{users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。创建用户后会在这里显示 API 连接结果。" />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
<span className="provider-id">{user.email ?? user.id}</span>
</div>
<span className={cx("status-pill", statusClass(user.status))}>
<UserRoundCheck size={13} />
{statusLabel(user.status)}
</span>
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
{statusOptions.map((option) => (
<button
key={option.value}
type="button"
className="theme-upload"
disabled={user.status === option.value}
aria-label={`${user.displayName} 设为${option.label}`}
onClick={() => void setUserStatus(user, option.value)}
>
{option.label}
</button>
))}
</div>
</article>
))}
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={() => setCreateOpen(false)}>
</button>
<button type="submit" className="confirm-primary" disabled={persistenceDisabled}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "发送邀请"}</span>
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={closeEdit}>
{editDraft && (
<form className="management-form dialog-form" onSubmit={(event) => void saveUserEdit(event)}>
<label>
<span></span>
<input value={editDraft.displayName} required onChange={(event) => updateEdit("displayName", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.email} type="email" onChange={(event) => updateEdit("email", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.phone} inputMode="tel" onChange={(event) => updateEdit("phone", event.target.value)} />
</label>
<label>
<span>QQ</span>
<input value={editDraft.qq} inputMode="numeric" onChange={(event) => updateEdit("qq", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.contactNote} onChange={(event) => updateEdit("contactNote", event.target.value)} />
</label>
<label>
<span></span>
<select value={editDraft.status} onChange={(event) => updateEdit("status", event.target.value as UserStatus)}>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="role-selector" aria-label="编辑角色">
{roleOptions.map((role) => (
<button key={role.value} type="button" className={cx("role-chip", editDraft.roles.includes(role.value) && "role-chip-active")} onClick={() => toggleEditRole(role.value)}>
<Sparkles size={13} />
<span>{role.label}</span>
</button>
))}
</div>
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={closeEdit}>
</button>
<button type="submit" className="confirm-primary">
<UserPen size={14} />
<span></span>
</button>
</div>
</form>
)}
</section>
</ManagementDialog>
<ConfirmDialog
open={confirmRemoval !== null}
title="停用用户"
description={`确认停用 ${confirmRemoval?.user.displayName ?? ""}?该账号将无法登录,已有审计记录会保留。`}
confirmLabel="确认停用"
danger
busy={confirmBusy}
onCancel={() => {
if (confirmRemoval) {
setStatusDrafts((current) => ({ ...current, [confirmRemoval.user.id]: confirmRemoval.user.status }));
}
setConfirmRemoval(null);
}}
onConfirm={() => void deactivateUser()}
/>
</div>
);
}
function AccessFlowGuide() {
return (
<div className="workflow-hint-grid" aria-label="用户管理流程提示">
<div className="workflow-hint-card">
<strong></strong>
<span></span>
</div>
<div className="workflow-hint-card">
<strong></strong>
<span></span>
</div>
<div className="workflow-hint-card">
<strong></strong>
<span>/</span>
</div>
</div>
);
}
function RoleImpactGuide() {
return (
<div className="role-impact-list management-form-wide" aria-label="角色影响说明">
<strong></strong>
<span>AI </span>
<span></span>
<span></span>
</div>
);
}