Complete platform management workflows

This commit is contained in:
npc0-hue
2026-07-14 16:39:37 +08:00
parent 7e05d0a4e7
commit 4f33f761a3
106 changed files with 11313 additions and 460 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ Do not define API clients, shared DTOs, route definitions, schemas, or bridge co
## Interaction Rules
Do not use fixed left-list/right-detail master-detail layouts for server or plugin details. Use detail routes, modals, or drawers.
Management list pages must keep the primary list, grid, or table as the full-width working surface. Do not add permanent right-side create/edit/detail panes or fixed left-list/right-form master-detail layouts for users, plugins, AI providers, servers, or similar management resources. Use modals, drawers, or detail routes for create, edit, and detail workflows unless a future OpenSpec change explicitly requires an inline split layout.
## Visual Style Rules
+17 -1
View File
@@ -74,13 +74,29 @@ npm run dev
For Docker, the web console is built with `VITE_PLATFORM_API_BASE_URL=/api/v1` and served by Nginx. Nginx proxies `/api/v1` and `/healthz` to the `platform` compose service, so browser code never needs a direct backend container address.
Current UI behavior is a browser-verifiable console shell with the required first-party page routes. Data-backed workflows, plugin page hosting, and API integration belong to later OpenSpec changes.
Current UI behavior is a browser-verifiable API-backed management console with the required first-party page routes. Server management now includes platform-mediated lifecycle, config, administrator, runtime distribution, dependency, and log workflows.
Server list and server detail surfaces expose runtime actions through platform APIs:
- generate run packages for selected OS/architecture targets.
- download the latest authorized run package.
- push a self-update job to an online run endpoint when the endpoint reports `run.self-update`.
- reset the current run key, which invalidates older run packages until regenerated.
- generate and download plugin-declared client-manager packages, including SCUM-style companion managers.
- reset the current client-manager key separately from the run key.
- request dependency checks and typed dependency install jobs.
- open live server log stream metadata and request historical log backfill jobs.
These screens show safe availability reasons, run online/offline status, job/build/dependency progress, artifact IDs, checksums, key generations, fingerprints, and redacted `secret://runtime-keys/.../current` refs. They must not render raw run/client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct run sockets, backend storage URLs, or large inline log bodies.
Browser walkthrough baseline:
1. Start `npm run dev`.
2. Open the local Vite URL.
3. Verify 首页、服务器管理、插件市场、用户管理、AI 提供商管理 render without visible overlap on desktop and mobile widths.
4. In 服务器管理, verify the server card action menu contains runtime actions without turning the whole card into an accidental click target.
5. In a server detail route, verify the overview renders the 运行分发 section, action availability reasons, dependency/log controls, and safe redacted refs only.
6. Switch black mecha and magical-girl themes when UI styling changed; runtime controls must keep the shared translucent console surfaces and avoid nested double frames.
Automated browser acceptance uses the repository local debug stack:
+55 -3
View File
@@ -117,7 +117,7 @@ async function main() {
{
name: "服务器管理",
hash: "#/servers",
markers: ["服务器管理", server.name, server.id, "创建服务器", "全部", "离线"]
markers: ["服务器管理", server.name, server.id, "创建服务器", "全部", "离线", "运行操作"]
},
{
name: "插件市场",
@@ -126,7 +126,8 @@ async function main() {
"插件市场",
"平台 API",
marketplacePlugin.id,
marketplacePlugin.manifestRef,
marketplacePlugin.name,
"Development plugin",
"process.install",
"process.start",
"process.stop",
@@ -144,7 +145,7 @@ async function main() {
{
name: "AI 提供商管理",
hash: "#/aiProviders",
markers: ["AI 提供商管理", "已连接", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
markers: ["AI 提供商管理", "平台 API", aiProvider.name, aiProvider.apiKeyRef, "密钥引用"]
},
{
name: "服务器详情",
@@ -152,6 +153,10 @@ async function main() {
markers: [
server.name,
`${server.id} · 插件 ${server.pluginId}@${server.pluginVersion} · 节点 ${server.runEndpointId}`,
"运行分发",
"run 包",
"客户端管理器",
"依赖",
"启动",
"停止",
"日志",
@@ -166,6 +171,14 @@ async function main() {
for (const route of routeChecks) {
const state = await verifyBrowserRoute(chrome, route.hash, route.markers, route.name);
evidence.routes.push(state);
if (route.name === "服务器管理") {
const runtimeMenu = await verifyServerQuickRuntimeMenu(chrome, route.name);
evidence.routes.push({
name: "服务器管理 / 运行操作菜单",
url: await chrome.url(),
...runtimeMenu
});
}
}
const pluginControls = await clickAndVerify(chrome, "插件控制", ["Logs 桥接执行", "server.logs.read", "server.artifacts.read", "读取"]);
@@ -295,6 +308,20 @@ async function verifyResponsiveThemeWalkthroughs(chrome, routeChecks, server) {
layout,
textSample: state.textSample
});
if (route.name === "服务器管理") {
const runtimeMenu = await verifyServerQuickRuntimeMenu(chrome, `${scenario.name} / 服务器管理`);
const runtimeMenuLayout = await chrome.layoutSnapshot();
assertNoVisibleLayoutIssues(runtimeMenuLayout, `${scenario.name} / 服务器管理 / 运行操作菜单`);
routeEvidence.push({
name: "服务器管理 / 运行操作菜单",
url: await chrome.url(),
requiredMarkers: runtimeMenu.requiredMarkers,
fallbackScan: runtimeMenu.fallbackScan,
forbiddenFragmentScan: runtimeMenu.forbiddenFragmentScan,
layout: runtimeMenuLayout,
textSample: runtimeMenu.textSample
});
}
}
await chrome.evaluate(() => {
@@ -372,6 +399,31 @@ async function clickAndVerify(chrome, buttonText, markers) {
};
}
async function verifyServerQuickRuntimeMenu(chrome, label) {
const markers = ["生成 run", "下载 run", "推送更新", "生成客户端", "依赖检查", "依赖安装", "实时日志", "历史日志"];
await chrome.evaluate(() => {
const summary = Array.from(document.querySelectorAll("summary")).find((item) => item.textContent?.includes("运行操作"));
if (!(summary instanceof HTMLElement)) {
throw new Error("runtime action menu summary not found");
}
const details = summary.closest("details");
if (!(details instanceof HTMLDetailsElement)) {
throw new Error("runtime action menu container not found");
}
details.open = true;
});
await chrome.waitForText(markers, `${label} runtime action menu`);
const visibleText = await chrome.visibleText();
assertMarkers(visibleText, markers, `${label} runtime action menu`);
scanText(visibleText, `${label} runtime action menu`);
return {
requiredMarkers: markers,
fallbackScan: "passed",
forbiddenFragmentScan: "passed",
textSample: visibleText.slice(0, 1200)
};
}
async function ensureAiProvider(headers) {
const providers = await getJson("/ai-providers", headers);
if (providers.items.some((item) => item.id === "ai.openai")) {
+193 -2
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PlatformApiClient, setPlatformApiSessionToken } from "./client";
import type { AiProviderResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
import type { AiProviderResponse, ArtifactDownloadReferenceResponse, GamePluginResponse, JobResponse, MarketplacePluginResponse, RunEndpointResponse, ServerInstanceResponse } from "./types";
const provider: AiProviderResponse = {
id: "ai.openai",
@@ -105,6 +105,37 @@ const artifact = {
updatedAt: "2026-07-03T00:00:00Z"
};
const runtimeActions = {
serverInstanceId: server.id,
pluginId: plugin.id,
runEndpointId: endpoint.id,
runStatus: "online",
actions: [
{ key: "generate-run", label: "Generate run", available: true },
{ key: "download-run", label: "Download run", available: true },
{ key: "push-run-update", label: "Push run update", available: true },
{ key: "generate-client-manager", label: "Generate client manager", available: true },
{ key: "dependencies-check", label: "Check dependencies", available: true },
{ key: "historical-logs", label: "Historical logs", available: true }
]
};
const runtimeDownload: ArtifactDownloadReferenceResponse = {
artifactId: "artifact-run-1",
ownerKind: "server-instance",
ownerId: server.id,
filename: "run-linux-amd64.zip",
contentType: "application/zip",
sizeBytes: 128,
checksum: "sha256:runchecksum",
state: "available",
downloadUrl: "/api/v1/artifacts/artifact-run-1/content",
expiresAt: "2026-07-03T00:15:00Z",
rangeSupported: true,
chunkSizeBytes: 1048576,
storageBehavior: "platform-memory-transfer-session"
};
describe("PlatformApiClient AI providers", () => {
afterEach(() => {
setPlatformApiSessionToken(null);
@@ -160,6 +191,13 @@ describe("PlatformApiClient AI providers", () => {
if (url.endsWith("/api/v1/server-instances") && (!init?.method || init.method === "GET")) {
return jsonResponse({ items: [server], count: 1 });
}
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "PUT") {
expect(JSON.parse(String(init.body))).toEqual({ name: "Example Survival Renamed" });
return jsonResponse({ ...server, name: "Example Survival Renamed" });
}
if (url.endsWith("/api/v1/server-instances/server-1") && init?.method === "DELETE") {
return new Response(null, { status: 204 });
}
if (url.endsWith("/api/v1/metrics/platform")) {
return jsonResponse({ cpuPercent: 28, memoryPercent: 42, diskPercent: 19, source: "platform-derived", collectedAt: "2026-07-03T00:00:00Z" });
}
@@ -314,6 +352,122 @@ describe("PlatformApiClient AI providers", () => {
if (url.endsWith("/api/v1/server-instances/server-1/administrators/user-2") && init?.method === "DELETE") {
return jsonResponse({ ...server, adminUserIds: [] });
}
if (url.endsWith("/api/v1/server-instances/server-1/runtime/actions")) {
return jsonResponse(runtimeActions);
}
if (url.endsWith("/api/v1/server-instances/server-1/run/generate") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" });
return jsonResponse({
id: "run-dist-1",
serverInstanceId: server.id,
pluginId: plugin.id,
runEndpointId: endpoint.id,
targetOs: "linux",
targetArch: "amd64",
packageFormat: "zip",
artifactId: "artifact-run-1",
checksum: "sha256:runchecksum",
keyGeneration: 1,
secretRef: "secret://runtime-keys/server-1/run/current",
status: "available",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/run/download") && init?.method === "POST") {
return jsonResponse(runtimeDownload);
}
if (url.endsWith("/api/v1/server-instances/server-1/run/key/reset") && init?.method === "POST") {
return jsonResponse({
id: "runtime-key-server-1-run-2",
serverInstanceId: server.id,
componentKind: "run",
secretRef: "secret://runtime-keys/server-1/run/current",
fingerprint: "abc123def456",
generation: 2,
status: "active",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/run/update") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" });
return jsonResponse({
id: "run-update-1",
serverInstanceId: server.id,
runEndpointId: endpoint.id,
artifactId: "artifact-run-1",
checksum: "sha256:runchecksum",
jobId: "job-run-update",
idempotencyKey: "idem-run-update",
status: "queued",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/generate") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({
profileKey: "scum-client-manager",
targetOs: "windows",
targetArch: "amd64",
repositoryUrl: "https://github.com/F88888/scum_client.git",
sourceRevision: "main",
idempotencyKey: "idem-client-generate"
});
return jsonResponse({
id: "client-dist-1",
serverInstanceId: server.id,
pluginId: plugin.id,
profileKey: "scum-client-manager",
targetOs: "windows",
targetArch: "amd64",
repositoryUrl: "https://github.com/F88888/scum_client.git",
sourceRevision: "main",
buildJobId: "client-build-1",
artifactId: "artifact-client-1",
checksum: "sha256:clientchecksum",
keyGeneration: 1,
secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
status: "available",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/download") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ profileKey: "scum-client-manager" });
return jsonResponse({ ...runtimeDownload, artifactId: "artifact-client-1", filename: "scum-client-manager.exe" });
}
if (url.endsWith("/api/v1/server-instances/server-1/client-managers/key/reset") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ componentKind: "client-manager", componentKey: "scum-client-manager" });
return jsonResponse({
id: "runtime-key-server-1-client-2",
serverInstanceId: server.id,
componentKind: "client-manager",
componentKey: "scum-client-manager",
secretRef: "secret://runtime-keys/server-1/client-manager/scum-client-manager/current",
fingerprint: "def456abc123",
generation: 2,
status: "active",
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
});
}
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/check") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", idempotencyKey: "idem-dep-check" });
return jsonResponse({ ...job, id: "job-dep-check", capability: "dependencies.check", targetKey: "dependencies/java-21" });
}
if (url.endsWith("/api/v1/server-instances/server-1/dependencies/install") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" });
return jsonResponse({ ...job, id: "job-dep-install", capability: "dependencies.install", targetKey: "dependencies/install/install-java-linux" });
}
if (url.endsWith("/api/v1/server-instances/server-1/logs/live")) {
return jsonResponse({ items: [], count: 0 });
}
if (url.endsWith("/api/v1/server-instances/server-1/logs/backfill") && init?.method === "POST") {
expect(JSON.parse(String(init.body))).toEqual({ sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" });
return jsonResponse({ ...job, id: "job-log-backfill", capability: "logs.backfill", targetKey: "logs/latest-log", inputRef: "artifact://logs/checkpoint/1" });
}
if (url.endsWith("/api/v1/plugin-bridge/authorize") && init?.method === "POST") {
return jsonResponse({
pluginId: plugin.id,
@@ -371,6 +525,8 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.health()).resolves.toMatchObject({ status: "ok" });
await expect(client.listGamePlugins()).resolves.toMatchObject({ count: 1 });
await expect(client.listServerInstances()).resolves.toMatchObject({ count: 1 });
await expect(client.updateServerInstance(server.id, { name: "Example Survival Renamed" })).resolves.toMatchObject({ name: "Example Survival Renamed" });
await expect(client.archiveServerInstance(server.id)).resolves.toBeUndefined();
await expect(client.getPlatformResourceUsage()).resolves.toMatchObject({ source: "platform-derived", cpuPercent: 28 });
await expect(client.listServerMetrics()).resolves.toMatchObject({ count: 1, items: [{ serverInstanceId: server.id, online: true }] });
await expect(client.getServerConfig(server.id)).resolves.toMatchObject({ content: "server.name=Example Survival #1\n" });
@@ -398,6 +554,41 @@ describe("PlatformApiClient AI providers", () => {
await expect(client.listServerAdministratorCandidates(server.id)).resolves.toMatchObject({ count: 1 });
await expect(client.addServerAdministrator(server.id, { userId: "user-2" })).resolves.toMatchObject({ adminUserIds: ["user-admin-1", "user-2"] });
await expect(client.removeServerAdministrator(server.id, "user-2")).resolves.toMatchObject({ adminUserIds: [] });
const runtime = await client.getServerRuntimeActions(server.id);
expect(runtime.runStatus).toBe("online");
expect(runtime.actions.some((action) => action.key === "generate-run" && action.available)).toBe(true);
await expect(client.generateRunDistribution(server.id, { targetOs: "linux", targetArch: "amd64", idempotencyKey: "idem-run-generate" })).resolves.toMatchObject({
artifactId: "artifact-run-1",
keyGeneration: 1,
secretRef: "secret://runtime-keys/server-1/run/current"
});
await expect(client.downloadLatestRunDistribution(server.id)).resolves.toMatchObject({ artifactId: "artifact-run-1", rangeSupported: true });
await expect(client.resetRunKey(server.id)).resolves.toMatchObject({ componentKind: "run", generation: 2 });
await expect(client.pushRunUpdate(server.id, { artifactId: "artifact-run-1", checksum: "sha256:runchecksum", idempotencyKey: "idem-run-update" })).resolves.toMatchObject({
jobId: "job-run-update",
status: "queued"
});
await expect(
client.generateClientManager(server.id, {
profileKey: "scum-client-manager",
targetOs: "windows",
targetArch: "amd64",
repositoryUrl: "https://github.com/F88888/scum_client.git",
sourceRevision: "main",
idempotencyKey: "idem-client-generate"
})
).resolves.toMatchObject({ artifactId: "artifact-client-1", profileKey: "scum-client-manager" });
await expect(client.downloadLatestClientManager(server.id, { profileKey: "scum-client-manager" })).resolves.toMatchObject({ artifactId: "artifact-client-1" });
await expect(client.resetClientManagerKey(server.id, { componentKind: "client-manager", componentKey: "scum-client-manager" })).resolves.toMatchObject({ generation: 2 });
await expect(client.checkDependencies(server.id, { probeKey: "java-21", idempotencyKey: "idem-dep-check" })).resolves.toMatchObject({ capability: "dependencies.check" });
await expect(client.installDependencies(server.id, { probeKey: "java-21", installPlanKey: "install-java-linux", idempotencyKey: "idem-dep-install" })).resolves.toMatchObject({
capability: "dependencies.install"
});
await expect(client.listServerLiveLogs(server.id)).resolves.toMatchObject({ count: 0 });
await expect(client.requestLogBackfill(server.id, { sourceKey: "latest-log", checkpointRef: "artifact://logs/checkpoint/1", limit: 200, idempotencyKey: "idem-log-backfill" })).resolves.toMatchObject({
capability: "logs.backfill"
});
await expect(client.authorizePluginBridge({ pluginId: plugin.id, routeKey: "logs", action: "logs.query" })).resolves.toMatchObject({
allowed: true
});
@@ -408,7 +599,7 @@ describe("PlatformApiClient AI providers", () => {
client.invokeAI({ requestId: "ai-1", serverInstanceId: server.id, purpose: "config.suggest", prompt: "Tune PVP safely", currentConfig: "server.name=Example Survival #1\n" })
).resolves.toMatchObject({ status: "ok", usage: { mocked: true }, configRecommendation: { diffSummary: "review required" } });
expect(fetchMock).toHaveBeenCalledTimes(24);
expect(fetchMock).toHaveBeenCalledTimes(38);
});
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
+102
View File
@@ -15,7 +15,13 @@ import type {
ArtifactListResponse,
AuthSessionResponse,
AuditEventListResponse,
ClientManagerBuildRequest,
ClientManagerDistributionResponse,
ClientManagerDownloadRequest,
ComponentKeyResponse,
ComponentKeyResetRequest,
CurrentUserResponse,
DependencyJobRequest,
FileOperationDispatchRequest,
FileOperationDispatchResponse,
GamePluginListResponse,
@@ -25,6 +31,7 @@ import type {
JobResponse,
LlmConfigSuggestionRequest,
LlmConfigSuggestionResponse,
LogBackfillRequest,
LogStreamCursorRequest,
LogStreamCursorResponse,
LogStreamListResponse,
@@ -39,7 +46,11 @@ import type {
PluginBridgeExecuteRequest,
PluginBridgeExecuteResponse,
RegisterRequest,
RunDistributionGenerateRequest,
RunDistributionResponse,
RunEndpointListResponse,
RunUpdateJobResponse,
RunUpdateRequest,
ServerConfigResponse,
ServerConfigDiffPreviewRequest,
ServerConfigDiffPreviewResponse,
@@ -49,10 +60,12 @@ import type {
ServerConfigWriteApprovalRequest,
ServerConfigWriteDispatchResponse,
ServerInstanceListResponse,
ServerInstanceUpdateRequest,
ServerInstanceResponse,
ServerMemberListResponse,
ServerMemberRequest,
ServerMetricsListResponse,
ServerRuntimeActionsResponse,
UserCreateRequest,
UserListResponse,
UserProfileUpdateRequest,
@@ -186,6 +199,84 @@ export class PlatformApiClient {
return this.request<JobResponse>(`/jobs/${encodeURIComponent(id)}`);
}
async getServerRuntimeActions(id: string): Promise<ServerRuntimeActionsResponse> {
return this.request<ServerRuntimeActionsResponse>(`/server-instances/${encodeURIComponent(id)}/runtime/actions`);
}
async generateRunDistribution(id: string, request: RunDistributionGenerateRequest): Promise<RunDistributionResponse> {
return this.request<RunDistributionResponse>(`/server-instances/${encodeURIComponent(id)}/run/generate`, {
method: "POST",
body: request
});
}
async downloadLatestRunDistribution(id: string): Promise<ArtifactDownloadReferenceResponse> {
return this.request<ArtifactDownloadReferenceResponse>(`/server-instances/${encodeURIComponent(id)}/run/download`, {
method: "POST",
body: {}
});
}
async resetRunKey(id: string): Promise<ComponentKeyResponse> {
return this.request<ComponentKeyResponse>(`/server-instances/${encodeURIComponent(id)}/run/key/reset`, {
method: "POST",
body: {}
});
}
async pushRunUpdate(id: string, request: RunUpdateRequest): Promise<RunUpdateJobResponse> {
return this.request<RunUpdateJobResponse>(`/server-instances/${encodeURIComponent(id)}/run/update`, {
method: "POST",
body: request
});
}
async generateClientManager(id: string, request: ClientManagerBuildRequest): Promise<ClientManagerDistributionResponse> {
return this.request<ClientManagerDistributionResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/generate`, {
method: "POST",
body: request
});
}
async downloadLatestClientManager(id: string, request: ClientManagerDownloadRequest = {}): Promise<ArtifactDownloadReferenceResponse> {
return this.request<ArtifactDownloadReferenceResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/download`, {
method: "POST",
body: request
});
}
async resetClientManagerKey(id: string, request: ComponentKeyResetRequest): Promise<ComponentKeyResponse> {
return this.request<ComponentKeyResponse>(`/server-instances/${encodeURIComponent(id)}/client-managers/key/reset`, {
method: "POST",
body: request
});
}
async checkDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/check`, {
method: "POST",
body: request
});
}
async installDependencies(id: string, request: DependencyJobRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/dependencies/install`, {
method: "POST",
body: request
});
}
async listServerLiveLogs(id: string): Promise<LogStreamListResponse> {
return this.request<LogStreamListResponse>(`/server-instances/${encodeURIComponent(id)}/logs/live`);
}
async requestLogBackfill(id: string, request: LogBackfillRequest): Promise<JobResponse> {
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, {
method: "POST",
body: request
});
}
async createJob(request: JobCreateRequest): Promise<JobResponse> {
return this.request<JobResponse>("/jobs", { method: "POST", body: request });
}
@@ -230,6 +321,17 @@ export class PlatformApiClient {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`);
}
async updateServerInstance(id: string, request: ServerInstanceUpdateRequest): Promise<ServerInstanceResponse> {
return this.request<ServerInstanceResponse>(`/server-instances/${encodeURIComponent(id)}`, {
method: "PUT",
body: request
});
}
async archiveServerInstance(id: string): Promise<void> {
return this.request<void>(`/server-instances/${encodeURIComponent(id)}`, { method: "DELETE", parseJson: false });
}
async getPlatformResourceUsage(): Promise<PlatformResourceUsageResponse> {
return this.request<PlatformResourceUsageResponse>("/metrics/platform");
}
+127
View File
@@ -109,6 +109,10 @@ export interface ServerInstanceResponse {
updatedAt: string;
}
export interface ServerInstanceUpdateRequest {
name?: string;
}
export interface ServerInstanceListResponse {
items: ServerInstanceResponse[];
count: number;
@@ -233,6 +237,129 @@ export interface ArtifactContentChunk {
storageBehavior?: string;
}
export interface ServerRuntimeActionResponse {
key: string;
label: string;
available: boolean;
reason?: string;
}
export interface ServerRuntimeActionsResponse {
serverInstanceId: string;
pluginId: string;
runEndpointId: string;
runStatus: string;
actions: ServerRuntimeActionResponse[];
}
export interface RunDistributionGenerateRequest {
targetOs: string;
targetArch: string;
idempotencyKey?: string;
}
export interface RunDistributionResponse {
id: string;
serverInstanceId: string;
pluginId: string;
runEndpointId: string;
targetOs: string;
targetArch: string;
packageFormat: string;
artifactId: string;
checksum: string;
keyGeneration: number;
secretRef: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface RunUpdateRequest {
artifactId: string;
checksum?: string;
idempotencyKey?: string;
}
export interface RunUpdateJobResponse {
id: string;
serverInstanceId: string;
runEndpointId: string;
artifactId: string;
checksum: string;
jobId?: string;
idempotencyKey?: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface ClientManagerBuildRequest {
profileKey: string;
targetOs: string;
targetArch: string;
repositoryUrl: string;
sourceRevision?: string;
idempotencyKey?: string;
}
export interface ClientManagerDistributionResponse {
id: string;
serverInstanceId: string;
pluginId: string;
profileKey: string;
targetOs: string;
targetArch: string;
repositoryUrl: string;
sourceRevision: string;
buildJobId: string;
artifactId: string;
checksum: string;
keyGeneration: number;
secretRef: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface ClientManagerDownloadRequest {
profileKey?: string;
}
export interface ComponentKeyResetRequest {
componentKind: "run" | "client-manager" | string;
componentKey?: string;
}
export interface ComponentKeyResponse {
id: string;
serverInstanceId: string;
componentKind: string;
componentKey?: string;
secretRef: string;
fingerprint: string;
generation: number;
status: string;
createdAt: string;
updatedAt: string;
resetAt?: string;
}
export interface DependencyJobRequest {
probeKey: string;
installPlanKey?: string;
targetOs?: string;
targetArch?: string;
idempotencyKey?: string;
}
export interface LogBackfillRequest {
sourceKey: string;
checkpointRef?: string;
limit?: number;
idempotencyKey?: string;
}
export interface PluginBridgeAuthorizeRequest {
pluginId: string;
routeKey: string;
@@ -1,3 +1,4 @@
import { X } from "lucide-react";
import type { ReactNode } from "react";
interface ConfirmDialogProps {
@@ -35,6 +36,36 @@ export function ConfirmDialog({ open, title, description, confirmLabel, danger,
);
}
interface ManagementDialogProps {
open: boolean;
title: string;
description?: ReactNode;
wide?: boolean;
onClose: () => void;
children: ReactNode;
}
export function ManagementDialog({ open, title, description, wide, onClose, children }: ManagementDialogProps) {
if (!open) {
return null;
}
return (
<div className="confirm-backdrop management-dialog-backdrop" role="presentation" onClick={onClose}>
<div className={`drawer-panel management-dialog-panel${wide ? " management-dialog-wide" : ""}`} role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
<div className="panel-header">
<h2>{title}</h2>
<button type="button" className="theme-upload drawer-close" onClick={onClose}>
<X size={14} />
<span></span>
</button>
</div>
{description && <p className="dialog-description">{description}</p>}
{children}
</div>
</div>
);
}
interface UsageMeterProps {
label: string;
percent?: number;
+10 -8
View File
@@ -23,14 +23,16 @@ export function PageFrame({ kicker, title, status, metrics }: PageFrameProps) {
</div>
<span className="page-status">{status}</span>
</header>
<div className="metric-grid">
{metrics.map((metric) => (
<article key={metric.label} className={cx("metric-card", `metric-tone-${metric.tone}`)}>
<span className="metric-label">{metric.label}</span>
<strong className="metric-value">{metric.value}</strong>
</article>
))}
</div>
{metrics.length > 0 && (
<dl className="page-summary-strip" aria-label={`${title} 快速状态`}>
{metrics.map((metric) => (
<div key={metric.label} className={cx("page-summary-chip", `summary-tone-${metric.tone}`)}>
<dt>{metric.label}</dt>
<dd>{metric.value}</dd>
</div>
))}
</dl>
)}
</section>
);
}
+19 -6
View File
@@ -1,7 +1,8 @@
import type { AiProviderKind, AiProviderResponse, AiProviderStatus, AiRelayMode } from "../api/types";
export type AiProviderFilter = AiProviderStatus | "all";
export type AiProviderViewState = "api" | "local" | "saving" | "error";
export type AiProviderListState = "loading" | "ready" | "error";
export type AiProviderViewState = "api" | "local-development" | "saving" | "error";
export interface AiProviderFormState {
id: string;
@@ -30,22 +31,34 @@ export interface AiProviderActionState {
message: string;
}
export interface AiProviderPageInitialState {
providers?: AiProviderResponse[];
selectedId?: string;
listState?: AiProviderListState;
listError?: string;
source?: AiProviderViewState;
action?: AiProviderActionState | null;
}
export function emptyAiProviderForm(): AiProviderFormState {
return {
id: "",
name: "",
kind: "openai-compatible",
baseUrl: "https://api.example.test/v1",
baseUrl: "",
apiKeyRef: "secret://providers/",
modelsText: "gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "direct",
modelsText: "",
defaultModel: "",
relayMode: "relay",
timeoutMs: "30000",
redactionPolicy: "default"
};
}
export function aiProviderToForm(provider: AiProviderResponse): AiProviderFormState {
export function aiProviderToForm(provider?: AiProviderResponse): AiProviderFormState {
if (!provider) {
return emptyAiProviderForm();
}
return {
id: provider.id,
name: provider.name,
@@ -8,6 +8,7 @@ import type {
export type ServerWorkflowViewState = "api" | "local" | "saving";
export type ServerLifecycleActionLabel = "create" | "start" | "stop" | "refresh";
export type ServerRemovalAction = "archive";
export interface ServerCreateFormState {
id: string;
@@ -23,6 +24,17 @@ export interface ServerWorkflowActionState {
message: string;
}
export interface ServerMetadataFormState {
name: string;
}
export interface ServerRemovalConfirmationState {
action: ServerRemovalAction;
serverInstanceId: string;
name: string;
state: ServerInstanceState;
}
export interface ServerManagementSummary {
total: number;
running: number;
@@ -83,3 +95,11 @@ export function defaultServerCreateForm(plugins: GamePluginResponse[], endpoints
runEndpointId: endpoints[0]?.id ?? ""
};
}
export function serverMetadataFormFromInstance(instance: ServerInstanceResponse): ServerMetadataFormState {
return { name: instance.name };
}
export function canArchiveServer(state: ServerInstanceState): boolean {
return state !== "running" && state !== "installing" && state !== "deleted";
}
+31
View File
@@ -0,0 +1,31 @@
import type { UserResponse, UserStatus } from "../api/types";
export type UserListSource = "api" | "local-development";
export type UserRemovalAction = "deactivate";
export interface UserEditFormState {
displayName: string;
email: string;
phone: string;
qq: string;
contactNote: string;
roles: string[];
status: UserStatus;
}
export interface UserRemovalConfirmationState {
action: UserRemovalAction;
user: UserResponse;
}
export function userEditFormFromResponse(user: UserResponse): UserEditFormState {
return {
displayName: user.displayName,
email: user.email ?? "",
phone: user.profile?.phone ?? "",
qq: user.profile?.qq ?? "",
contactNote: user.profile?.contactNote ?? "",
roles: [...user.roles],
status: user.status
};
}
+53 -6
View File
@@ -2,21 +2,68 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AiProvidersPage } from "./AiProvidersPage";
import type { AiProviderResponse } from "../api/types";
const provider: AiProviderResponse = {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
timeoutMs: 30000,
status: "active",
redactionPolicy: "default"
};
describe("AiProvidersPage", () => {
it("renders the AI provider management workflow", () => {
const html = renderToStaticMarkup(<AiProvidersPage />);
it("renders the loading state before API data arrives", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ listState: "loading" }} />);
expect(html).toContain("AI 提供商管理");
expect(html).toContain("正在加载 AI 提供商");
});
it("renders an empty API state when the platform returns no providers", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [], listState: "ready", source: "api" }} />);
expect(html).toContain("暂无 AI 提供商");
expect(html).toContain("page-summary-chip");
expect(html).not.toContain("metric-card");
expect(html).toContain("平台还没有返回任何提供商");
expect(html).toContain("新增提供商");
expect(html).not.toContain('value="secret://providers/"');
expect(html).not.toContain("OpenAI Relay");
});
it("renders local-development fixtures with an explicit status label", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "local-development" }} />);
expect(html).toContain("OpenAI Relay");
expect(html).toContain("Local Ollama");
expect(html).toContain("新增");
expect(html).toContain("保存");
expect(html).toContain("本地开发");
expect(html).toContain("secret://providers/openai");
expect(html).toContain("测试");
expect(html).toContain("模型");
expect(html).toContain("编辑");
expect(html).toContain("更多");
});
it("renders guided provider setup for an existing provider", () => {
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], selectedId: provider.id, listState: "ready", source: "api" }} />);
expect(html).toContain("配置流程");
expect(html).toContain("提供商预设");
expect(html).toContain("保存前检查");
expect(html).toContain("测试已保存配置");
expect(html).toContain("发现模型并填入");
expect(html).toContain("secret://providers/...");
expect(html).not.toContain("api.example.test");
});
it("does not render raw key field names", () => {
const html = renderToStaticMarkup(<AiProvidersPage />);
const html = renderToStaticMarkup(<AiProvidersPage initialState={{ providers: [provider], listState: "ready", source: "api" }} />);
expect(html).not.toContain("apiKey=");
expect(html).not.toContain("rawApiKey");
+412 -124
View File
@@ -1,88 +1,170 @@
import { Candy, CheckCircle2, FlaskConical, Power, Sparkles, WandSparkles } from "lucide-react";
import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, UserRoundMinus } from "lucide-react";
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
import { EmptyState } from "../components/StateViews";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
aiProviderToForm,
emptyAiProviderForm,
type AiProviderActionState,
type AiProviderFilter,
type AiProviderFormState,
summarizeAiProviders,
type AiProviderViewState
type AiProviderListState,
type AiProviderPageInitialState,
type AiProviderViewState,
summarizeAiProviders
} from "../contracts/aiProviders";
import { aiProviderCreateRequestFromForm, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
import { aiProviderCreateRequestFromForm, aiProviderRetireRequest, aiProviderUpdateRequestFromForm } from "../schemas/aiProviders";
import { cx } from "../utils/classes";
const seedProviders: AiProviderResponse[] = [
interface AiProvidersPageProps extends Partial<PageComponentProps> {
initialState?: AiProviderPageInitialState;
}
interface ProviderPreset {
id: string;
label: string;
summary: string;
draft: Partial<AiProviderFormState>;
}
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
const providerPresets: ProviderPreset[] = [
{
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://relay.example.test/v1",
apiKeyRef: "secret://providers/openai",
models: ["gpt-4.1", "gpt-4.1-mini"],
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
timeoutMs: 30000,
status: "active",
redactionPolicy: "default"
id: "openai-relay",
label: "OpenAI Relay",
summary: "平台中转,适合公网 OpenAI 兼容网关。",
draft: {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-4.1, gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ai.local",
name: "Local Ollama",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "env://OLLAMA_API_KEY",
models: ["llama3.1", "qwen2.5-coder"],
defaultModel: "qwen2.5-coder",
relayMode: "local",
timeoutMs: 20000,
status: "disabled",
redactionPolicy: "local"
id: "claude-relay",
label: "Claude Relay",
summary: "平台托管 Anthropic 兼容配置,密钥只留引用。",
draft: {
id: "ai.claude",
name: "Claude Relay",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet, claude-haiku",
defaultModel: "claude-sonnet",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "gemini-relay",
label: "Gemini Relay",
summary: "平台托管 Google Gemini 配置,保存后发现模型。",
draft: {
id: "ai.gemini",
name: "Gemini Relay",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-pro",
defaultModel: "gemini-pro",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ollama-local",
label: "Ollama Local",
summary: "本机或内网模型服务,默认走本地模式。",
draft: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "secret://providers/ollama-local",
modelsText: "llama3.1, qwen2.5",
defaultModel: "llama3.1",
relayMode: "local",
redactionPolicy: "default"
}
}
];
export function AiProvidersPage() {
const [providers, setProviders] = useState<AiProviderResponse[]>(seedProviders);
const [selectedId, setSelectedId] = useState(seedProviders[0]?.id ?? "");
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
const initialSelectedProvider = initialState?.selectedId ? initialState.providers?.find((provider) => provider.id === initialState.selectedId) : undefined;
const [providers, setProviders] = useState<AiProviderResponse[]>(initialState?.providers ?? []);
const [listState, setListState] = useState<AiProviderListState>(initialState?.listState ?? "loading");
const [listError, setListError] = useState(initialState?.listError ?? "");
const [selectedId, setSelectedId] = useState(initialState?.selectedId ?? "");
const [filter, setFilter] = useState<AiProviderFilter>("all");
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(seedProviders[0]));
const [viewState, setViewState] = useState<AiProviderViewState>("local");
const [action, setAction] = useState<AiProviderActionState | null>(null);
const [form, setForm] = useState<AiProviderFormState>(() => aiProviderToForm(initialSelectedProvider));
const [formMode, setFormMode] = useState<"create" | "edit" | null>(initialSelectedProvider ? "edit" : null);
const [viewState, setViewState] = useState<AiProviderViewState>(initialState?.source ?? "local-development");
const [action, setAction] = useState<AiProviderActionState | null>(initialState?.action ?? null);
const [confirmRetire, setConfirmRetire] = useState<AiProviderResponse | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [expandedProviderId, setExpandedProviderId] = useState<string | null>(null);
const [formCheck, setFormCheck] = useState<FormCheckState | null>(null);
useEffect(() => {
let cancelled = false;
if (initialState?.providers) {
setListState(initialState.listState ?? "ready");
setViewState(initialState.source ?? "local-development");
return () => {
cancelled = true;
};
}
platformApiClient
.listAiProviders()
.then((response) => {
if (cancelled || response.items.length === 0) {
if (cancelled) {
return;
}
const nextSelectedId =
initialState?.selectedId && response.items.some((provider) => provider.id === initialState.selectedId)
? initialState.selectedId
: "";
const nextSelectedProvider = response.items.find((provider) => provider.id === nextSelectedId);
setProviders(response.items);
setSelectedId(response.items[0].id);
setForm(aiProviderToForm(response.items[0]));
setSelectedId(nextSelectedId);
setForm(nextSelectedProvider ? aiProviderToForm(nextSelectedProvider) : emptyAiProviderForm());
setFormMode(nextSelectedProvider ? "edit" : null);
setListState("ready");
setListError("");
setViewState("api");
setFormCheck(null);
})
.catch(() => {
if (!cancelled) {
setViewState("local");
.catch((error: unknown) => {
if (cancelled) {
return;
}
setProviders([]);
setSelectedId("");
setForm(emptyAiProviderForm());
setListState("error");
setListError(error instanceof Error ? error.message : "AI 提供商 API 加载失败");
setViewState("error");
setFormCheck(null);
});
return () => {
cancelled = true;
};
}, []);
}, [initialState?.listState, initialState?.providers, initialState?.selectedId, initialState?.source]);
const metrics = useMemo(() => summarizeAiProviders(providers), [providers]);
const filteredProviders = useMemo(
() => providers.filter((provider) => filter === "all" || provider.status === filter),
[filter, providers]
);
const selectedProvider = providers.find((provider) => provider.id === selectedId);
const filteredProviders = useMemo(() => providers.filter((provider) => filter === "all" || provider.status === filter), [filter, providers]);
function updateForm<K extends keyof AiProviderFormState>(key: K, value: AiProviderFormState[K]) {
setForm((current) => ({ ...current, [key]: value }));
@@ -90,18 +172,71 @@ export function AiProvidersPage() {
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
setFormCheck(null);
}
function selectProvider(provider: AiProviderResponse) {
setSelectedId(provider.id);
setForm(aiProviderToForm(provider));
setFormMode("edit");
setAction(null);
setFormCheck(null);
setExpandedProviderId(null);
}
function startCreate() {
setSelectedId("");
setForm(emptyAiProviderForm());
setFormMode("create");
setAction(null);
setConfirmRetire(null);
setFormCheck(null);
setExpandedProviderId(null);
}
function closeForm() {
setFormMode(null);
setFormCheck(null);
}
function applyProviderPreset(preset: ProviderPreset) {
setForm((current) => ({
...current,
...preset.draft,
id: formMode === "edit" ? current.id : preset.draft.id ?? current.id
}));
setFormCheck({ status: "pending", message: `已套用 ${preset.label} 预设。保存前请确认 Base URL、secret 引用和默认模型。` });
}
function runFormPreflight() {
const missing: string[] = [];
const models = form.modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
if (!form.id.trim()) {
missing.push("ID");
}
if (!form.name.trim()) {
missing.push("名称");
}
if (!form.baseUrl.trim()) {
missing.push("Base URL");
}
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) {
missing.push("secret://providers/... 密钥引用");
}
if (models.length === 0) {
missing.push("至少一个模型");
}
if (!Number.isFinite(Number.parseInt(form.timeoutMs, 10)) || Number.parseInt(form.timeoutMs, 10) <= 0) {
missing.push("有效超时");
}
setFormCheck(
missing.length > 0
? { status: "failed", message: `保存前检查未通过:请补充 ${missing.join("、")}` }
: { status: "succeeded", message: "保存前检查通过。保存后先测试配置,测试成功再启用。" }
);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -117,46 +252,103 @@ export function AiProvidersPage() {
setSelectedId(saved.id);
setForm(aiProviderToForm(saved));
setViewState("api");
setAction({ providerId: saved.id, label: "save", success: true, message: "saved" });
} catch {
const local = localProviderFromForm(form, existing ? selectedProvider?.status ?? "active" : "active");
upsertProvider(local);
setSelectedId(local.id);
setForm(aiProviderToForm(local));
setViewState("local");
setAction({ providerId: local.id, label: "save", success: true, message: "saved locally" });
setListState("ready");
setListError("");
setAction({ providerId: saved.id, label: "save", success: true, message: "已保存" });
setFormCheck(null);
setFormMode(null);
} catch (error) {
setViewState("error");
const message = errorMessage(error, "保存失败");
setAction({ providerId: form.id.trim() || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message });
}
}
async function handleStatus(provider: AiProviderResponse) {
setExpandedProviderId(null);
const nextStatus: Extract<AiProviderStatus, "active" | "disabled"> = provider.status === "active" ? "disabled" : "active";
try {
const updated = await platformApiClient.setAiProviderStatus(provider.id, { status: nextStatus });
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status });
} catch {
const updated = { ...provider, status: nextStatus };
upsertProvider(updated);
setAction({ providerId: provider.id, label: "status", success: true, message: nextStatus });
setAction({ providerId: provider.id, label: "status", success: true, message: updated.status === "disabled" ? "已停用" : "已启用" });
} catch (error) {
setAction({ providerId: provider.id, label: "status", success: false, message: errorMessage(error, "状态更新失败") });
}
}
async function handleRetire(provider: AiProviderResponse) {
setConfirmBusy(true);
try {
const retired = await platformApiClient.setAiProviderStatus(provider.id, aiProviderRetireRequest());
upsertProvider(retired);
setAction({ providerId: provider.id, label: "retire", success: true, message: "已退役" });
setConfirmRetire(null);
} catch (error) {
setAction({ providerId: provider.id, label: "retire", success: false, message: errorMessage(error, "退役失败,可能仍被引用") });
} finally {
setConfirmBusy(false);
}
}
async function handleTest(provider: AiProviderResponse) {
setExpandedProviderId(null);
try {
const result = await platformApiClient.testAiProvider(provider.id);
setAction({ providerId: provider.id, label: "test", success: result.success, message: result.message });
} catch {
const success = provider.status === "active";
setAction({ providerId: provider.id, label: "test", success, message: success ? "metadata validation passed" : "provider must be active" });
} catch (error) {
setAction({ providerId: provider.id, label: "test", success: false, message: errorMessage(error, "测试失败") });
}
}
async function handleModels(provider: AiProviderResponse) {
setExpandedProviderId(null);
try {
const result = await platformApiClient.listAiProviderModels(provider.id);
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length}` });
} catch {
setAction({ providerId: provider.id, label: "models", success: true, message: `${provider.models.length}` });
setAction({ providerId: provider.id, label: "models", success: true, message: `${result.models.length} 个模型` });
} catch (error) {
setAction({ providerId: provider.id, label: "models", success: false, message: errorMessage(error, "模型刷新失败") });
}
}
async function handleSavedFormTest() {
const providerId = form.id.trim() || selectedId;
if (formMode !== "edit" || !providerId) {
setFormCheck({ status: "failed", message: "请先保存提供商,再测试已保存配置。" });
return;
}
setFormCheck({ status: "pending", message: "正在测试已保存配置…" });
try {
const result = await platformApiClient.testAiProvider(providerId);
setAction({ providerId, label: "test", success: result.success, message: result.message });
setFormCheck({ status: result.success ? "succeeded" : "failed", message: result.success ? `${result.message}。测试成功后可在更多菜单启用。` : result.message });
} catch (error) {
const message = errorMessage(error, "测试失败");
setAction({ providerId, label: "test", success: false, message });
setFormCheck({ status: "failed", message });
}
}
async function handleDiscoverModelsForForm() {
const providerId = form.id.trim() || selectedId;
if (formMode !== "edit" || !providerId) {
setFormCheck({ status: "failed", message: "请先保存提供商,再从平台发现模型。" });
return;
}
setFormCheck({ status: "pending", message: "正在发现模型…" });
try {
const result = await platformApiClient.listAiProviderModels(providerId);
setForm((current) => ({
...current,
modelsText: result.models.join(", "),
defaultModel: result.defaultModel ?? result.models[0] ?? current.defaultModel
}));
setAction({ providerId, label: "models", success: true, message: `${result.models.length} 个模型` });
setFormCheck({ status: "succeeded", message: `已填入 ${result.models.length} 个模型;保存后生效。` });
} catch (error) {
const message = errorMessage(error, "模型发现失败");
setAction({ providerId, label: "models", success: false, message });
setFormCheck({ status: "failed", message });
}
}
@@ -176,23 +368,23 @@ export function AiProvidersPage() {
AI
</h1>
</div>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewState === "api" ? "已连接" : viewState === "saving" ? "保存中" : "本地视图"}</span>
<span className={cx("page-status", viewState === "api" && "page-status-ready")}>{viewStateLabel(viewState)}</span>
</header>
<div className="metric-grid ai-provider-metrics">
<article className="metric-card metric-tone-neutral">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.total}</strong>
</article>
<article className="metric-card metric-tone-success">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.active}</strong>
</article>
<article className="metric-card metric-tone-warning">
<span className="metric-label"></span>
<strong className="metric-value">{metrics.models}</strong>
</article>
</div>
<dl className="page-summary-strip ai-provider-summary" aria-label="AI 提供商快速状态">
<div className="page-summary-chip summary-tone-neutral">
<dt></dt>
<dd>{metrics.total}</dd>
</div>
<div className="page-summary-chip summary-tone-success">
<dt></dt>
<dd>{metrics.active}</dd>
</div>
<div className="page-summary-chip summary-tone-warning">
<dt></dt>
<dd>{metrics.models}</dd>
</div>
</dl>
<div className="ai-provider-toolbar" aria-label="provider filters">
{(["all", "active", "disabled", "error"] as AiProviderFilter[]).map((item) => (
@@ -205,8 +397,16 @@ export function AiProvidersPage() {
<span></span>
</button>
</div>
{action && <ResultBadge status={action.success ? "succeeded" : "failed"} label={action.message} />}
<div className="ai-provider-workspace">
{listState === "loading" && <LoadingState label="正在加载 AI 提供商…" />}
{listState === "error" && <ErrorState title="AI 提供商加载失败" reason={listError} diagnosticId="ai-provider:list" onRetry={() => window.location.reload()} />}
{listState === "ready" && providers.length === 0 && (
<EmptyState icon={<FlaskConical size={26} />} title="暂无 AI 提供商" description="平台还没有返回任何提供商。创建第一个平台托管的 AI 提供商后,这里会显示 API 数据。" actionLabel="新增提供商" onAction={startCreate} />
)}
{listState === "ready" && providers.length > 0 && (
<div className="provider-table-wrap">
<table className="provider-table">
<thead>
@@ -238,50 +438,83 @@ export function AiProvidersPage() {
<td>
<code className="secret-ref">{provider.apiKeyRef}</code>
</td>
<td>
<div className="row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" title="启用或禁用" aria-label={`${provider.name} 启用或禁用`} onClick={() => void handleStatus(provider)}>
<Power size={15} />
</button>
<button type="button" title="测试配置" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<td className="provider-actions-cell">
<div className="row-actions human-row-actions" aria-label={`${provider.name} 操作`}>
<button type="button" aria-label={`${provider.name} 测试配置`} onClick={() => void handleTest(provider)}>
<FlaskConical size={15} />
<span></span>
</button>
<button type="button" title="刷新模型" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<button type="button" aria-label={`${provider.name} 刷新模型`} onClick={() => void handleModels(provider)}>
<Sparkles size={15} />
<span></span>
</button>
<button type="button" aria-label={`编辑 ${provider.name}`} onClick={() => selectProvider(provider)}>
<WandSparkles size={15} />
<span></span>
</button>
<button
type="button"
className={cx(expandedProviderId === provider.id && "row-action-button-active")}
aria-expanded={expandedProviderId === provider.id}
aria-label={`${provider.name} 更多操作`}
onClick={() => setExpandedProviderId((current) => (current === provider.id ? null : provider.id))}
>
<MoreHorizontal size={15} />
<span></span>
</button>
</div>
{expandedProviderId === provider.id && (
<div className="inline-action-menu" role="menu" aria-label={`${provider.name} 更多操作`}>
<button type="button" role="menuitem" onClick={() => void handleStatus(provider)}>
<Power size={14} />
<span>{provider.status === "active" ? "停用提供商" : "启用提供商"}</span>
</button>
<button
type="button"
role="menuitem"
className="danger-command"
onClick={() => {
setExpandedProviderId(null);
setConfirmRetire(provider);
}}
>
<UserRoundMinus size={14} />
<span>退</span>
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredProviders.length === 0 && (
<EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />
)}
{filteredProviders.length === 0 && <EmptyState title="暂无匹配提供商" description="调整状态筛选,或点击新增配置一个平台托管的 AI 提供商。" />}
</div>
)}
<form className="provider-form" onSubmit={(event) => void handleSubmit(event)}>
<div className="form-header">
<h2>{selectedProvider ? "编辑提供商" : "新增提供商"}</h2>
{action && (
<span className={cx("action-result", action.success ? "action-result-success" : "action-result-failed")}>
<CheckCircle2 size={14} />
{action.message}
</span>
)}
<ManagementDialog open={formMode !== null} title={formMode === "edit" ? "编辑提供商" : "新增提供商"} wide onClose={closeForm}>
<form className="provider-form dialog-form" onSubmit={(event) => void handleSubmit(event)}>
<ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设">
{providerPresets.map((preset) => (
<button key={preset.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(preset)}>
<strong>{preset.label}</strong>
<span>{preset.summary}</span>
</button>
))}
</div>
<label>
ID
<input name="id" value={form.id} onChange={handleInput} disabled={Boolean(selectedProvider)} />
<span>ID</span>
<input name="id" value={form.id} onChange={handleInput} disabled={formMode === "edit"} />
<small className="field-help"> ID ai.openai</small>
</label>
<label>
<span></span>
<input name="name" value={form.name} onChange={handleInput} />
</label>
<div className="form-grid">
<label>
<span></span>
<select name="kind" value={form.kind} onChange={handleInput}>
<option value="openai-compatible">OpenAI Compatible</option>
<option value="openai">OpenAI</option>
@@ -292,7 +525,7 @@ export function AiProvidersPage() {
</select>
</label>
<label>
<span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
@@ -301,48 +534,103 @@ export function AiProvidersPage() {
</label>
</div>
<label>
Base URL
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<small className="field-help"></small>
</label>
<label>
<span></span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
<small className="field-help"> secret://providers/... 引用,不要粘贴 raw API key。</small>
</label>
<label>
<span></span>
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
<small className="field-help"></small>
</label>
<div className="form-grid">
<label>
<span></span>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
</label>
<label>
ms
<span> ms</span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
</label>
</div>
<label>
<span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
<small className="field-help"> default Bearer token </small>
</label>
<button type="submit" className="primary-command" title="保存提供商">
<WandSparkles size={16} />
<span></span>
</button>
<div className="form-helper-actions">
<button type="button" className="theme-upload" onClick={runFormPreflight}>
</button>
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleSavedFormTest()}>
{formMode === "edit" ? "测试已保存配置" : "保存后可测试"}
</button>
<button type="button" className="theme-upload" disabled={formMode !== "edit"} onClick={() => void handleDiscoverModelsForForm()}>
{formMode === "edit" ? "发现模型并填入" : "保存后可发现模型"}
</button>
</div>
{formCheck && <ResultBadge status={formCheck.status} label={formCheck.message} />}
<div className="confirm-actions">
<button type="button" onClick={closeForm}>
</button>
<button type="submit" className="confirm-primary">
<WandSparkles size={16} />
<span></span>
</button>
</div>
</form>
</div>
</ManagementDialog>
<ConfirmDialog
open={confirmRetire !== null}
title="退役 AI 提供商"
description={`确认将 ${confirmRetire?.name ?? ""}${confirmRetire?.id ?? ""})退役为停用状态?如果仍被引用,平台会拒绝这次变更。`}
confirmLabel="确认退役"
danger
busy={confirmBusy}
onCancel={() => setConfirmRetire(null)}
onConfirm={() => void (confirmRetire ? handleRetire(confirmRetire) : undefined)}
/>
</section>
);
}
function localProviderFromForm(form: AiProviderFormState, status: AiProviderStatus): AiProviderResponse {
const request = aiProviderCreateRequestFromForm(form);
return {
...request,
defaultModel: request.defaultModel,
status
};
function ProviderSetupGuide() {
return (
<div className="form-guidance provider-setup-guide">
<strong></strong>
<span> secret </span>
</div>
);
}
function errorMessage(error: unknown, fallback: string): string {
if (!(error instanceof Error)) {
return fallback;
}
if (error.message.toLowerCase().includes("reference")) {
return `${fallback}${error.message}`;
}
return error.message || fallback;
}
function viewStateLabel(viewState: AiProviderViewState): string {
switch (viewState) {
case "api":
return "平台 API";
case "local-development":
return "本地开发";
case "saving":
return "保存中";
default:
return "连接失败";
}
}
function filterLabel(filter: AiProviderFilter): string {
+25 -1
View File
@@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { HomePage } from "./HomePage";
import { MaintenancePage } from "./MaintenancePage";
import { PluginsPage } from "./PluginsPage";
import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
@@ -10,6 +11,7 @@ import { UsersPage } from "./UsersPage";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
import type { UserResponse } from "../api/types";
const adminUser: CurrentUserView = {
id: "user-admin",
@@ -30,6 +32,17 @@ const noopOperations: OperationTracker = {
isPending: () => false
};
const reviewUser: UserResponse = {
id: "user-reviewer",
displayName: "Plugin Reviewer",
email: "reviewer@example.test",
status: "pending",
roles: ["server-admin"],
profile: { contactNote: "needs approval" },
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
function pageProps(params: PageComponentProps["params"] = {}): PageComponentProps {
return {
session: adminUser,
@@ -86,11 +99,22 @@ describe("first-party console pages", () => {
});
it("renders user access review context", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} />);
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [reviewUser], loading: false, source: "local-development" }} />);
expect(html).toContain("用户管理");
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("needs approval");
expect(html).toContain("本地开发样例 / 禁止假成功");
});
it("renders maintenance triage entry points", () => {
const html = renderToStaticMarkup(<MaintenancePage {...pageProps()} />);
expect(html).toContain("系统维护");
expect(html).toContain("维护排障入口");
expect(html).toContain("节点详情");
expect(html).toContain("最近失败任务");
expect(html).toContain("审计异常");
});
it("renders profile settings as a full page", () => {
+281 -32
View File
@@ -1,16 +1,20 @@
import { Sparkles, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Activity, ListChecks, RotateCcw, ServerCog, Sparkles, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AuditEventResponse, RunEndpointResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState } from "../components/StateViews";
import type { AuditEventResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse } from "../api/types";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import { cx } from "../utils/classes";
type ModuleState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
export function MaintenancePage() {
export function MaintenancePage({ session, operations, onNavigate }: PageComponentProps) {
const [endpoints, setEndpoints] = useState<ModuleState<RunEndpointResponse[]>>({ status: "loading" });
const [events, setEvents] = useState<ModuleState<AuditEventResponse[]>>({ status: "loading" });
const [jobs, setJobs] = useState<ModuleState<JobResponse[]>>({ status: "loading" });
const [servers, setServers] = useState<ModuleState<ServerInstanceResponse[]>>({ status: "loading" });
const [triageResult, setTriageResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const refreshEndpoints = useCallback(async () => {
setEndpoints({ status: "loading" });
@@ -32,10 +36,71 @@ export function MaintenancePage() {
}
}, []);
useEffect(() => {
const refreshJobs = useCallback(async () => {
setJobs({ status: "loading" });
try {
const response = await platformApiClient.listJobs();
setJobs({ status: "ready", data: response.items });
} catch (error) {
setJobs({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshServers = useCallback(async () => {
setServers({ status: "loading" });
try {
const response = await platformApiClient.listServerInstances();
setServers({ status: "ready", data: response.items });
} catch (error) {
setServers({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
}
}, []);
const refreshAll = useCallback(() => {
void refreshEndpoints();
void refreshEvents();
}, [refreshEndpoints, refreshEvents]);
void refreshJobs();
void refreshServers();
}, [refreshEndpoints, refreshEvents, refreshJobs, refreshServers]);
useEffect(() => {
refreshAll();
}, [refreshAll]);
const endpointItems = endpoints.status === "ready" ? endpoints.data : [];
const eventItems = events.status === "ready" ? events.data : [];
const jobItems = jobs.status === "ready" ? jobs.data : [];
const serverItems = servers.status === "ready" ? servers.data : [];
const failedJobs = useMemo(() => jobItems.filter((job) => job.state === "failed").slice(0, 8), [jobItems]);
const serverById = useMemo(() => new Map(serverItems.map((server) => [server.id, server])), [serverItems]);
const endpointById = useMemo(() => new Map(endpointItems.map((endpoint) => [endpoint.id, endpoint])), [endpointItems]);
const failedAuditCount = eventItems.filter((event) => event.result !== "success").length;
const unhealthyEndpointCount = endpointItems.filter((endpoint) => endpoint.status !== "online" || heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5).length;
async function retryJob(job: JobResponse) {
const retryStamp = Date.now();
const operationId = operations.begin({ intent: "重试任务", targetKind: "platform", targetId: job.id, requester: session.displayName });
setTriageResult({ status: "pending", label: `正在重试 ${job.id}` });
try {
const retried = await platformApiClient.createJob({
id: `${job.id}-retry-${retryStamp}`,
serverInstanceId: job.serverInstanceId,
runEndpointId: job.runEndpointId,
capability: job.capability,
targetKey: job.targetKey,
inputRef: job.inputRef,
idempotencyKey: `web-retry-${job.id}-${retryStamp}`,
progress: { percent: 0, message: `retry of ${job.id}` }
});
operations.succeed(operationId, `已创建重试任务:${retried.id}`, retried);
setTriageResult({ status: "succeeded", label: `已创建重试任务 ${retried.id}` });
void refreshJobs();
} catch (error) {
const message = error instanceof Error ? error.message : "任务重试失败";
operations.fail(operationId, message);
setTriageResult({ status: "failed", label: message });
}
}
return (
<div className="maintenance-page">
@@ -46,19 +111,40 @@ export function MaintenancePage() {
<WandSparkles size={22} style={{ verticalAlign: "-3px" }} />
</h1>
</div>
<button
type="button"
className="icon-command"
onClick={() => {
void refreshEndpoints();
void refreshEvents();
}}
>
<button type="button" className="icon-command" onClick={refreshAll}>
<Sparkles size={16} />
<span></span>
</button>
</header>
<div className="form-guidance maintenance-triage-intro">
<strong></strong>
<span> run </span>
</div>
<section className="maintenance-triage-grid" aria-label="维护排障入口">
<button type="button" className="triage-card" onClick={() => void refreshEndpoints()}>
<ServerCog size={18} />
<span></span>
<strong>{endpoints.status === "ready" ? `${unhealthyEndpointCount} 个需关注` : "加载中"}</strong>
<small></small>
</button>
<button type="button" className="triage-card" onClick={() => void refreshJobs()}>
<ListChecks size={18} />
<span></span>
<strong>{jobs.status === "ready" ? `${failedJobs.length} 个失败` : "加载中"}</strong>
<small></small>
</button>
<button type="button" className="triage-card" onClick={() => void refreshEvents()}>
<Activity size={18} />
<span></span>
<strong>{events.status === "ready" ? `${failedAuditCount} 条异常` : "加载中"}</strong>
<small></small>
</button>
</section>
{triageResult && <ResultBadge status={triageResult.status} label={triageResult.label} />}
<section className="console-panel" aria-label="run endpoints">
<div className="panel-header">
<h2></h2>
@@ -67,26 +153,104 @@ export function MaintenancePage() {
{endpoints.status === "error" && (
<ErrorState title="运行节点加载失败" reason={endpoints.reason} diagnosticId="maintenance-endpoints" onRetry={() => void refreshEndpoints()} compact />
)}
{servers.status === "error" && <ErrorState title="相关服务器加载失败" reason={servers.reason} diagnosticId="maintenance-servers" onRetry={() => void refreshServers()} compact />}
{endpoints.status === "ready" && endpoints.data.length === 0 && (
<EmptyState title="暂无运行节点" description="还没有运行端注册到平台。" actionLabel="刷新" onAction={() => void refreshEndpoints()} />
)}
{endpoints.status === "ready" && endpoints.data.length > 0 && (
<div className="resource-list">
{endpoints.data.map((endpoint) => (
<article key={endpoint.id} className="resource-list-item">
<div>
<strong>{endpoint.displayName}</strong>
<span className="provider-id">{endpoint.id}</span>
<div className="resource-list maintenance-node-list">
{endpoints.data.map((endpoint) => {
const relatedServers = serverItems.filter((server) => server.runEndpointId === endpoint.id);
const firstRelatedServer = relatedServers[0];
return (
<article key={endpoint.id} className="resource-list-item maintenance-node-item">
<div>
<strong>{endpoint.displayName}</strong>
<span className="provider-id">{endpoint.id}</span>
</div>
<span className={cx("status-pill", endpointStatusClass(endpoint))}>{endpointStatusLabel(endpoint)}</span>
<span>
{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} {endpoint.capacity.queuedJobs}
</span>
<span>{heartbeatReason(endpoint)}</span>
<details className="node-detail">
<summary></summary>
<span> {endpoint.version}</span>
<span> {endpoint.capabilities.slice(0, 4).join(" / ") || "未上报"}</span>
<span> {relatedServers.length}</span>
</details>
<div className="maintenance-actions">
<button type="button" className="theme-upload" onClick={() => void refreshEndpoints()}>
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
</button>
<button
type="button"
className="theme-upload"
disabled={!firstRelatedServer}
onClick={() => firstRelatedServer && onNavigate("serverDetail", { serverId: firstRelatedServer.id })}
>
</button>
</div>
</article>
);
})}
</div>
)}
</section>
<section className="console-panel" aria-label="failed jobs">
<div className="panel-header">
<h2></h2>
</div>
{jobs.status === "loading" && <LoadingState label="正在加载任务…" compact />}
{jobs.status === "error" && <ErrorState title="任务加载失败" reason={jobs.reason} diagnosticId="maintenance-jobs" onRetry={() => void refreshJobs()} compact />}
{jobs.status === "ready" && failedJobs.length === 0 && (
<EmptyState title="暂无失败任务" description="最近任务没有失败记录;如果节点异常,请先查看运行节点心跳和容量。" actionLabel="刷新任务" onAction={() => void refreshJobs()} />
)}
{jobs.status === "ready" && failedJobs.length > 0 && (
<div className="operation-list">
{failedJobs.map((job) => {
const server = job.serverInstanceId ? serverById.get(job.serverInstanceId) : undefined;
const endpoint = endpointById.get(job.runEndpointId);
return (
<div key={job.id} className="operation-item">
<div className="operation-item-head">
<strong>{job.capability}</strong>
<span className="status-pill status-error">{jobStateLabel(job.state)}</span>
</div>
<div className="operation-meta">
<span>
<code>{job.id}</code>
</span>
<span> {server ? server.name : job.serverInstanceId ?? "平台任务"}</span>
<span> {endpoint ? endpoint.displayName : job.runEndpointId}</span>
<span>{progressMessage(job)}</span>
<span>{formatTimestamp(job.updatedAt)}</span>
</div>
<div className="maintenance-actions">
<button type="button" className="theme-upload" onClick={() => void retryJob(job)}>
<RotateCcw size={13} />
</button>
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
</button>
<button type="button" className="theme-upload" disabled={!job.serverInstanceId} onClick={() => job.serverInstanceId && onNavigate("serverDetail", { serverId: job.serverInstanceId })}>
</button>
</div>
</div>
<span className={cx("status-pill", endpoint.status === "online" ? "status-active" : endpoint.status === "offline" ? "status-error" : "status-disabled")}>
{endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status}
</span>
<span>
{endpoint.capacity.runningJobs}/{endpoint.capacity.maxJobs} {endpoint.capacity.queuedJobs}
</span>
<span> {new Date(endpoint.lastHeartbeatAt).toLocaleString()}</span>
</article>
))}
);
})}
</div>
)}
</section>
@@ -108,7 +272,7 @@ export function MaintenancePage() {
<div key={event.id} className="operation-item">
<div className="operation-item-head">
<strong>{event.summary || `${event.action} ${event.resourceKind}`}</strong>
<span className={cx("status-pill", event.result === "success" ? "status-active" : "status-error")}>{event.result}</span>
<span className={cx("status-pill", auditResultClass(event))}>{event.result}</span>
</div>
<div className="operation-meta">
<span>
@@ -118,7 +282,20 @@ export function MaintenancePage() {
<span>
{event.resourceKind}/{event.resourceId}
</span>
<span>{new Date(event.createdAt).toLocaleString()}</span>
<span>{formatTimestamp(event.createdAt)}</span>
</div>
<div className="maintenance-actions">
<button type="button" className="theme-upload" onClick={() => void refreshEvents()}>
</button>
<button
type="button"
className="theme-upload"
disabled={event.resourceKind !== "server-instance"}
onClick={() => event.resourceKind === "server-instance" && onNavigate("serverDetail", { serverId: event.resourceId })}
>
</button>
</div>
</div>
))}
@@ -128,3 +305,75 @@ export function MaintenancePage() {
</div>
);
}
function endpointStatusClass(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) <= 5) {
return "status-active";
}
if (endpoint.status === "offline") {
return "status-error";
}
return "status-disabled";
}
function endpointStatusLabel(endpoint: RunEndpointResponse): string {
if (endpoint.status === "online" && heartbeatAgeMinutes(endpoint.lastHeartbeatAt) > 5) {
return "心跳延迟";
}
return endpoint.status === "online" ? "在线" : endpoint.status === "offline" ? "离线" : endpoint.status;
}
function heartbeatReason(endpoint: RunEndpointResponse): string {
const age = heartbeatAgeMinutes(endpoint.lastHeartbeatAt);
if (!Number.isFinite(age)) {
return `心跳时间异常:${endpoint.lastHeartbeatAt}`;
}
if (endpoint.status === "offline") {
return `心跳异常:节点离线,最后 ${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
if (age > 5) {
return `心跳异常:${Math.round(age)} 分钟未更新`;
}
if (endpoint.capacity.runningJobs >= endpoint.capacity.maxJobs) {
return "容量已满:等待任务会继续排队";
}
return `心跳正常:${formatTimestamp(endpoint.lastHeartbeatAt)}`;
}
function heartbeatAgeMinutes(value: string): number {
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) {
return Number.POSITIVE_INFINITY;
}
return (Date.now() - timestamp) / 60000;
}
function auditResultClass(event: AuditEventResponse): string {
return event.result === "success" ? "status-active" : "status-error";
}
function jobStateLabel(state: JobResponse["state"]): string {
switch (state) {
case "queued":
return "排队";
case "accepted":
return "已接收";
case "running":
return "运行中";
case "succeeded":
return "成功";
case "cancelled":
return "已取消";
default:
return "失败";
}
}
function progressMessage(job: JobResponse): string {
return job.progress.message ? `原因 ${job.progress.message}` : `进度 ${job.progress.percent}%`;
}
function formatTimestamp(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
+11
View File
@@ -31,6 +31,8 @@ describe("PluginsPage", () => {
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "loading" }} />);
expect(html).toContain("插件市场");
expect(html).toContain("page-summary-chip");
expect(html).not.toContain("metric-card");
expect(html).toContain("正在加载插件市场");
});
@@ -54,6 +56,7 @@ describe("PluginsPage", () => {
expect(html).toContain("安装");
expect(html).toContain("启用");
expect(html).toContain("停用");
expect(html).toContain('role="dialog"');
expect(html).not.toContain("billing");
expect(html).not.toContain("/Users/");
expect(html).not.toContain("unix://");
@@ -71,4 +74,12 @@ describe("PluginsPage", () => {
expect(html).toContain("本地演示数据");
expect(html).toContain("本地演示数据仅用于前端开发");
});
it("does not open a plugin detail pane until a plugin is selected", () => {
const html = renderToStaticMarkup(<PluginsPage initialState={{ listState: "ready", plugins: [marketplacePlugin] }} />);
expect(html).toContain("Example Server");
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain("manifest validated");
});
});
+16 -53
View File
@@ -3,10 +3,10 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from "rea
import { platformApiClient } from "../api/client";
import type { GamePluginStatus, MarketplacePluginFilterRequest, MarketplacePluginResponse, MarketplacePluginStateAction } from "../api/types";
import { ManagementDialog } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { pluginCatalog } from "../contracts/shell";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
@@ -59,28 +59,28 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
serverType: serverType.trim() || undefined,
capability: capability.trim() || undefined
};
if (initialState?.plugins) {
setListState(initialState.listState ?? "ready");
setListError(initialState.listError ?? "");
setPlugins(initialState.plugins);
setSelectedId(initialState.selectedId ?? "");
setDetail(initialState.detail ?? null);
setUsingFallback(initialState.usingFallback ?? false);
return;
}
try {
const response = await platformApiClient.listMarketplacePlugins(filter);
setPlugins(response.items);
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : response.items[0]?.id || ""));
setSelectedId((current) => (current && response.items.some((plugin) => plugin.id === current) ? current : ""));
setUsingFallback(false);
setListState("ready");
} catch (error) {
if (import.meta.env.DEV) {
const fallback = fallbackMarketplacePlugins(filter);
setPlugins(fallback);
setSelectedId((current) => (current && fallback.some((plugin) => plugin.id === current) ? current : fallback[0]?.id || ""));
setUsingFallback(true);
setListState("ready");
setListError("");
return;
}
setListState("error");
setListError(error instanceof Error ? error.message : "插件市场加载失败");
setPlugins([]);
setDetail(null);
}
}, [capability, keyword, serverType, statusFilter]);
}, [capability, initialState, keyword, serverType, statusFilter]);
useEffect(() => {
void refresh();
@@ -248,13 +248,13 @@ export function PluginsPage({ initialState }: PluginsPageProps = {}) {
</section>
)}
{selectedId && (
<section className="drawer-panel plugin-detail-panel" aria-label="plugin marketplace detail">
<ManagementDialog open={selectedId !== ""} title={detail?.name ?? "插件详情"} wide onClose={() => { setSelectedId(""); setDetail(null); setDetailError(""); }}>
<div className="plugin-detail-panel" aria-label="plugin marketplace detail">
{detailPending && <LoadingState label="正在加载插件详情…" compact />}
{detailError && <ErrorState title="插件详情加载失败" reason={detailError} diagnosticId={`plugin-detail:${selectedId}`} compact />}
{detail && <PluginDetail plugin={detail} actionPending={actionPending} actionsDisabled={usingFallback} onAction={(action) => void changeState(action)} />}
</section>
)}
</div>
</ManagementDialog>
</div>
);
}
@@ -326,43 +326,6 @@ function DetailStat({ label, value }: { label: string; value: string }) {
);
}
function fallbackMarketplacePlugins(filter: MarketplacePluginFilterRequest): MarketplacePluginResponse[] {
const keyword = filter.keyword?.trim().toLowerCase() ?? "";
return pluginCatalog
.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.validation,
version: plugin.version,
serverType: plugin.serverType,
serverDisplayName: plugin.serverType,
supportedOs: ["linux"],
manifestRef: `manifest://${plugin.id}/${plugin.version}`,
createFormSchemaRef: "schemas/create-form.schema.json",
capabilities: ["process.install", "process.start", "logs.read"],
declaredPermissions: plugin.permissions,
permissions: {
ai: plugin.permissions.includes("ai.invoke"),
logs: plugin.permissions.includes("server.logs.read"),
files: plugin.permissions.some((permission) => permission.includes("files")),
jobs: plugin.permissions.includes("server.lifecycle"),
artifacts: plugin.permissions.some((permission) => permission.includes("artifacts"))
},
lifecycleActions: { install: "actions/install.json", start: "actions/start.json", stop: "actions/stop.json" },
bridgeActions: plugin.bridgeActions,
pages: [{ key: "overview", title: "Overview", path: "/overview", permissions: plugin.permissions, bridgeActions: plugin.bridgeActions }],
tags: [plugin.serverType],
aiPurposes: plugin.permissions.includes("ai.invoke") ? ["logs.diagnose"] : [],
validationViolations: plugin.status === "invalid" ? [plugin.validation] : undefined,
status: plugin.status,
source: "local-development"
}))
.filter((plugin) => !filter.status || filter.status === "all" || plugin.status === filter.status)
.filter((plugin) => !filter.serverType || plugin.serverType === filter.serverType)
.filter((plugin) => !filter.capability || plugin.capabilities.includes(filter.capability) || plugin.bridgeActions.includes(filter.capability))
.filter((plugin) => !keyword || [plugin.id, plugin.name, plugin.serverType, ...plugin.tags, ...plugin.capabilities].some((value) => value.toLowerCase().includes(keyword)));
}
function unique(values: string[]): string[] {
return Array.from(new Set(values.filter(Boolean))).sort((left, right) => left.localeCompare(right));
}
+1
View File
@@ -18,6 +18,7 @@ Do not put shared API clients, shared DTOs, route definitions, or bridge contrac
Pages must use the shared black-mecha / magical-girl visual system from `../theme/` instead of page-local card systems or unrelated palettes.
- Reuse shared shell, card, panel, table, drawer, dialog, status, command, log, diff, plugin group, and operation history classes from `theme/base.css`.
- Keep management lists, grids, and tables full-width. Create, edit, and detail workflows belong in modals, drawers, or detail routes, not in permanent right-side panes or inline form panels beside/above the list.
- Keep page surfaces translucent enough for the selected built-in or uploaded background desktop to remain visible.
- Ambient magical particles are global shell chrome. Pages must not add fixed decorative sparkles, hearts, moons, snowflakes, sigils, or custom backdrop layers.
- Do not introduce opaque white cards, heavy dark dashboards, stock marketing layouts, or single-page custom gradients that bypass the theme tokens.
@@ -45,6 +45,15 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain('capability: "config.write"');
});
it("uses typed server metadata and archive APIs without local fallback mutation", () => {
expect(serverDetailPageSource).toContain("updateServerInstance(instance.id");
expect(serverDetailPageSource).toContain("archiveServerInstance(instance.id)");
expect(serverDetailPageSource).toContain("serverMetadataUpdateRequestFromForm");
expect(serverDetailPageSource).toContain("canArchiveServer(instance.state)");
expect(serverDetailPageSource).toContain("配置读取不可用");
expect(serverDetailPageSource).not.toContain("fallbackConfig");
});
it("does not locally mutate visible config after dispatching approval jobs", () => {
expect(serverDetailPageSource).not.toContain("setCurrentConfig(suggestion.diff.nextContent)");
expect(serverDetailPageSource).not.toContain("content: diff.nextContent");
@@ -60,6 +69,26 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("Bearer ");
});
it("surfaces run distribution and client-manager workflows through platform-mediated APIs", () => {
expect(serverDetailPageSource).toContain("getServerRuntimeActions");
expect(serverDetailPageSource).toContain("generateRunDistribution");
expect(serverDetailPageSource).toContain("downloadLatestRunDistribution");
expect(serverDetailPageSource).toContain("pushRunUpdate");
expect(serverDetailPageSource).toContain("generateClientManager");
expect(serverDetailPageSource).toContain("resetClientManagerKey");
expect(serverDetailPageSource).toContain("checkDependencies");
expect(serverDetailPageSource).toContain("installDependencies");
expect(serverDetailPageSource).toContain("listServerLiveLogs");
expect(serverDetailPageSource).toContain("requestLogBackfill");
expect(serverDetailPageSource).toContain("safeRuntimeRef");
expect(serverDetailPageSource).not.toContain("authKey");
expect(serverDetailPageSource).not.toContain("password=");
expect(serverDetailPageSource).not.toContain("unix://");
expect(serverDetailPageSource).not.toContain("tcp://");
expect(serverDetailPageSource).not.toContain("mysql://");
expect(serverDetailPageSource).not.toContain("sqlite://");
});
it("routes plugin lifecycle controls through platform lifecycle APIs instead of generic jobs", () => {
expect(serverDetailPageSource).toContain('action === "install" || action === "restart" || action === "status"');
expect(serverDetailPageSource).toContain('action !== "start" && action !== "stop"');
+503 -18
View File
@@ -1,26 +1,29 @@
import { ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { Archive, ChevronDown, ChevronRight, Download, MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
ConfigDiffLineResponse,
ArtifactDownloadReferenceResponse,
ArtifactResponse,
ClientManagerDistributionResponse,
GamePluginResponse,
JobResponse,
LogEntryBody,
LogStreamResponse,
RunDistributionResponse,
ServerConfigDiffPreviewResponse,
ServerConfigResponse,
ServerInstanceResponse,
ServerMemberResponse,
ServerMetricsResponse
ServerMetricsResponse,
ServerRuntimeActionsResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
import { canStartServer, canStopServer, pluginLabel } from "../contracts/serverManagement";
import { canArchiveServer, canStartServer, canStopServer, pluginLabel, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import {
serverDetailSections,
serverIsOnline,
@@ -30,7 +33,16 @@ import {
type PluginControlGroupView,
type ServerDetailSection
} from "../contracts/workspace";
import { serverLifecycleCommandRequest } from "../schemas/serverManagement";
import {
clientManagerBuildRequest,
dependencyJobRequest,
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverArchiveConfirmation,
serverLifecycleCommandRequest,
serverMetadataUpdateRequestFromForm
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { cx } from "../utils/classes";
@@ -38,7 +50,6 @@ import { stateLabel, statusClass } from "./ServersPage";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
const fallbackConfig = "# server.properties\nmax-players=20\nmotd=Welcome to the server\npvp=true\nview-distance=8\n";
const defaultConfigKey = "server.properties";
export function ServerDetailPage({ session, params, operations, onNavigate }: PageComponentProps) {
@@ -49,6 +60,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
const [jobs, setJobs] = useState<JobResponse[]>([]);
const [artifacts, setArtifacts] = useState<ArtifactResponse[]>([]);
const [runtimeActions, setRuntimeActions] = useState<LoadState<ServerRuntimeActionsResponse>>({ status: "loading" });
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
@@ -59,14 +71,19 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, jobResponse] = await Promise.all([
const [detail, pluginResponse, jobResponse, runtimeResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient.listJobs(serverId)
platformApiClient.listJobs(serverId),
platformApiClient
.getServerRuntimeActions(serverId)
.then((data): LoadState<ServerRuntimeActionsResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerRuntimeActionsResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "运行分发状态加载失败" }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.items);
setJobs(jobResponse.items);
setRuntimeActions(runtimeResponse);
const artifactLists = await Promise.all(
jobResponse.items.slice(0, 20).map((job) =>
platformApiClient
@@ -79,6 +96,7 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
} catch (error) {
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
setArtifacts([]);
setRuntimeActions({ status: "error", reason: "运行分发状态加载失败" });
}
try {
const metricsResponse = await platformApiClient.listServerMetrics();
@@ -224,6 +242,25 @@ export function ServerDetailPage({ session, params, operations, onNavigate }: Pa
</nav>
{section === "overview" && <OverviewSection instance={instance.data} metrics={metrics} jobs={jobs} onOpenLogs={() => setSection("logs")} />}
{section === "overview" && (
<RuntimeDistributionSection
instance={instance.data}
runtimeActions={runtimeActions}
session={session}
operations={operations}
onOpenLogs={() => setSection("logs")}
onChanged={() => void refresh()}
/>
)}
{section === "overview" && (
<ServerMetadataSection
instance={instance.data}
session={session}
operations={operations}
onChanged={(next) => setInstance({ status: "ready", data: next })}
onArchived={() => onNavigate("servers")}
/>
)}
{section === "overview" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
{section === "logs" && <LogsSection serverId={serverId} />}
{section === "config" && <ConfigSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
@@ -264,6 +301,99 @@ function uniqueArtifacts(artifacts: ArtifactResponse[]): ArtifactResponse[] {
return [...byID.values()];
}
interface ServerMetadataSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onChanged: (instance: ServerInstanceResponse) => void;
onArchived: () => void;
}
function ServerMetadataSection({ instance, session, operations, onChanged, onArchived }: ServerMetadataSectionProps) {
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const [confirmArchive, setConfirmArchive] = useState<ReturnType<typeof serverArchiveConfirmation> | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
useEffect(() => {
setDraft(serverMetadataFormFromInstance(instance));
}, [instance.id, instance.name]);
async function saveMetadata(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const operationId = operations.begin({ intent: "更新服务器信息", targetKind: "server", targetId: instance.id, requester: session.displayName });
setResult({ status: "pending", label: "正在保存服务器信息" });
try {
const updated = await platformApiClient.updateServerInstance(instance.id, serverMetadataUpdateRequestFromForm(draft));
onChanged(updated);
operations.succeed(operationId, `服务器信息已更新:${updated.id}`);
setResult({ status: "succeeded", label: `已更新 ${updated.name}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器信息更新失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "服务器信息更新失败" });
}
}
async function archiveServer() {
setConfirmBusy(true);
const operationId = operations.begin({ intent: "归档服务器", targetKind: "server", targetId: instance.id, requester: session.displayName });
try {
await platformApiClient.archiveServerInstance(instance.id);
operations.succeed(operationId, `服务器已归档:${instance.id}`);
setResult({ status: "succeeded", label: `${instance.name} 已归档` });
setConfirmArchive(null);
onArchived();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "服务器归档失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "归档失败,平台拒绝当前状态" });
} finally {
setConfirmBusy(false);
}
}
return (
<article className="console-panel" aria-label="server metadata">
<div className="panel-header">
<h2>
<Pencil size={16} style={{ verticalAlign: "-2px" }} />
</h2>
{result && <ResultBadge status={result.status} label={result.label} />}
</div>
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveMetadata(event)}>
<label>
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
</label>
<div className="action-strip">
<button type="submit" className="primary-command" disabled={draft.name.trim() === instance.name}>
<Pencil size={14} />
<span></span>
</button>
<button type="button" className="icon-command danger-command" disabled={!canArchiveServer(instance.state)} onClick={() => setConfirmArchive(serverArchiveConfirmation(instance))}>
<Archive size={14} />
<span></span>
</button>
</div>
</form>
{!canArchiveServer(instance.state) && (
<p className="provider-id" style={{ marginTop: 10 }}>
</p>
)}
<ConfirmDialog
open={confirmArchive !== null}
title="归档服务器"
description={`确认归档 ${confirmArchive?.name ?? ""}${confirmArchive?.serverInstanceId ?? ""})?运行中或安装中的服务器会被平台拒绝,历史记录会保留。`}
confirmLabel="确认归档"
danger
busy={confirmBusy}
onCancel={() => setConfirmArchive(null)}
onConfirm={() => void archiveServer()}
/>
</article>
);
}
interface ServerAdministratorsSectionProps {
instance: ServerInstanceResponse;
session: PageComponentProps["session"];
@@ -451,6 +581,362 @@ function OverviewSection({ instance, metrics, jobs, onOpenLogs }: OverviewSectio
);
}
interface RuntimeDistributionSectionProps {
instance: ServerInstanceResponse;
runtimeActions: LoadState<ServerRuntimeActionsResponse>;
session: PageComponentProps["session"];
operations: PageComponentProps["operations"];
onOpenLogs: () => void;
onChanged: () => void;
}
function RuntimeDistributionSection({ instance, runtimeActions, session, operations, onOpenLogs, onChanged }: RuntimeDistributionSectionProps) {
const defaults = runtimeDefaultsForPlugin(instance.pluginId);
const [targetOs, setTargetOs] = useState(defaults.runOs);
const [targetArch, setTargetArch] = useState("amd64");
const [profileKey, setProfileKey] = useState(defaults.clientProfileKey);
const [repositoryUrl, setRepositoryUrl] = useState(defaults.repositoryUrl);
const [sourceRevision, setSourceRevision] = useState(defaults.sourceRevision);
const [probeKey, setProbeKey] = useState(defaults.probeKey);
const [installPlanKey, setInstallPlanKey] = useState(defaults.installPlanKey);
const [logSourceKey, setLogSourceKey] = useState(defaults.logSourceKey);
const [checkpointRef, setCheckpointRef] = useState("");
const [lastRun, setLastRun] = useState<RunDistributionResponse | null>(null);
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const actionByKey = useMemo(() => {
if (runtimeActions.status !== "ready") {
return new Map<string, { available: boolean; reason?: string }>();
}
return new Map(runtimeActions.data.actions.map((action) => [action.key, { available: action.available, reason: action.reason }]));
}, [runtimeActions]);
function canUse(key: string): boolean {
return actionByKey.get(key)?.available ?? false;
}
function reasonFor(key: string): string {
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
}
async function runOperation<T>(intent: string, execute: () => Promise<T>, summarize: (value: T) => string) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
setResult({ status: "pending", label: `${intent} 执行中` });
try {
const value = await execute();
const label = summarize(value);
operations.succeed(operationId, label);
setResult({ status: "succeeded", label });
onChanged();
} catch (error) {
const reason = error instanceof Error ? error.message : `${intent} 失败`;
operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason });
}
}
function latestRunArtifact(): { artifactId: string; checksum?: string } | null {
if (lastRun) {
return { artifactId: lastRun.artifactId, checksum: lastRun.checksum };
}
if (lastDownload) {
return { artifactId: lastDownload.artifactId, checksum: lastDownload.checksum };
}
return null;
}
return (
<article className="console-panel" aria-label="run distribution controls">
<div className="panel-header">
<h2>
<PackageOpen size={16} style={{ verticalAlign: "-2px" }} />
</h2>
{runtimeActions.status === "ready" ? (
<span className={cx("status-pill", runtimeActions.data.runStatus === "online" ? "status-active" : "status-disabled")}>
run {runtimeActions.data.runStatus}
</span>
) : runtimeActions.status === "error" ? (
<ResultBadge status="failed" label={runtimeActions.reason} />
) : (
<span className="page-status"></span>
)}
</div>
{result && (
<div style={{ marginBottom: 10 }}>
<ResultBadge status={result.status} label={result.label} />
</div>
)}
<div className="provider-form" style={{ marginBottom: 12 }}>
<div className="form-grid">
<label>
run
<select value={targetOs} onChange={(event) => setTargetOs(event.target.value)}>
<option value="linux">linux</option>
<option value="windows">windows</option>
<option value="darwin">darwin</option>
</select>
</label>
<label>
<select value={targetArch} onChange={(event) => setTargetArch(event.target.value)}>
<option value="amd64">amd64</option>
<option value="arm64">arm64</option>
</select>
</label>
<label>
profile
<input value={profileKey} onChange={(event) => setProfileKey(event.target.value)} />
</label>
<label>
<input value={repositoryUrl} onChange={(event) => setRepositoryUrl(event.target.value)} />
</label>
<label>
revision
<input value={sourceRevision} onChange={(event) => setSourceRevision(event.target.value)} />
</label>
<label>
probe
<input value={probeKey} onChange={(event) => setProbeKey(event.target.value)} />
</label>
<label>
plan
<input value={installPlanKey} onChange={(event) => setInstallPlanKey(event.target.value)} />
</label>
<label>
<input value={logSourceKey} onChange={(event) => setLogSourceKey(event.target.value)} />
</label>
</div>
</div>
<div className="plugin-group-body">
<RuntimeActionRow
title="run 包"
description={`生成 ${targetOs}/${targetArch} run。包内含当前密钥,界面只显示 generation、artifact 和 secret ref。`}
disabled={!canUse("generate-run")}
reason={reasonFor("generate-run")}
actionLabel="生成 run"
onAction={() =>
void runOperation(
"生成 run",
async () => {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, targetOs, targetArch));
setLastRun(distribution);
return distribution;
},
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 已生成,artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`
)
}
/>
<RuntimeActionRow
title="run 下载与更新"
description={lastDownload ? `最近下载引用 ${lastDownload.artifactId}` : "下载最新 run 包,或用最近生成/下载的 artifact 推送自更新。"}
disabled={!canUse("download-run")}
reason={reasonFor("download-run")}
actionLabel="下载 run"
onAction={() =>
void runOperation(
"下载 run",
async () => {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
setLastDownload(reference);
return reference;
},
(reference) => `下载引用已创建,artifact ${reference.artifactId},有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}`
)
}
secondaryLabel="推送更新"
secondaryDisabled={!canUse("push-run-update") || latestRunArtifact() === null}
secondaryReason={latestRunArtifact() === null ? "请先生成或下载 run 包" : reasonFor("push-run-update")}
onSecondary={() =>
void runOperation(
"推送 run 更新",
async () => {
const artifact = latestRunArtifact();
if (!artifact) {
throw new Error("请先生成或下载 run 包");
}
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
},
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`
)
}
/>
<RuntimeActionRow
title="run 密钥"
description="重置后旧 run 包会失效,必须重新生成并重新部署。"
disabled={!canUse("reset-run-key")}
reason={reasonFor("reset-run-key")}
actionLabel="重置 run 密钥"
danger
onAction={() =>
void runOperation(
"重置 run 密钥",
() => platformApiClient.resetRunKey(instance.id),
(key) => `run 密钥已重置,generation ${key.generation}fingerprint ${key.fingerprint}`
)
}
/>
<RuntimeActionRow
title="客户端管理器"
description={lastClient ? `最近构建 ${lastClient.artifactId}generation ${lastClient.keyGeneration}` : "按插件声明的 profile 构建客户端管理器,使用独立密钥。"}
disabled={!canUse("generate-client-manager")}
reason={reasonFor("generate-client-manager")}
actionLabel="生成客户端"
onAction={() =>
void runOperation(
"生成客户端管理器",
async () => {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({ serverInstanceId: instance.id, profileKey, targetOs, targetArch, repositoryUrl, sourceRevision })
);
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器已生成,artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`
)
}
secondaryLabel="下载客户端"
secondaryDisabled={!canUse("download-client-manager")}
secondaryReason={reasonFor("download-client-manager")}
onSecondary={() =>
void runOperation(
"下载客户端管理器",
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }),
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}`
)
}
/>
<RuntimeActionRow
title="客户端密钥"
description="客户端管理器和 run 使用不同密钥。重置后旧客户端必须重新生成。"
disabled={!canUse("reset-client-manager-key")}
reason={reasonFor("reset-client-manager-key")}
actionLabel="重置客户端密钥"
danger
onAction={() =>
void runOperation(
"重置客户端密钥",
() => platformApiClient.resetClientManagerKey(instance.id, { componentKind: "client-manager", componentKey: profileKey }),
(key) => `客户端密钥已重置,generation ${key.generation}fingerprint ${key.fingerprint}`
)
}
/>
<RuntimeActionRow
title="依赖"
description={`检查 ${probeKey},安装计划 ${installPlanKey || "未填写"}`}
disabled={!canUse("dependencies-check")}
reason={reasonFor("dependencies-check")}
actionLabel="依赖检查"
onAction={() =>
void runOperation(
"依赖检查",
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
(job) => `依赖检查任务已排队,job ${job.id}`
)
}
secondaryLabel="依赖安装"
secondaryDisabled={!canUse("dependencies-install") || !installPlanKey.trim()}
secondaryReason={!installPlanKey.trim() ? "请填写插件声明的 install plan" : reasonFor("dependencies-install")}
onSecondary={() =>
void runOperation(
"依赖安装",
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
(job) => `依赖安装任务已排队,job ${job.id}`
)
}
/>
<RuntimeActionRow
title="日志"
description="实时日志来自平台日志 API,历史日志通过 backfill job 返回 cursor/ref。"
disabled={!canUse("live-logs")}
reason={reasonFor("live-logs")}
actionLabel="实时日志"
onAction={onOpenLogs}
secondaryLabel="历史回填"
secondaryDisabled={!canUse("historical-logs")}
secondaryReason={reasonFor("historical-logs")}
onSecondary={() =>
void runOperation(
"历史日志回填",
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
(job) => `历史日志回填任务已排队,job ${job.id}`
)
}
>
<label>
checkpoint ref
<input value={checkpointRef} placeholder="可选 artifact://logs/checkpoint/..." onChange={(event) => setCheckpointRef(event.target.value)} />
</label>
</RuntimeActionRow>
</div>
</article>
);
}
interface RuntimeActionRowProps {
title: string;
description: string;
disabled: boolean;
reason: string;
actionLabel: string;
danger?: boolean;
onAction: () => void;
secondaryLabel?: string;
secondaryDisabled?: boolean;
secondaryReason?: string;
onSecondary?: () => void;
children?: ReactNode;
}
function RuntimeActionRow({ title, description, disabled, reason, actionLabel, danger, onAction, secondaryLabel, secondaryDisabled, secondaryReason, onSecondary, children }: RuntimeActionRowProps) {
return (
<div className="plugin-control-row">
<span>
<strong>{title}</strong>
<p>{description}</p>
{disabled && <span className="provider-id">{reason}</span>}
{children}
</span>
<div className="action-strip">
<button type="button" className={cx("icon-command", danger && "danger-command")} disabled={disabled} title={disabled ? reason : actionLabel} onClick={onAction}>
<Sparkles size={14} />
<span>{actionLabel}</span>
</button>
{secondaryLabel && onSecondary && (
<button type="button" className="icon-command" disabled={secondaryDisabled} title={secondaryDisabled ? secondaryReason : secondaryLabel} onClick={onSecondary}>
<Download size={14} />
<span>{secondaryLabel}</span>
</button>
)}
</div>
</div>
);
}
function runtimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {
runOs: isScum ? "windows" : "linux",
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
sourceRevision: "main",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
logSourceKey: isScum ? "server-log" : "latest-log"
};
}
function safeRuntimeRef(ref: string): string {
if (ref.startsWith("secret://runtime-keys/") || ref.startsWith("artifact://")) {
return ref;
}
return "[redacted-ref]";
}
interface LogsSectionProps {
serverId: string;
}
@@ -471,8 +957,8 @@ function LogsSection({ serverId }: LogsSectionProps) {
const refresh = useCallback(async () => {
setStreams({ status: "loading" });
try {
const response = await platformApiClient.listLogStreams();
const serverStreams = response.items.filter((stream) => stream.serverInstanceId === serverId);
const response = await platformApiClient.listServerLiveLogs(serverId);
const serverStreams = response.items;
setStreams({ status: "ready", data: serverStreams });
const collected: Array<LogEntryBody & { source: string }> = [];
for (const stream of serverStreams) {
@@ -659,9 +1145,9 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
const response: ServerConfigResponse = await platformApiClient.getServerConfig(serverId);
setConfig({ status: "ready", data: { content: response.content, source: "api" } });
setDraft(response.content);
} catch {
setConfig({ status: "ready", data: { content: fallbackConfig, source: "local" } });
setDraft(fallbackConfig);
} catch (error) {
setConfig({ status: "error", reason: error instanceof Error ? error.message : "配置读取接口不可用" });
setDraft("");
}
}, [serverId]);
@@ -717,9 +1203,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
<article className="console-panel" aria-label="server configuration">
<div className="panel-header">
<h2></h2>
{config.status === "ready" && (
<span className="page-status">{config.data.source === "api" ? `配置版本 v${instance.configVersion}` : "本地示例配置(配置读取接口未提供)"}</span>
)}
{config.status === "ready" && <span className="page-status"> v{instance.configVersion}</span>}
</div>
{writeOperation && (
<div style={{ marginBottom: 10 }}>
@@ -736,6 +1220,7 @@ function ConfigSection({ serverId, instance, session, operations }: ConfigSectio
</div>
)}
{config.status === "loading" && <LoadingState label="正在加载配置…" compact />}
{config.status === "error" && <ErrorState title="配置读取不可用" reason={config.reason} diagnosticId={`server-config:${serverId}`} onRetry={() => void refresh()} compact />}
{previewError && <ErrorState title="配置差异预览失败" reason={previewError} compact />}
{config.status === "ready" && (
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void prepareDiff(event)}>
@@ -1125,7 +1610,7 @@ interface LlmSectionProps {
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
const [prompt, setPrompt] = useState("");
const [currentConfig, setCurrentConfig] = useState<string>(fallbackConfig);
const [currentConfig, setCurrentConfig] = useState<string>("");
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
@@ -1140,7 +1625,7 @@ function LlmSection({ serverId, instance, session, operations }: LlmSectionProps
}
})
.catch(() => {
// keep the local fallback config
setCurrentConfig("");
});
return () => {
cancelled = true;
+141 -5
View File
@@ -14,7 +14,14 @@ import {
type ServerCreateFormState
} from "../contracts/serverManagement";
import { filterServerCards, serverIsOnline, type ServerCardView, type ServerStatusFilter } from "../contracts/workspace";
import { serverCreateRequestFromForm } from "../schemas/serverManagement";
import {
clientManagerBuildRequest,
dependencyJobRequest,
logBackfillRequest,
runDistributionGenerateRequest,
runUpdateRequest,
serverCreateRequestFromForm
} from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace";
import { cx } from "../utils/classes";
@@ -114,6 +121,56 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
}
}
async function handleQuickRuntimeAction(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction) {
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
const intent = quickRuntimeActionLabel(action);
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
try {
let message = "运行操作已提交";
if (action === "generate-run") {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
message = `run 包已生成,artifact ${distribution.artifactId}`;
} else if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
message = `run 下载引用已创建,artifact ${reference.artifactId}`;
} else if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`;
} else if (action === "generate-client-manager") {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({
serverInstanceId: instance.id,
profileKey: defaults.clientProfileKey,
targetOs: defaults.clientOs,
targetArch: "amd64",
repositoryUrl: defaults.repositoryUrl,
sourceRevision: "main"
})
);
message = `客户端管理器已生成,artifact ${distribution.artifactId}`;
} else if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
message = `依赖检查任务已排队,job ${job.id}`;
} else if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
message = `依赖安装任务已排队,job ${job.id}`;
} else if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
message = "已打开服务器详情,可切换到日志页查看实时日志";
} else if (action === "historical-logs") {
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
message = `历史日志回填任务已排队,job ${job.id}`;
}
operations.succeed(operationId, message);
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId);
}
}
const latestCreate = operations.operations.find((operation) => operation.intent === "创建服务器");
return (
@@ -240,7 +297,13 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
{visibleCards.length > 0 && (
<div className="server-card-grid" aria-label="server list">
{visibleCards.map((card) => (
<ServerCard key={card.instance.id} card={card} metricsPending={metricsPending} onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })} />
<ServerCard
key={card.instance.id}
card={card}
metricsPending={metricsPending}
onOpen={() => onNavigate("serverDetail", { serverId: card.instance.id })}
onQuickAction={(action) => void handleQuickRuntimeAction(card.instance, action)}
/>
))}
</div>
)}
@@ -248,17 +311,28 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
);
}
type ServerQuickRuntimeAction =
| "generate-run"
| "download-run"
| "push-run-update"
| "generate-client-manager"
| "dependencies-check"
| "dependencies-install"
| "live-logs"
| "historical-logs";
interface ServerCardProps {
card: ServerCardView;
metricsPending: boolean;
onOpen: () => void;
onQuickAction: (action: ServerQuickRuntimeAction) => void;
}
function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
const online = serverIsOnline(instance.state);
return (
<button type="button" className="server-card" onClick={onOpen} aria-label={`打开 ${instance.name} 详情`}>
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
<div className="server-card-head">
<span>
<strong>{instance.name}</strong>
@@ -289,10 +363,72 @@ function ServerCard({ card, metricsPending, onOpen }: ServerCardProps) {
<UsageMeter label="内存" percent={metrics?.memoryPercent} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} />
</div>
</button>
<div className="action-strip" style={{ justifyContent: "space-between" }}>
<button type="button" className="icon-command" onClick={onOpen}>
<Sparkles size={14} />
<span></span>
</button>
<details className="runtime-action-menu">
<summary className="icon-command"></summary>
<div className="action-list">
{serverQuickActions.map((action) => (
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
<Candy size={13} />
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</details>
</div>
</article>
);
}
const serverQuickActions: ServerQuickRuntimeAction[] = [
"generate-run",
"download-run",
"push-run-update",
"generate-client-manager",
"dependencies-check",
"dependencies-install",
"live-logs",
"historical-logs"
];
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
switch (action) {
case "generate-run":
return "生成 run";
case "download-run":
return "下载 run";
case "push-run-update":
return "推送更新";
case "generate-client-manager":
return "生成客户端";
case "dependencies-check":
return "依赖检查";
case "dependencies-install":
return "依赖安装";
case "live-logs":
return "实时日志";
case "historical-logs":
return "历史日志";
}
}
function quickRuntimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {
runOs: isScum ? "windows" : "linux",
clientOs: "windows",
clientProfileKey: isScum ? "scum-client-manager" : "client-manager",
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
probeKey: isScum ? "steamcmd" : "java-21",
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
logSourceKey: isScum ? "server-log" : "latest-log"
};
}
function formatStat(value: number | undefined, pending: boolean, format: (value: number) => string): string {
if (typeof value === "number" && Number.isFinite(value)) {
return format(value);
+101
View File
@@ -0,0 +1,101 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { UsersPage } from "./UsersPage";
import type { UserResponse } from "../api/types";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
const adminUser: CurrentUserView = {
id: "user-admin",
displayName: "Operator",
status: "active",
roles: ["platformAdmin"],
capabilities: capabilitiesForRoles(["platformAdmin"]),
profile: {},
source: "local"
};
const serverUser: CurrentUserView = {
...adminUser,
id: "user-server",
roles: ["serverAdmin"],
capabilities: capabilitiesForRoles(["serverAdmin"])
};
const managedUser: UserResponse = {
id: "user-reviewer",
displayName: "Plugin Reviewer",
email: "reviewer@example.test",
status: "pending",
roles: ["server-admin"],
profile: { phone: "13800000000", qq: "10001", contactNote: "needs approval" },
createdAt: "2026-07-03T00:00:00Z",
updatedAt: "2026-07-03T00:00:00Z"
};
const noopOperations: OperationTracker = {
operations: [],
begin: () => "op-test",
update: () => undefined,
succeed: () => undefined,
fail: () => undefined,
isPending: () => false
};
function pageProps(session = adminUser): PageComponentProps {
return {
session,
params: {},
operations: noopOperations,
onNavigate: () => undefined,
onLogout: async () => undefined,
onProfileSave: async () => session,
onThemePreferenceSave: async () => ({ userId: session.id, paletteId: "mecha-black", backgroundPresetId: "mecha-grid", persistence: "api", updatedAt: "2026-07-03T00:00:00Z" })
};
}
describe("UsersPage", () => {
it("renders an empty API-backed state without local users", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [], loading: false, source: "api" }} />);
expect(html).toContain("暂无用户");
expect(html).toContain("page-summary-chip");
expect(html).not.toContain("metric-card");
expect(html).not.toContain("Plugin Reviewer");
});
it("renders explicit local-development fixture state with a single status flow", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "local-development" }} />);
expect(html).toContain("Plugin Reviewer");
expect(html).toContain("本地开发样例 / 禁止假成功");
expect(html).toContain("编辑");
expect(html).toContain("状态");
expect(html).toContain("应用状态");
expect(html).toContain("停用");
expect(html).toContain("邀请用户");
expect(html).toContain("审核申请");
expect(html).toContain("绑定服务器范围");
expect(html).toContain("角色影响");
expect(html).toContain("needs approval");
});
it("does not render create or edit forms inline on the list page", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps()} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
expect(html).toContain("访问列表");
expect(html).toContain("邀请用户");
expect(html).not.toContain('role="dialog"');
expect(html).not.toContain('aria-label="编辑用户"');
});
it("does not render account maintenance controls for non-admins", () => {
const html = renderToStaticMarkup(<UsersPage {...pageProps(serverUser)} initialState={{ users: [managedUser], loading: false, source: "api" }} />);
expect(html).toContain("当前账号不能管理用户");
expect(html).not.toContain("邀请用户");
expect(html).not.toContain("停用");
});
});
+312 -74
View File
@@ -1,13 +1,16 @@
import { HeartHandshake, Sparkles, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { HeartHandshake, Sparkles, UserPen, UserRoundCheck, UserRoundPlus } from "lucide-react";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { UserCreateRequest, UserResponse, UserStatus } from "../api/types";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import { PageFrame } from "../components/PageFrame";
import type { PageComponentProps } from "../contracts/page";
import { userAccess } from "../contracts/shell";
import { type UserEditFormState, type UserListSource, type UserRemovalConfirmationState, userEditFormFromResponse } from "../contracts/users";
import { isPlatformAdmin } from "../contracts/workspace";
import { userCreateRequestFromDraft, userDeactivateRequest, userUpdateRequestFromEditForm } from "../schemas/users";
import { cx } from "../utils/classes";
const roleOptions = [
@@ -37,12 +40,29 @@ const fallbackUsers: UserResponse[] = userAccess.map((user, index) => ({
updatedAt: "2026-07-03T00:00:00Z"
}));
export function UsersPage({ session, operations }: PageComponentProps) {
const [users, setUsers] = useState<UserResponse[]>(fallbackUsers);
const [loading, setLoading] = useState(true);
const [source, setSource] = useState<"api" | "local">("local");
const [loadError, setLoadError] = useState("");
interface UsersPageInitialState {
users?: UserResponse[];
loading?: boolean;
source?: UserListSource;
loadError?: string;
}
interface UsersPageProps extends PageComponentProps {
initialState?: UsersPageInitialState;
}
export function UsersPage({ session, operations, initialState }: UsersPageProps) {
const [users, setUsers] = useState<UserResponse[]>(initialState?.users ?? []);
const [loading, setLoading] = useState(initialState?.loading ?? true);
const [source, setSource] = useState<UserListSource>(initialState?.source ?? "api");
const [loadError, setLoadError] = useState(initialState?.loadError ?? "");
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string }>();
const [createOpen, setCreateOpen] = useState(false);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<UserEditFormState | null>(null);
const [confirmRemoval, setConfirmRemoval] = useState<UserRemovalConfirmationState | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [statusDrafts, setStatusDrafts] = useState<Record<string, UserStatus>>({});
const [draft, setDraft] = useState<UserCreateRequest>({
displayName: "",
email: "",
@@ -53,6 +73,15 @@ export function UsersPage({ session, operations }: PageComponentProps) {
useEffect(() => {
let cancelled = false;
if (initialState?.users) {
setUsers(initialState.users);
setLoading(initialState.loading ?? false);
setSource(initialState.source ?? "api");
setLoadError(initialState.loadError ?? "");
return () => {
cancelled = true;
};
}
void platformApiClient
.listUsers()
.then((response) => {
@@ -64,9 +93,9 @@ export function UsersPage({ session, operations }: PageComponentProps) {
})
.catch(() => {
if (!cancelled) {
setUsers(fallbackUsers);
setSource("local");
setLoadError("账号 API 加载失败,当前显示本地样例;创建与状态更新仍会尝试平台 API。");
setUsers(import.meta.env.DEV ? fallbackUsers : []);
setSource(import.meta.env.DEV ? "local-development" : "api");
setLoadError(import.meta.env.DEV ? "账号 API 加载失败,当前显示本地开发样例;状态变更会被平台 API 拒绝或写入。" : "账号 API 加载失败,未显示本地样例数据。");
}
})
.finally(() => {
@@ -77,7 +106,11 @@ export function UsersPage({ session, operations }: PageComponentProps) {
return () => {
cancelled = true;
};
}, []);
}, [initialState]);
useEffect(() => {
setStatusDrafts(Object.fromEntries(users.map((user) => [user.id, user.status])));
}, [users]);
const counts = useMemo(
() => ({
@@ -87,34 +120,96 @@ export function UsersPage({ session, operations }: PageComponentProps) {
}),
[users]
);
const persistenceDisabled = source === "local-development";
async function createUser(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const request: UserCreateRequest = {
...draft,
displayName: draft.displayName.trim(),
email: draft.email?.trim(),
roles: draft.roles.length ? draft.roles : ["server-admin"],
profile: {
phone: draft.profile?.phone?.trim(),
qq: draft.profile?.qq?.trim(),
contactNote: draft.profile?.contactNote?.trim()
}
};
const operationId = operations.begin({ intent: "创建用户", targetKind: "platform", targetId: "users", requester: session.displayName });
setResult({ status: "pending", label: `正在创建用户 ${operationId}` });
const request = userCreateRequestFromDraft(draft);
const operationId = operations.begin({ intent: "邀请用户", targetKind: "platform", targetId: "users", requester: session.displayName });
setResult({ status: "pending", label: `正在发送邀请 ${operationId}` });
try {
const created = await platformApiClient.createUser(request);
setUsers((current) => [created, ...current.filter((user) => user.id !== created.id)]);
operations.succeed(operationId, `用户已创建:${created.id}`);
operations.succeed(operationId, `用户邀请已创建:${created.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `创建 ${created.displayName}` });
setResult({ status: "succeeded", label: `邀请 ${created.displayName}` });
setCreateOpen(false);
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户创建失败");
setResult({ status: "failed", label: "用户创建失败,未写入数据库" });
}
setDraft({ displayName: "", email: "", roles: ["server-admin"], status: "pending", profile: { phone: "", qq: "", contactNote: "" } });
}
function startEdit(user: UserResponse) {
setEditingUserId(user.id);
setEditDraft(userEditFormFromResponse(user));
setResult(undefined);
}
function closeEdit() {
setEditingUserId(null);
setEditDraft(null);
}
function updateEdit<K extends keyof UserEditFormState>(key: K, value: UserEditFormState[K]) {
setEditDraft((current) => (current ? { ...current, [key]: value } : current));
}
function toggleEditRole(role: string) {
setEditDraft((current) => {
if (!current) {
return current;
}
const roles = current.roles.includes(role) ? current.roles.filter((item) => item !== role) : [...current.roles, role];
return { ...current, roles };
});
}
async function saveUserEdit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingUserId || !editDraft) {
return;
}
const operationId = operations.begin({ intent: "编辑用户", targetKind: "platform", targetId: editingUserId, requester: session.displayName });
setResult({ status: "pending", label: "正在保存用户" });
try {
const updated = await platformApiClient.updateUser(editingUserId, userUpdateRequestFromEditForm(editDraft));
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
setEditingUserId(null);
setEditDraft(null);
setSource("api");
setLoadError("");
operations.succeed(operationId, `用户已更新:${updated.id}`);
setResult({ status: "succeeded", label: `已更新 ${updated.displayName}` });
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户更新失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "用户更新失败,未写入数据库" });
}
}
async function deactivateUser() {
if (!confirmRemoval) {
return;
}
setConfirmBusy(true);
const user = confirmRemoval.user;
const operationId = operations.begin({ intent: "停用用户", targetKind: "platform", targetId: user.id, requester: session.displayName });
try {
const updated = await platformApiClient.updateUser(user.id, userDeactivateRequest());
setUsers((current) => current.map((item) => (item.id === updated.id ? updated : item)));
operations.succeed(operationId, `用户已停用:${updated.id}`);
setSource("api");
setLoadError("");
setResult({ status: "succeeded", label: `${updated.displayName} 已停用` });
setConfirmRemoval(null);
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "用户停用失败");
setResult({ status: "failed", label: error instanceof Error ? error.message : "停用失败,平台拒绝变更" });
} finally {
setConfirmBusy(false);
}
}
async function setUserStatus(user: UserResponse, status: UserStatus) {
@@ -140,6 +235,18 @@ export function UsersPage({ session, operations }: PageComponentProps) {
});
}
function applyUserStatus(user: UserResponse) {
const nextStatus = statusDrafts[user.id] ?? user.status;
if (nextStatus === user.status) {
return;
}
if (nextStatus === "disabled") {
setConfirmRemoval({ action: "deactivate", user });
return;
}
void setUserStatus(user, nextStatus);
}
if (!isPlatformAdmin(session)) {
return (
<div className="console-page">
@@ -160,7 +267,7 @@ export function UsersPage({ session, operations }: PageComponentProps) {
<PageFrame
kicker="身份"
title="用户管理"
status={source === "api" ? "账号 API 已连接" : "本地回退"}
status={source === "api" ? "账号 API 已连接" : "本地开发数据"}
metrics={[
{ label: "用户", value: `${users.length}`, tone: "success" },
{ label: "角色", value: `${counts.roleCount}`, tone: "neutral" },
@@ -173,10 +280,75 @@ export function UsersPage({ session, operations }: PageComponentProps) {
<section className="console-panel">
<div className="panel-header">
<h2></h2>
<h2>访</h2>
{loading ? <ResultBadge status="pending" label="加载用户…" /> : result && <ResultBadge status={result.status} label={result.label} />}
<span className="page-status">{source === "api" ? "平台数据" : "本地开发样例 / 禁止假成功"}</span>
<button type="button" className="primary-command" disabled={persistenceDisabled} onClick={() => setCreateOpen(true)}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "邀请用户"}</span>
</button>
</div>
<form className="management-form" onSubmit={createUser}>
<AccessFlowGuide />
<RoleImpactGuide />
{users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。邀请用户后会在这里显示 API 连接结果。" actionLabel="邀请用户" onAction={() => setCreateOpen(true)} />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
<span className="provider-id">{user.email ?? user.id}</span>
</div>
<span className={cx("status-pill", statusClass(user.status))}>
<UserRoundCheck size={13} />
{statusLabel(user.status)}
</span>
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
<button type="button" className="theme-upload" disabled={persistenceDisabled} aria-label={`编辑 ${user.displayName}`} onClick={() => startEdit(user)}>
<UserPen size={13} />
</button>
<label className="status-update-control">
<span></span>
<select
value={statusDrafts[user.id] ?? user.status}
disabled={persistenceDisabled}
aria-label={`选择 ${user.displayName} 状态`}
onChange={(event) => setStatusDrafts((current) => ({ ...current, [user.id]: event.target.value as UserStatus }))}
>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<button
type="button"
className="theme-upload"
disabled={persistenceDisabled || (statusDrafts[user.id] ?? user.status) === user.status}
aria-label={`应用 ${user.displayName} 状态变更`}
onClick={() => applyUserStatus(user)}
>
<UserRoundCheck size={13} />
</button>
</div>
</article>
))}
</div>
)}
</section>
<ManagementDialog open={createOpen} title="邀请用户" wide onClose={() => setCreateOpen(false)}>
<form className="management-form dialog-form" onSubmit={createUser}>
<div className="form-guidance management-form-wide">
<strong></strong>
<span></span>
</div>
<label>
<span></span>
<input value={draft.displayName} required onChange={(event) => setDraft((current) => ({ ...current, displayName: event.target.value }))} />
@@ -226,53 +398,119 @@ export function UsersPage({ session, operations }: PageComponentProps) {
</button>
))}
</div>
<button type="submit" className="profile-save-button">
<UserRoundPlus size={14} />
<span></span>
</button>
</form>
</section>
<section className="console-panel">
<div className="panel-header">
<h2>访</h2>
<span className="page-status">{source === "api" ? "平台数据" : "本地样例 / 待同步"}</span>
</div>
{users.length === 0 ? (
<EmptyState title="暂无用户" description="平台暂未返回可管理账号。创建用户后会在这里显示 API 连接结果。" />
) : (
<div className="resource-list user-management-list">
{users.map((user) => (
<article key={user.id} className="resource-list-item user-management-item">
<div>
<strong>{user.displayName}</strong>
<span className="provider-id">{user.email ?? user.id}</span>
</div>
<span className={cx("status-pill", statusClass(user.status))}>
<UserRoundCheck size={13} />
{statusLabel(user.status)}
</span>
<span>{user.roles.map(roleLabel).join(" / ")}</span>
<span>{profileSummary(user)}</span>
<div className="user-actions" aria-label={`${user.displayName} 状态操作`}>
{statusOptions.map((option) => (
<button
key={option.value}
type="button"
className="theme-upload"
disabled={user.status === option.value}
aria-label={`${user.displayName} 设为${option.label}`}
onClick={() => void setUserStatus(user, option.value)}
>
{option.label}
</button>
))}
</div>
</article>
))}
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={() => setCreateOpen(false)}>
</button>
<button type="submit" className="confirm-primary" disabled={persistenceDisabled}>
<UserRoundPlus size={14} />
<span>{persistenceDisabled ? "等待 API" : "发送邀请"}</span>
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog open={editDraft !== null} title="编辑用户" wide onClose={closeEdit}>
{editDraft && (
<form className="management-form dialog-form" onSubmit={(event) => void saveUserEdit(event)}>
<label>
<span></span>
<input value={editDraft.displayName} required onChange={(event) => updateEdit("displayName", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.email} type="email" onChange={(event) => updateEdit("email", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.phone} inputMode="tel" onChange={(event) => updateEdit("phone", event.target.value)} />
</label>
<label>
<span>QQ</span>
<input value={editDraft.qq} inputMode="numeric" onChange={(event) => updateEdit("qq", event.target.value)} />
</label>
<label>
<span></span>
<input value={editDraft.contactNote} onChange={(event) => updateEdit("contactNote", event.target.value)} />
</label>
<label>
<span></span>
<select value={editDraft.status} onChange={(event) => updateEdit("status", event.target.value as UserStatus)}>
{statusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="role-selector" aria-label="编辑角色">
{roleOptions.map((role) => (
<button key={role.value} type="button" className={cx("role-chip", editDraft.roles.includes(role.value) && "role-chip-active")} onClick={() => toggleEditRole(role.value)}>
<Sparkles size={13} />
<span>{role.label}</span>
</button>
))}
</div>
<RoleImpactGuide />
<div className="confirm-actions">
<button type="button" onClick={closeEdit}>
</button>
<button type="submit" className="confirm-primary">
<UserPen size={14} />
<span></span>
</button>
</div>
</form>
)}
</section>
</ManagementDialog>
<ConfirmDialog
open={confirmRemoval !== null}
title="停用用户"
description={`确认停用 ${confirmRemoval?.user.displayName ?? ""}?该账号将无法登录,已有审计记录会保留。`}
confirmLabel="确认停用"
danger
busy={confirmBusy}
onCancel={() => {
if (confirmRemoval) {
setStatusDrafts((current) => ({ ...current, [confirmRemoval.user.id]: confirmRemoval.user.status }));
}
setConfirmRemoval(null);
}}
onConfirm={() => void deactivateUser()}
/>
</div>
);
}
function AccessFlowGuide() {
return (
<div className="workflow-hint-grid" aria-label="用户管理流程提示">
<div className="workflow-hint-card">
<strong></strong>
<span></span>
</div>
<div className="workflow-hint-card">
<strong></strong>
<span></span>
</div>
<div className="workflow-hint-card">
<strong></strong>
<span>/</span>
</div>
</div>
);
}
function RoleImpactGuide() {
return (
<div className="role-impact-list management-form-wide" aria-label="角色影响说明">
<strong></strong>
<span>AI </span>
<span></span>
<span></span>
</div>
);
}
+5 -1
View File
@@ -1,4 +1,4 @@
import type { AiProviderRequest, AiProviderUpdateRequest } from "../api/types";
import type { AiProviderRequest, AiProviderStatusRequest, AiProviderUpdateRequest } from "../api/types";
import type { AiProviderFormState } from "../contracts/aiProviders";
export function aiProviderCreateRequestFromForm(form: AiProviderFormState): AiProviderRequest {
@@ -26,3 +26,7 @@ export function aiProviderUpdateRequestFromForm(form: AiProviderFormState): AiPr
redactionPolicy: form.redactionPolicy.trim() || "default"
};
}
export function aiProviderRetireRequest(): AiProviderStatusRequest {
return { status: "disabled" };
}
+81 -2
View File
@@ -1,5 +1,15 @@
import type { ServerLifecycleCommandRequest, ServerLifecycleCreateRequest, ServerInstanceResponse } from "../api/types";
import type { ServerCreateFormState } from "../contracts/serverManagement";
import type {
ClientManagerBuildRequest,
DependencyJobRequest,
LogBackfillRequest,
RunDistributionGenerateRequest,
RunUpdateRequest,
ServerInstanceUpdateRequest,
ServerLifecycleCommandRequest,
ServerLifecycleCreateRequest,
ServerInstanceResponse
} from "../api/types";
import type { ServerCreateFormState, ServerMetadataFormState, ServerRemovalConfirmationState } from "../contracts/serverManagement";
export function serverCreateRequestFromForm(form: ServerCreateFormState, sequence = Date.now()): ServerLifecycleCreateRequest {
const id = form.id.trim();
@@ -19,6 +29,75 @@ export function serverLifecycleCommandRequest(instance: ServerInstanceResponse,
};
}
export function serverMetadataUpdateRequestFromForm(form: ServerMetadataFormState): ServerInstanceUpdateRequest {
return { name: form.name.trim() };
}
export function serverArchiveConfirmation(instance: ServerInstanceResponse): ServerRemovalConfirmationState {
return {
action: "archive",
serverInstanceId: instance.id,
name: instance.name,
state: instance.state
};
}
export function runDistributionGenerateRequest(serverInstanceId: string, targetOs: string, targetArch: string, sequence = Date.now()): RunDistributionGenerateRequest {
return {
targetOs,
targetArch,
idempotencyKey: runtimeIdempotencyKey("run.generate", serverInstanceId, sequence)
};
}
export function runUpdateRequest(serverInstanceId: string, artifactId: string, checksum?: string, sequence = Date.now()): RunUpdateRequest {
return {
artifactId,
checksum,
idempotencyKey: runtimeIdempotencyKey("run.update", serverInstanceId, sequence)
};
}
export function clientManagerBuildRequest(input: {
serverInstanceId: string;
profileKey: string;
targetOs: string;
targetArch: string;
repositoryUrl: string;
sourceRevision?: string;
sequence?: number;
}): ClientManagerBuildRequest {
return {
profileKey: input.profileKey.trim(),
targetOs: input.targetOs,
targetArch: input.targetArch,
repositoryUrl: input.repositoryUrl.trim(),
sourceRevision: input.sourceRevision?.trim() || undefined,
idempotencyKey: runtimeIdempotencyKey("client-manager.generate", input.serverInstanceId, input.sequence ?? Date.now())
};
}
export function dependencyJobRequest(serverInstanceId: string, probeKey: string, installPlanKey = "", sequence = Date.now()): DependencyJobRequest {
return {
probeKey: probeKey.trim(),
installPlanKey: installPlanKey.trim() || undefined,
idempotencyKey: runtimeIdempotencyKey(installPlanKey ? "dependencies.install" : "dependencies.check", serverInstanceId, sequence)
};
}
export function logBackfillRequest(serverInstanceId: string, sourceKey: string, checkpointRef = "", limit = 200, sequence = Date.now()): LogBackfillRequest {
return {
sourceKey: sourceKey.trim(),
checkpointRef: checkpointRef.trim() || undefined,
limit,
idempotencyKey: runtimeIdempotencyKey("logs.backfill", serverInstanceId, sequence)
};
}
export function runtimeIdempotencyKey(action: string, serverInstanceId: string, sequence: number): string {
return `web:${action}:${serverInstanceId}:${sequence}`;
}
export function lifecycleIdempotencyKey(action: "create" | "start" | "stop", serverInstanceId: string, sequence: number): string {
return `web:${action}:${serverInstanceId}:${sequence}`;
}
+34
View File
@@ -0,0 +1,34 @@
import type { UserCreateRequest, UserUpdateRequest } from "../api/types";
import type { UserEditFormState } from "../contracts/users";
export function userCreateRequestFromDraft(draft: UserCreateRequest): UserCreateRequest {
return {
...draft,
displayName: draft.displayName.trim(),
email: draft.email?.trim(),
roles: draft.roles.length ? draft.roles : ["server-admin"],
profile: {
phone: draft.profile?.phone?.trim(),
qq: draft.profile?.qq?.trim(),
contactNote: draft.profile?.contactNote?.trim()
}
};
}
export function userUpdateRequestFromEditForm(form: UserEditFormState): UserUpdateRequest {
return {
displayName: form.displayName.trim(),
email: form.email.trim(),
roles: form.roles,
status: form.status,
profile: {
phone: form.phone.trim(),
qq: form.qq.trim(),
contactNote: form.contactNote.trim()
}
};
}
export function userDeactivateRequest(): UserUpdateRequest {
return { status: "disabled" };
}
+386 -9
View File
@@ -1203,6 +1203,48 @@ button {
align-items: end;
}
.workflow-hint-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 10px 0 12px;
}
.workflow-hint-card,
.form-guidance,
.role-impact-list {
display: grid;
gap: 4px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
color: var(--ink-soft);
box-shadow: inset 0 1px 0 var(--crystal-rim);
font-size: 12.5px;
line-height: 1.45;
}
.workflow-hint-card strong,
.form-guidance strong,
.role-impact-list strong {
color: var(--ink);
font-size: 13px;
}
.management-form-wide,
.role-impact-list {
grid-column: 1 / -1;
}
.role-impact-list {
margin: 0 0 12px;
}
.management-form .role-impact-list {
margin: 0;
}
.role-selector {
grid-column: 1 / -1;
display: flex;
@@ -1225,6 +1267,20 @@ button {
justify-content: flex-end;
}
.status-update-control {
min-width: 132px;
}
.status-update-control select {
min-height: 32px;
border: 1px solid var(--line-strong);
border-radius: 8px;
padding: 0 8px;
background: var(--surface-solid);
color: var(--ink);
font: inherit;
}
.profile-settings-page {
gap: 18px;
}
@@ -1352,7 +1408,7 @@ button {
.page-frame {
width: min(100%, 1180px);
display: grid;
gap: 18px;
gap: 8px;
}
.page-header {
@@ -1409,6 +1465,57 @@ button {
color: var(--success);
}
.page-summary-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: -2px 0 0;
}
.page-summary-chip {
display: inline-flex;
align-items: baseline;
gap: 8px;
min-height: 28px;
padding: 4px 10px;
border: 1px solid color-mix(in srgb, var(--line) 72%, transparent);
border-left-width: 3px;
border-radius: 999px;
background: color-mix(in srgb, var(--surface) 52%, transparent);
color: var(--ink-faint);
-webkit-backdrop-filter: blur(14px) saturate(1.1);
backdrop-filter: blur(14px) saturate(1.1);
box-shadow: inset 0 1px 0 var(--crystal-rim);
}
.page-summary-chip dt,
.page-summary-chip dd {
margin: 0;
}
.page-summary-chip dt {
font-size: 12px;
font-weight: 650;
}
.page-summary-chip dd {
color: var(--ink);
font-size: 13px;
font-weight: 760;
}
.summary-tone-success {
border-left-color: var(--teal);
}
.summary-tone-warning {
border-left-color: var(--gold);
}
.summary-tone-neutral {
border-left-color: var(--accent);
}
/* ---- metric cards ---- */
.metric-grid {
@@ -2598,10 +2705,6 @@ button {
min-height: 52px;
}
.ai-provider-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ai-provider-toolbar {
display: flex;
align-items: center;
@@ -2610,7 +2713,6 @@ button {
flex-wrap: wrap;
}
.ai-provider-workspace,
.server-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(300px, 360px);
@@ -2618,7 +2720,6 @@ button {
align-items: start;
}
.ai-provider-workspace > *,
.server-workspace > * {
min-width: 0;
}
@@ -2743,6 +2844,68 @@ button {
cursor: not-allowed;
}
.provider-actions-cell {
position: relative;
}
.human-row-actions {
justify-content: flex-start;
}
.human-row-actions button {
width: auto;
min-width: 58px;
height: 32px;
gap: 5px;
padding: 0 9px;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
}
.human-row-actions .row-action-button-active {
border-color: var(--accent);
color: var(--accent-deep);
background: var(--glass-wash), var(--accent-soft);
}
.inline-action-menu {
position: absolute;
right: 10px;
z-index: 4;
display: grid;
gap: 6px;
min-width: 160px;
margin-top: 6px;
padding: 8px;
border: 1px solid var(--line-strong);
border-radius: 8px;
background: var(--frosted-surface), var(--surface-solid);
box-shadow: var(--jelly-inset), 0 16px 36px var(--glass-shadow);
}
.inline-action-menu button {
min-height: 32px;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 0 9px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
color: var(--ink-soft);
cursor: pointer;
font-weight: 700;
text-align: left;
}
.inline-action-menu button:hover,
.inline-action-menu button:focus-visible {
border-color: var(--accent);
outline: none;
color: var(--ink);
}
.form-header {
display: flex;
align-items: center;
@@ -2779,6 +2942,61 @@ button {
background: var(--danger-soft);
}
.provider-setup-guide {
grid-column: 1 / -1;
}
.provider-preset-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.provider-preset-option {
min-height: 74px;
display: grid;
align-content: start;
gap: 4px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--jelly-highlight), var(--glass-wash), var(--surface-solid);
color: var(--ink-soft);
cursor: pointer;
text-align: left;
box-shadow: inset 0 1px 0 var(--crystal-rim);
}
.provider-preset-option strong {
color: var(--ink);
font-size: 13px;
}
.provider-preset-option span {
font-size: 12px;
line-height: 1.35;
}
.provider-preset-option:hover,
.provider-preset-option:focus-visible {
border-color: var(--accent);
outline: none;
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft);
}
.field-help {
color: var(--ink-faint);
font-size: 11.5px;
font-weight: 600;
line-height: 1.4;
}
.form-helper-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.provider-form label {
display: grid;
gap: 6px;
@@ -3204,6 +3422,60 @@ button {
box-shadow: var(--panel-shadow);
}
.management-dialog-panel {
width: min(720px, 100%);
height: auto;
max-height: min(760px, calc(100dvh - 32px));
overflow: auto;
border: 1px solid var(--line);
border-left: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--jelly-inset), inset 0 0 0 1px var(--diamond-line), 0 18px 48px var(--glass-shadow), 0 0 34px var(--moonbeam);
}
.management-dialog-wide {
width: min(920px, 100%);
}
.dialog-description {
margin: 0;
color: var(--ink-soft);
font-size: 14px;
line-height: 1.55;
}
.management-dialog-panel .provider-form,
.management-dialog-panel .management-form {
border: 0;
padding: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.management-dialog-panel .management-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.management-dialog-panel .dialog-form .role-selector,
.management-dialog-panel .dialog-form .confirm-actions {
grid-column: 1 / -1;
}
.management-dialog-panel .plugin-detail-panel,
.management-dialog-panel .plugin-group {
border: 0;
padding: 0;
background: transparent;
box-shadow: none;
clip-path: none;
}
.management-dialog-panel .plugin-group::before,
.management-dialog-panel .plugin-group::after {
content: none;
}
.plugin-detail-actions {
align-items: stretch;
}
@@ -3327,6 +3599,104 @@ button {
font-size: 12.5px;
}
/* ---- maintenance triage ---- */
.maintenance-triage-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.triage-card {
min-height: 120px;
display: grid;
align-content: start;
gap: 6px;
padding: 13px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--corner-sparkle), var(--jelly-highlight), var(--glass-wash), var(--surface);
background-size: 52px 52px, auto, auto, auto;
background-position: right 8px top 6px, center, center, center;
background-repeat: no-repeat;
color: var(--ink-soft);
cursor: pointer;
text-align: left;
box-shadow: inset 0 1px 0 var(--crystal-rim), var(--panel-shadow);
}
.triage-card svg {
color: var(--accent-deep);
}
.triage-card span {
color: var(--ink-faint);
font-size: 12px;
font-weight: 700;
}
.triage-card strong {
color: var(--ink);
font-size: 18px;
}
.triage-card small {
color: var(--ink-soft);
line-height: 1.4;
}
.triage-card:hover,
.triage-card:focus-visible {
border-color: var(--accent);
outline: none;
box-shadow: inset 0 1px 0 var(--crystal-rim), 0 0 0 2px var(--accent-soft), var(--panel-shadow);
}
.maintenance-node-item {
grid-template-columns: minmax(170px, 1fr) auto minmax(160px, 0.8fr) minmax(220px, 1.2fr);
}
.maintenance-node-item .node-detail,
.maintenance-node-item .maintenance-actions {
grid-column: 1 / -1;
}
.node-detail {
display: grid;
gap: 6px;
padding: 8px 10px;
border: 1px dashed var(--line);
border-radius: 8px;
color: var(--ink-faint);
font-size: 12px;
}
.node-detail summary {
color: var(--ink-soft);
cursor: pointer;
font-weight: 700;
}
.node-detail[open] {
background: var(--glass-wash), var(--accent-soft);
}
.node-detail span {
display: inline-block;
margin-right: 10px;
}
.maintenance-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.maintenance-actions .theme-upload {
min-height: 30px;
}
/* ---- operation history ---- */
.operation-list {
@@ -3498,6 +3868,10 @@ button {
.confirm-actions button {
min-height: 38px;
padding: 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 8px;
border: 1px solid var(--line-strong);
background: var(--surface-solid);
@@ -3533,6 +3907,10 @@ button {
.management-form,
.user-management-item,
.workflow-hint-grid,
.provider-preset-grid,
.maintenance-triage-grid,
.maintenance-node-item,
.profile-settings-hero,
.profile-settings-grid,
.profile-settings-form-row,
@@ -3542,6 +3920,7 @@ button {
}
.user-actions,
.maintenance-actions,
.profile-settings-actions {
justify-content: flex-start;
}
@@ -3682,9 +4061,7 @@ button {
grid-template-columns: 1fr;
}
.ai-provider-metrics,
.server-metrics,
.ai-provider-workspace,
.server-workspace,
.form-grid {
grid-template-columns: 1fr;