Revert SCUM real data management change
This commit is contained in:
@@ -244,6 +244,18 @@ describe("PlatformApiClient AI providers", () => {
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/players")) return jsonResponse({ items: [{ id: "scum-player-1", gamePlayerId: "steam-1", displayName: "Prisoner One", online: true }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squads")) return jsonResponse({ items: [{ id: "squad-1", squadId: "squad-1", name: "Alpha" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/squad-members")) return jsonResponse({ items: [{ id: "member-1", squadId: "squad-1", gamePlayerId: "steam-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/vehicles")) return jsonResponse({ items: [{ id: "vehicle-1", vehicleId: "vehicle-1", label: "SUV" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/flags")) return jsonResponse({ items: [{ id: "flag-1", flagId: "flag-1", ownerSquadId: "squad-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/positions")) return jsonResponse({ items: [{ id: "position-1", subjectType: "player", subjectId: "steam-1", x: 1, y: 2, z: 3 }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "waiting", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations/op-1/approve") && init?.method === "POST") return jsonResponse({ id: "op-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "player.fame.set", status: "queued", approvalLevel: "operator", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && (!init?.method || init.method === "GET")) return jsonResponse({ items: [], count: 0 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflows") && init?.method === "POST") return jsonResponse({ id: "workflow-1", serverInstanceId: server.id, pluginId: plugin.id, templateKey: "scum.world-refresh", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/workflow-steps?workflowId=workflow-1")) return jsonResponse({ items: [{ id: "step-1", workflowId: "workflow-1", serverInstanceId: server.id, stepKey: "read-positions", status: "queued", createdAt: "2026-07-03T00:00:00Z", updatedAt: "2026-07-03T00:00:00Z" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/file-operations/dispatch") && init?.method === "POST") {
|
||||
expect(JSON.parse(String(init.body))).toEqual({
|
||||
serverInstanceId: server.id,
|
||||
@@ -542,6 +554,18 @@ describe("PlatformApiClient AI providers", () => {
|
||||
await expect(client.deleteServerInstance(server.id, { password: "secret-password", force: true, confirmation: "FORCE DELETE" })).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.listSCUMPlayers(server.id)).resolves.toMatchObject({ count: 1, items: [{ gamePlayerId: "steam-1" }] });
|
||||
await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMSquadMembers(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMVehicles(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMFlags(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMPositions(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMOperations(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
await expect(client.createSCUMOperation(server.id, { templateKey: "player.fame.set", playerId: "steam-1", payload: { fame: 100 }, reason: "typed correction", idempotencyKey: "idem-scum-op" })).resolves.toMatchObject({ id: "op-1", status: "waiting" });
|
||||
await expect(client.approveSCUMOperation(server.id, "op-1")).resolves.toMatchObject({ id: "op-1", status: "queued" });
|
||||
await expect(client.listSCUMWorkflows(server.id)).resolves.toMatchObject({ count: 0 });
|
||||
await expect(client.createSCUMWorkflow(server.id, { templateKey: "scum.world-refresh", idempotencyKey: "idem-scum-workflow" })).resolves.toMatchObject({ id: "workflow-1", status: "queued" });
|
||||
await expect(client.listSCUMWorkflowSteps(server.id, "workflow-1")).resolves.toMatchObject({ count: 1, items: [{ stepKey: "read-positions" }] });
|
||||
await expect(client.dispatchFileOperation({ serverInstanceId: server.id, operation: "read", key: "logs/latest.log", idempotencyKey: "idem-file" })).resolves.toMatchObject({
|
||||
status: "queued",
|
||||
job: { capability: "files.read", targetKey: "logs/latest.log" }
|
||||
@@ -600,7 +624,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(36);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(48);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
@@ -629,6 +653,24 @@ describe("PlatformApiClient AI providers", () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("surfaces SCUM typed operation failures from the platform", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/operations") && init?.method === "POST") {
|
||||
return new Response(JSON.stringify({ code: "validation", message: "SCUM operation template is not declared" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new PlatformApiClient();
|
||||
|
||||
await expect(client.createSCUMOperation(server.id, { templateKey: "raw.sql", reason: "unsafe", idempotencyKey: "bad-scum-op" })).rejects.toThrow("SCUM operation template is not declared");
|
||||
});
|
||||
|
||||
it("keeps raw key and base URL fields out of provider responses", () => {
|
||||
expect("apiKey" in provider).toBe(false);
|
||||
expect("rawApiKey" in provider).toBe(false);
|
||||
|
||||
@@ -101,6 +101,14 @@ import type {
|
||||
RemoteAdapterDeclarationListResponse,
|
||||
RemoteAdapterRequest,
|
||||
RemoteAdapterResponse,
|
||||
SCUMListResponse,
|
||||
SCUMOperationListResponse,
|
||||
SCUMOperationRequest,
|
||||
SCUMOperationResponse,
|
||||
SCUMWorkflowCreateRequest,
|
||||
SCUMWorkflowListResponse,
|
||||
SCUMWorkflowResponse,
|
||||
SCUMWorkflowStepListResponse,
|
||||
ServerRuntimeActionsResponse,
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
@@ -575,6 +583,57 @@ export class PlatformApiClient {
|
||||
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listSCUMPlayers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
|
||||
}
|
||||
|
||||
async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
|
||||
}
|
||||
|
||||
async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squad-members`);
|
||||
}
|
||||
|
||||
async listSCUMVehicles(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/vehicles`);
|
||||
}
|
||||
|
||||
async listSCUMFlags(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/flags`);
|
||||
}
|
||||
|
||||
async listSCUMPositions(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
|
||||
}
|
||||
|
||||
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
|
||||
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
|
||||
}
|
||||
|
||||
async createSCUMOperation(serverInstanceId: string, request: SCUMOperationRequest): Promise<SCUMOperationResponse> {
|
||||
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async approveSCUMOperation(serverInstanceId: string, operationId: string): Promise<SCUMOperationResponse> {
|
||||
return this.request<SCUMOperationResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations/${encodeURIComponent(operationId)}/approve`, { method: "POST", body: {} });
|
||||
}
|
||||
|
||||
async listSCUMWorkflows(serverInstanceId: string): Promise<SCUMWorkflowListResponse> {
|
||||
return this.request<SCUMWorkflowListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`);
|
||||
}
|
||||
|
||||
async createSCUMWorkflow(serverInstanceId: string, request: SCUMWorkflowCreateRequest): Promise<SCUMWorkflowResponse> {
|
||||
return this.request<SCUMWorkflowResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflows`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async listSCUMWorkflowSteps(serverInstanceId: string, workflowId?: string): Promise<SCUMWorkflowStepListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (workflowId) params.set("workflowId", workflowId);
|
||||
const query = params.toString();
|
||||
return this.request<SCUMWorkflowStepListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/workflow-steps${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async dispatchFileOperation(request: FileOperationDispatchRequest): Promise<FileOperationDispatchResponse> {
|
||||
return this.request<FileOperationDispatchResponse>("/file-operations/dispatch", {
|
||||
method: "POST",
|
||||
|
||||
@@ -6,7 +6,7 @@ API clients and DTO types live here, not inside page components.
|
||||
|
||||
- `users`: user and role APIs.
|
||||
- `serverPlugins`: plugin marketplace and installed plugin APIs.
|
||||
- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM local resource read APIs.
|
||||
- `serverInstances`: create server, lifecycle, deployment/member/detail APIs, and SCUM typed projection/workflow APIs.
|
||||
- `aiProviders`: provider CRUD, test, and model APIs.
|
||||
- `jobs`: job status and operation APIs.
|
||||
- `runEndpoints`: run endpoint status, lifecycle capabilities, and capacity APIs.
|
||||
@@ -26,7 +26,7 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
|
||||
- `getServerRuntimeBinding` reads `/server-instances/{id}/runtime-binding`; `updateServerRuntimeBinding` patches the selected profile and logical refs for internal/advanced logical transports. Server detail must not expose a manual runtime-binding tab or require these fields before normal start/stop when plugin-declared deployment/lifecycle data is sufficient. Responses contain only profile metadata, logical key names, configured/secret-backed flags, missing keys, and safe reasons. They never contain stored refs or secret values.
|
||||
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
|
||||
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
|
||||
- SCUM local resource reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`. Removed legacy SCUM execution endpoints have no frontend client wrappers; future writes must use the reviewed named-field/gift contracts and must never expose SQL text, RCON text, DSNs, host paths, or protected payloads.
|
||||
- SCUM projection reads use `listSCUMPlayers`, `listSCUMSquads`, `listSCUMSquadMembers`, `listSCUMVehicles`, `listSCUMFlags`, and `listSCUMPositions`; SCUM writes use `createSCUMOperation`, `approveSCUMOperation`, `createSCUMWorkflow`, and workflow/step list APIs. These APIs expose only projection rows, typed template keys, status, and safe summaries, never SQL text, RCON text, DSNs, host paths, or protected payloads.
|
||||
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it is not wired into SCUM server-detail/plugin pages as a raw file workbench.
|
||||
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
|
||||
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
|
||||
|
||||
@@ -1373,6 +1373,17 @@ export interface RemoteAdapterResponse {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type SCUMJsonRecord = Record<string, unknown>;
|
||||
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
|
||||
export interface SCUMWorkflowCreateRequest { templateKey: string; idempotencyKey: string; input?: SCUMJsonRecord; }
|
||||
export interface SCUMOperationRequest { templateKey: string; playerId?: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; reason: string; idempotencyKey: string; }
|
||||
export interface SCUMWorkflowResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; requestedBy?: string; idempotencyKey?: string; status: string; currentStepKey?: string; input?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
export interface SCUMWorkflowStepResponse { id: string; workflowId: string; serverInstanceId: string; stepKey: string; dependsOn?: string[]; status: string; operationKey?: string; queryTemplateKey?: string; capability?: string; targetKey?: string; jobId?: string; attempt?: number; maxAttempts?: number; mutatesState?: boolean; confirmation?: SCUMJsonRecord; safeSummary?: SCUMJsonRecord; blockerReason?: string; auditReferences?: string[]; createdAt: string; updatedAt: string; completedAt?: string; }
|
||||
export interface SCUMOperationResponse { id: string; serverInstanceId: string; pluginId: string; templateKey: string; playerId?: string; requesterId?: string; approverId?: string; approvalLevel: string; payload?: SCUMJsonRecord; guard?: SCUMJsonRecord; confirmation?: SCUMJsonRecord; status: string; reason?: string; runJobId?: string; safeSummary?: SCUMJsonRecord; auditReferences?: string[]; createdAt: string; approvedAt?: string; completedAt?: string; updatedAt: string; }
|
||||
export type SCUMWorkflowListResponse = SCUMListResponse<SCUMWorkflowResponse>;
|
||||
export type SCUMWorkflowStepListResponse = SCUMListResponse<SCUMWorkflowStepResponse>;
|
||||
export type SCUMOperationListResponse = SCUMListResponse<SCUMOperationResponse>;
|
||||
|
||||
export interface ServerConfigResponse {
|
||||
serverInstanceId: string;
|
||||
configVersion: number;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Shared Visual Contract
|
||||
|
||||
All first-party pages inherit the platform_web game-operations style with black-mecha default materials and a selectable magical-girl theme. Page implementations must use shared theme tokens and surface classes so 首页、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护, server details, drawers, dialogs, safe diffs, plugin-declared pages, and job/status surfaces all feel like one console.
|
||||
All first-party pages inherit the platform_web game-operations style with black-mecha default materials and a selectable magical-girl theme. Page implementations must use shared theme tokens and surface classes so 首页、服务器管理、插件市场、用户管理、AI 提供商管理、系统维护, server details, drawers, dialogs, safe diffs, plugin-declared pages, and workflow/status surfaces all feel like one console.
|
||||
|
||||
- Major surfaces remain transparent jelly/glass panels with visible background desktop, icy rim light, diamond borders, shine sweeps, and candy-color accents.
|
||||
- Built-in magical desktops and user-uploaded backgrounds render behind readable contrast surfaces.
|
||||
@@ -23,7 +23,7 @@ Default landing page for server owners and server administrators. Shows searchab
|
||||
|
||||
## 服务器详情
|
||||
|
||||
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM detail uses exactly 用户管理, 队伍管理, 实时地图, 礼包管理, and AI 助手. SCUM deployment and administrator settings move to compact header/list actions instead of a permanent management tab, and AI suggestions produce reviewable config diffs or named-field drafts without raw AI keys reaching the frontend. Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
|
||||
Daily operations hub for one server. Status header shows online state, player count, TPS, latency, CPU/memory/disk progress, metric freshness, and confirmed start/stop lifecycle actions. Plugin-declared pages render as first-class server tabs before platform sections, so each game owns its safe menu surface; SCUM pages use projection-backed users, squads, map, gifts, and workflows. Built-in sections are 管理 (deployment status, metadata, administrators) and AI 助手 (LLM suggestions produce reviewable config diffs or typed workflow drafts; no raw AI keys reach the frontend). Raw logs, management terminal/RCON input, arbitrary config workbench, generic operation history, runtime-binding, and generic plugin-control tabs must not be exposed in server detail.
|
||||
|
||||
## 插件市场
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Plugin page runs with safe platform context.
|
||||
- `plugin-lifecycle.request`: declared plugin lifecycle request through Platform.
|
||||
- `ai.invoke`: platform-mediated AI invocation.
|
||||
|
||||
The host intersects manifest-level and page-level permissions/actions before exposing context. SCUM pages receive only declared local-resource actions for 用户管理, 队伍管理, 实时地图, 礼包管理, and AI 助手; the host does not synthesize undeclared SCUM semantics.
|
||||
The host intersects manifest-level and page-level permissions/actions before exposing context. The SCUM operations page additionally intersects its command, snapshot, and query-template keys with `gameClientBridge.pages.operations`; it does not synthesize undeclared SCUM semantics.
|
||||
|
||||
## Forbidden
|
||||
|
||||
|
||||
@@ -1 +1,14 @@
|
||||
export interface PluginPageWorkspaceActions {}
|
||||
export interface PluginPageWorkspaceActions {
|
||||
listSCUMPlayers?: () => Promise<unknown>;
|
||||
listSCUMSquads?: () => Promise<unknown>;
|
||||
listSCUMSquadMembers?: () => Promise<unknown>;
|
||||
listSCUMVehicles?: () => Promise<unknown>;
|
||||
listSCUMFlags?: () => Promise<unknown>;
|
||||
listSCUMPositions?: () => Promise<unknown>;
|
||||
listSCUMOperations?: () => Promise<unknown>;
|
||||
createSCUMOperation?: (request: unknown) => Promise<unknown>;
|
||||
approveSCUMOperation?: (operationId: string) => Promise<unknown>;
|
||||
listSCUMWorkflows?: () => Promise<unknown>;
|
||||
createSCUMWorkflow?: (request: unknown) => Promise<unknown>;
|
||||
listSCUMWorkflowSteps?: (workflowId?: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ describe("first-party console pages", () => {
|
||||
expect(html).not.toContain("操作历史");
|
||||
});
|
||||
|
||||
it("routes SCUM management through plugin pages and the AI assistant", () => {
|
||||
it("routes SCUM operations through typed plugin workflow surfaces", () => {
|
||||
expect(serverDetailPageSource).toContain("PluginPageHostPage");
|
||||
expect(serverDetailPageSource).toContain("plugin:${page.key}");
|
||||
expect(serverDetailPageSource).toContain("section === \"llm\"");
|
||||
|
||||
@@ -96,18 +96,18 @@ describe("PluginPageHostPage", () => {
|
||||
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
|
||||
});
|
||||
|
||||
it("removes SCUM legacy workspace callbacks from the parent host", () => {
|
||||
it("keeps typed SCUM workspace callbacks stable across parent operational refreshes", () => {
|
||||
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
|
||||
expect(hostSource).toContain("hostContextRef.current = hostContext");
|
||||
expect(hostSource).not.toContain("listSCUMPlayers");
|
||||
expect(hostSource).not.toContain("listSCUMPositions");
|
||||
expect(hostSource).not.toContain("createSCUMWorkflow");
|
||||
expect(hostSource).not.toContain("createSCUMOperation");
|
||||
expect(hostSource).not.toContain("listSCUMWorkflowSteps");
|
||||
expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)");
|
||||
expect(hostSource).toContain("createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never)");
|
||||
expect(hostSource).toContain("createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never)");
|
||||
expect(hostSource).toContain("listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)");
|
||||
expect(hostSource).not.toContain("refreshWorkspace");
|
||||
expect(hostSource).not.toContain("requestFile");
|
||||
expect(hostSource).not.toContain("writeFile");
|
||||
expect(hostSource).not.toContain("getDeclaredFileReadSnapshot");
|
||||
expect(hostSource).toContain("const workspaceActions = undefined");
|
||||
expect(hostSource).toContain("}, [pluginId, serverId]);");
|
||||
expect(hostSource).not.toContain("}, [hostContext, readyPlugin, serverId]);");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
const [state, setState] = useState<PluginPageState>(() => initialPlugin ? { status: "ready", plugin: initialPlugin } : { status: "loading" });
|
||||
const [bundle, setBundle] = useState<ComponentType<{ context: ReturnType<typeof createPluginBridgeHostContext>; workspaceActions?: PluginPageWorkspaceActions; availability: PluginPageAvailability }> | null>(null);
|
||||
const [bundleError, setBundleError] = useState("");
|
||||
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证本地数据通道。" });
|
||||
const [availability, setAvailability] = useState<PluginPageAvailability>({ available: false, reason: "正在验证 Companion 可用性。" });
|
||||
const readyPluginRef = useRef<GamePluginResponse | undefined>(undefined);
|
||||
const hostContextRef = useRef<ReturnType<typeof createPluginBridgeHostContext> | undefined>(undefined);
|
||||
|
||||
@@ -66,7 +66,23 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
}) : undefined, [manifestContract, routeKey, serverId]);
|
||||
readyPluginRef.current = readyPlugin;
|
||||
hostContextRef.current = hostContext;
|
||||
const workspaceActions = undefined as PluginPageWorkspaceActions | undefined;
|
||||
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
|
||||
if (!pluginId) return undefined;
|
||||
return {
|
||||
listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
|
||||
listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
|
||||
listSCUMSquadMembers: () => platformApiClient.listSCUMSquadMembers(serverId),
|
||||
listSCUMVehicles: () => platformApiClient.listSCUMVehicles(serverId),
|
||||
listSCUMFlags: () => platformApiClient.listSCUMFlags(serverId),
|
||||
listSCUMPositions: () => platformApiClient.listSCUMPositions(serverId),
|
||||
listSCUMOperations: () => platformApiClient.listSCUMOperations(serverId),
|
||||
createSCUMOperation: (request) => platformApiClient.createSCUMOperation(serverId, request as never),
|
||||
approveSCUMOperation: (operationId) => platformApiClient.approveSCUMOperation(serverId, operationId),
|
||||
listSCUMWorkflows: () => platformApiClient.listSCUMWorkflows(serverId),
|
||||
createSCUMWorkflow: (request) => platformApiClient.createSCUMWorkflow(serverId, request as never),
|
||||
listSCUMWorkflowSteps: (workflowId) => platformApiClient.listSCUMWorkflowSteps(serverId, workflowId)
|
||||
};
|
||||
}, [pluginId, serverId]);
|
||||
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
|
||||
const loadableBundlePage = useMemo(() => declaredBundlePage, [bundleLoadKey]);
|
||||
useEffect(() => {
|
||||
@@ -78,7 +94,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
if (!serverId) { setAvailability({ available: false, reason: "插件页面没有绑定服务器。" }); return () => { active = false; }; }
|
||||
void platformApiClient.getGameClientBridgeStatus(serverId).then((status) => {
|
||||
if (active) setAvailability({ available: status.available, reason: status.reason, features: status.features });
|
||||
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证本地数据通道。" }); });
|
||||
}).catch((error) => { if (active) setAvailability({ available: false, reason: error instanceof Error ? error.message : "无法验证 Companion 可用性。" }); });
|
||||
return () => { active = false; };
|
||||
}, [bundleLoadKey, loadableBundlePage, serverId]);
|
||||
|
||||
@@ -127,7 +143,7 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>数据通道</strong> {availability.available ? "可用" : "不可用"}</span></div>
|
||||
<div className="action-list" aria-label="plugin page declarations"><span><strong>Bundle</strong> {page.bundleKey ? `${page.bundleKey}@${page.bundleVersion}` : "未声明"}</span><span><strong>完整性</strong> {page.bundleIntegritySha256 ? `${page.bundleIntegritySha256.slice(0, 18)}…` : "未声明"}</span><span><strong>Companion</strong> {availability.available ? "可用" : "不可用"}</span></div>
|
||||
</section>
|
||||
{bundleError && <ErrorState title="插件页面不可用" reason={bundleError} />}
|
||||
{!bundle && !bundleError && <LoadingState label="正在校验并加载插件页面 bundle…" />}
|
||||
|
||||
@@ -135,11 +135,6 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).not.toContain("ScumFileManagementSection");
|
||||
});
|
||||
|
||||
it("keeps SCUM detail tabs to plugin management pages plus AI assistant", () => {
|
||||
expect(serverDetailPageSource).toContain('plugin?.id === "game.scum"');
|
||||
expect(serverDetailPageSource).toContain('return [...pluginPages, { id: "llm", label: "AI 助手" }]');
|
||||
});
|
||||
|
||||
it("keeps plugin lifecycle output out of raw bridge-visible detail surfaces", () => {
|
||||
expect(serverDetailPageSource).not.toContain("platformApiClient.openArtifactDownload(artifact.id)");
|
||||
expect(serverDetailPageSource).not.toContain("downloadArtifactReference(reference");
|
||||
|
||||
@@ -310,7 +310,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
|
||||
function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> {
|
||||
const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title }));
|
||||
if (plugin?.id === "game.scum") return [...pluginPages, { id: "llm", label: "AI 助手" }];
|
||||
return [...pluginPages, ...serverDetailSections];
|
||||
}
|
||||
|
||||
|
||||
@@ -44,11 +44,11 @@ describe("console shell routes", () => {
|
||||
});
|
||||
|
||||
it("round-trips hosted plugin page hashes with server context", () => {
|
||||
const hash = hashForPage("pluginPage", { pluginId: "game.scum", routeKey: "players", serverId: "server/scum-1" });
|
||||
expect(hash).toBe("#/plugin-pages/game.scum/players?serverInstanceId=server%2Fscum-1");
|
||||
const hash = hashForPage("pluginPage", { pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
|
||||
expect(hash).toBe("#/plugin-pages/game.scum/operations?serverInstanceId=server%2Fscum-1");
|
||||
const resolved = resolveRouteHash(hash, platformAdmin);
|
||||
expect(resolved.route).toMatchObject({ id: "pluginPage", showInNav: false, requiredCapability: "servers.read" });
|
||||
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "players", serverId: "server/scum-1" });
|
||||
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
|
||||
});
|
||||
|
||||
it("keeps route metadata available for shell navigation", () => {
|
||||
|
||||
@@ -756,7 +756,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))}
|
||||
.console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px}
|
||||
.console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850}
|
||||
.map-local-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-local-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)}
|
||||
.map-projection-board{position:relative;min-height:320px;border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d)}.map-projection-dot{position:absolute;width:9px;height:9px;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%)}
|
||||
.console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px}
|
||||
.console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left}
|
||||
.console-row-button,.operations-job-row{width:100%;cursor:pointer}
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("safeDiagnosticText", () => {
|
||||
});
|
||||
|
||||
it("preserves safe operational wording instead of matching labels alone", () => {
|
||||
const safe = "密钥状态已配置;Base URL 由平台托管;token 不会下发;RCON 数据不会下发。";
|
||||
const safe = "密钥状态已配置;Base URL 由平台托管;token 不会下发;RCON 数据不会投影。";
|
||||
expect(safeDiagnosticText(safe)).toBe(safe);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user