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