Rebuild SCUM plugin data ownership
This commit is contained in:
@@ -245,11 +245,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
});
|
||||
}
|
||||
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/users")) return jsonResponse({ items: [{ id: "scum-user-1", serverInstanceId: server.id, displayName: "Prisoner One", steamId: "steam-1" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/datasets/squads")) return jsonResponse({ items: [{ id: "squad-1", serverInstanceId: server.id, fields: { squadId: "squad-1", name: "Alpha" } }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/activity")) return jsonResponse({ items: [{ id: "activity-1", serverInstanceId: server.id, kind: "login" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/gifts")) return jsonResponse({ items: [{ id: "gift-1", serverInstanceId: server.id, name: "Starter" }], count: 1 });
|
||||
if (url.endsWith("/api/v1/server-instances/server-1/scum/map-points")) return jsonResponse({ items: [{ id: "point-1", serverInstanceId: server.id, kind: "base" }], 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 });
|
||||
@@ -559,11 +555,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
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.listSCUMUsers(server.id)).resolves.toMatchObject({ count: 1, items: [{ steamId: "steam-1" }] });
|
||||
await expect(client.listSCUMSquads(server.id)).resolves.toMatchObject({ count: 1 });
|
||||
await expect(client.listSCUMActivity(server.id)).resolves.toMatchObject({ count: 1, items: [{ kind: "login" }] });
|
||||
await expect(client.listSCUMGifts(server.id)).resolves.toMatchObject({ count: 1, items: [{ name: "Starter" }] });
|
||||
await expect(client.listSCUMMapPoints(server.id)).resolves.toMatchObject({ count: 1, items: [{ kind: "base" }] });
|
||||
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 });
|
||||
@@ -632,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(52);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(48);
|
||||
});
|
||||
|
||||
it("calls plugin marketplace endpoints with filter and state contracts", async () => {
|
||||
|
||||
+13
-59
@@ -47,14 +47,6 @@ import type {
|
||||
GameClientBridgeSnapshotListResponse,
|
||||
GameClientBridgeSnapshotQuery,
|
||||
GameClientBridgeStatusResponse,
|
||||
GameGiftCatalogListResponse,
|
||||
GameGiftCatalogRequest,
|
||||
GameGiftCatalogResponse,
|
||||
GameGiftGrantListResponse,
|
||||
GameGiftGrantRequest,
|
||||
GameGiftGrantResponse,
|
||||
GameGiftRevisionListResponse,
|
||||
GameGiftRevisionResponse,
|
||||
GamePluginListResponse,
|
||||
HealthResponse,
|
||||
JobCreateRequest,
|
||||
@@ -110,14 +102,9 @@ import type {
|
||||
RemoteAdapterRequest,
|
||||
RemoteAdapterResponse,
|
||||
SCUMListResponse,
|
||||
SCUMActivityListResponse,
|
||||
SCUMGiftsListResponse,
|
||||
SCUMMapPointsListResponse,
|
||||
SCUMOperationListResponse,
|
||||
SCUMOperationRequest,
|
||||
SCUMOperationResponse,
|
||||
SCUMSquadsListResponse,
|
||||
SCUMUsersListResponse,
|
||||
SCUMWorkflowCreateRequest,
|
||||
SCUMWorkflowListResponse,
|
||||
SCUMWorkflowResponse,
|
||||
@@ -592,6 +579,17 @@ export class PlatformApiClient {
|
||||
return this.request<RemoteAdapterDeclarationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`);
|
||||
}
|
||||
|
||||
async listPluginData(serverInstanceId: string, collection: string, key?: string): Promise<{ items: Array<{ key: string; value: Record<string, unknown> }>; count: number }> {
|
||||
const params = new URLSearchParams();
|
||||
if (key) params.set("key", key);
|
||||
const query = params.toString();
|
||||
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
async putPluginData(serverInstanceId: string, collection: string, key: string, value: Record<string, unknown>): Promise<{ key: string; value: Record<string, unknown> }> {
|
||||
return this.request(`/server-instances/${encodeURIComponent(serverInstanceId)}/plugin-data/${encodeURIComponent(collection)}`, { method: "PUT", body: { key, value } });
|
||||
}
|
||||
|
||||
async requestRemoteAdapter(serverInstanceId: string, request: RemoteAdapterRequest): Promise<RemoteAdapterResponse> {
|
||||
return this.request<RemoteAdapterResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/remote-adapters`, { method: "POST", body: request });
|
||||
}
|
||||
@@ -600,24 +598,8 @@ export class PlatformApiClient {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/players`);
|
||||
}
|
||||
|
||||
async listSCUMUsers(serverInstanceId: string): Promise<SCUMUsersListResponse> {
|
||||
return this.request<SCUMUsersListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/users`);
|
||||
}
|
||||
|
||||
async listSCUMSquads(serverInstanceId: string): Promise<SCUMSquadsListResponse> {
|
||||
return this.request<SCUMSquadsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/datasets/squads`);
|
||||
}
|
||||
|
||||
async listSCUMActivity(serverInstanceId: string): Promise<SCUMActivityListResponse> {
|
||||
return this.request<SCUMActivityListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/activity`);
|
||||
}
|
||||
|
||||
async listSCUMGifts(serverInstanceId: string): Promise<SCUMGiftsListResponse> {
|
||||
return this.request<SCUMGiftsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/gifts`);
|
||||
}
|
||||
|
||||
async listSCUMMapPoints(serverInstanceId: string): Promise<SCUMMapPointsListResponse> {
|
||||
return this.request<SCUMMapPointsListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/map-points`);
|
||||
async listSCUMSquads(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/squads`);
|
||||
}
|
||||
|
||||
async listSCUMSquadMembers(serverInstanceId: string): Promise<SCUMListResponse> {
|
||||
@@ -636,34 +618,6 @@ export class PlatformApiClient {
|
||||
return this.request<SCUMListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/positions`);
|
||||
}
|
||||
|
||||
async listGameGiftCatalogs(serverInstanceId: string): Promise<GameGiftCatalogListResponse> {
|
||||
return this.request<GameGiftCatalogListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`);
|
||||
}
|
||||
|
||||
async saveGameGiftCatalog(serverInstanceId: string, request: GameGiftCatalogRequest): Promise<GameGiftCatalogResponse> {
|
||||
return this.request<GameGiftCatalogResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async publishGameGiftCatalog(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionResponse> {
|
||||
return this.request<GameGiftRevisionResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/publish`, { method: "POST", body: {} });
|
||||
}
|
||||
|
||||
async listGameGiftRevisions(serverInstanceId: string, catalogId: string): Promise<GameGiftRevisionListResponse> {
|
||||
return this.request<GameGiftRevisionListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gifts/${encodeURIComponent(catalogId)}/revisions`);
|
||||
}
|
||||
|
||||
async listGameGiftGrants(serverInstanceId: string): Promise<GameGiftGrantListResponse> {
|
||||
return this.request<GameGiftGrantListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`);
|
||||
}
|
||||
|
||||
async requestGameGiftGrant(serverInstanceId: string, request: GameGiftGrantRequest): Promise<GameGiftGrantResponse> {
|
||||
return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants`, { method: "POST", body: request });
|
||||
}
|
||||
|
||||
async approveGameGiftGrant(serverInstanceId: string, grantId: string): Promise<GameGiftGrantResponse> {
|
||||
return this.request<GameGiftGrantResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/game-gift-grants/${encodeURIComponent(grantId)}/approve`, { method: "POST", body: {} });
|
||||
}
|
||||
|
||||
async listSCUMOperations(serverInstanceId: string): Promise<SCUMOperationListResponse> {
|
||||
return this.request<SCUMOperationListResponse>(`/server-instances/${encodeURIComponent(serverInstanceId)}/scum/operations`);
|
||||
}
|
||||
|
||||
@@ -388,12 +388,9 @@ export interface GamePluginResponse {
|
||||
validationViolations?: string[];
|
||||
runtimeProfiles?: GamePluginRuntimeProfilesResponse;
|
||||
gameClientBridge?: GameClientBridgeManifestResponse;
|
||||
mapTrajectories?: GameMapGeometryResponse;
|
||||
status: GamePluginStatus;
|
||||
}
|
||||
|
||||
export interface GameMapGeometryResponse { mapId: string; mapVersion: string; worldMinX: number; worldMinY: number; worldMaxX: number; worldMaxY: number; imageWidth: number; imageHeight: number; precision: number; sampleDistance: number; sampleIntervalSeconds: number; retentionSeconds: number; }
|
||||
|
||||
export type PluginCreateFieldType = "text" | "number" | "boolean" | "select" | "port";
|
||||
|
||||
export interface PluginCreateFieldResponse {
|
||||
@@ -1378,26 +1375,6 @@ export interface RemoteAdapterResponse {
|
||||
|
||||
export type SCUMJsonRecord = Record<string, unknown>;
|
||||
export interface SCUMListResponse<T = SCUMJsonRecord> { items: T[]; count: number; }
|
||||
export interface SCUMPersistedRecord { id: string; serverInstanceId: string; observedAt?: string; syncedAt?: string; payload?: SCUMJsonRecord; }
|
||||
export interface SCUMUserRecord extends SCUMPersistedRecord { userProfileId?: string; steamId?: string; displayName?: string; famePoints?: number; normalBalance?: number; goldBalance?: number; lastLoginTime?: string; lastLogoutTime?: string; isAlive?: boolean; }
|
||||
export interface SCUMSquadRecord extends SCUMPersistedRecord { squadId?: string; name?: string; message?: string; score?: number; memberLimit?: number; memberCount?: number; }
|
||||
export interface SCUMActivityEventRecord extends SCUMPersistedRecord { eventId?: string; kind?: string; subjectId?: string; subjectName?: string; summary?: string; occurredAt?: string; locationX?: number; locationY?: number; locationZ?: number; }
|
||||
export interface SCUMGiftRecord extends SCUMPersistedRecord { giftId?: string; name?: string; status?: string; recipientUserProfileId?: string; recipientName?: string; availableAt?: string; expiresAt?: string; }
|
||||
export interface SCUMMapPointRecord extends SCUMPersistedRecord { pointId?: string; kind?: string; label?: string; locationX?: number; locationY?: number; locationZ?: number; mapId?: string; }
|
||||
export type SCUMUsersListResponse = SCUMListResponse<SCUMUserRecord>;
|
||||
export type SCUMSquadsListResponse = SCUMListResponse<SCUMSquadRecord>;
|
||||
export type SCUMActivityListResponse = SCUMListResponse<SCUMActivityEventRecord>;
|
||||
export type SCUMGiftsListResponse = SCUMListResponse<SCUMGiftRecord>;
|
||||
export type SCUMMapPointsListResponse = SCUMListResponse<SCUMMapPointRecord>;
|
||||
export interface GameGiftItemResponse { catalogItemKey: string; label: string; quantity: number; }
|
||||
export interface GameGiftCatalogResponse { id: string; name: string; gameVersion: string; draftItems: GameGiftItemResponse[]; latestRevisionId?: string; updatedAt: string; }
|
||||
export interface GameGiftCatalogListResponse { items: GameGiftCatalogResponse[]; }
|
||||
export interface GameGiftRevisionResponse { id: string; catalogId: string; revision: number; gameVersion: string; items: GameGiftItemResponse[]; publishedBy: string; publishedAt: string; }
|
||||
export interface GameGiftRevisionListResponse { items: GameGiftRevisionResponse[]; }
|
||||
export interface GameGiftGrantResponse { id: string; revisionId: string; revisionNumber: number; gameVersion: string; items: GameGiftItemResponse[]; gamePlayerRecordId: string; playerDisplayName: string; notice: string; requesterId: string; approverId?: string; status: string; deliverySummary?: string; notificationSummary?: string; createdAt: string; approvedAt?: string; completedAt?: string; }
|
||||
export interface GameGiftGrantListResponse { items: GameGiftGrantResponse[]; }
|
||||
export interface GameGiftCatalogRequest { id?: string; name: string; gameVersion: string; items: Array<{ catalogItemKey: string; quantity: number }>; }
|
||||
export interface GameGiftGrantRequest { revisionId: string; gamePlayerRecordId: string; notice: string; idempotencyKey: string; }
|
||||
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; }
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SCUMPersistedDataView, scumDatasetForPluginRoute } from "./SCUMPersistedDataView";
|
||||
|
||||
describe("SCUMPersistedDataView", () => {
|
||||
it("maps the five declared SCUM plugin routes to persisted datasets", () => {
|
||||
expect(scumDatasetForPluginRoute("players")).toBe("users");
|
||||
expect(scumDatasetForPluginRoute("squads")).toBe("squads");
|
||||
expect(scumDatasetForPluginRoute("workflows")).toBe("activity");
|
||||
expect(scumDatasetForPluginRoute("gifts")).toBe("gifts");
|
||||
expect(scumDatasetForPluginRoute("live-map")).toBe("map");
|
||||
});
|
||||
|
||||
it("renders a themed loading state without synthetic records", () => {
|
||||
const html = renderToStaticMarkup(<SCUMPersistedDataView serverInstanceId="server-1" dataset="users" />);
|
||||
expect(html).toContain("正在读取用户同步数据");
|
||||
expect(html).not.toContain("Prisoner One");
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Activity, Gift, MapPinned, RefreshCw, ShieldAlert, UsersRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { SCUMActivityEventRecord, SCUMGiftRecord, SCUMMapPointRecord, SCUMSquadRecord, SCUMUserRecord } from "../api/types";
|
||||
import { EmptyState, ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
export type SCUMPersistedDataset = "users" | "squads" | "activity" | "gifts" | "map";
|
||||
|
||||
interface SCUMPersistedDataViewProps {
|
||||
serverInstanceId: string;
|
||||
dataset: SCUMPersistedDataset;
|
||||
}
|
||||
|
||||
type DatasetRecord = (SCUMUserRecord | SCUMSquadRecord | SCUMActivityEventRecord | SCUMGiftRecord | SCUMMapPointRecord) & { fields?: Record<string, unknown>; updatedAt?: string; freshness?: { observedAt?: string } };
|
||||
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: DatasetRecord[]; count: number };
|
||||
|
||||
const datasetCopy: Record<SCUMPersistedDataset, { title: string; summary: string; empty: string; icon: typeof UsersRound }> = {
|
||||
users: { title: "用户", summary: "来自 SCUM 用户、角色和账户记录的已同步数据。", empty: "还没有完成的用户同步记录。", icon: UsersRound },
|
||||
squads: { title: "队伍", summary: "来自 SCUM 队伍和成员记录的已同步数据。", empty: "还没有完成的队伍同步记录。", icon: UsersRound },
|
||||
activity: { title: "活动", summary: "来自已解析日志和活动记录的持久化事件。", empty: "还没有完成的活动同步记录。", icon: Activity },
|
||||
gifts: { title: "礼包", summary: "来自 SCUM 礼包目录和发放记录的已同步数据。", empty: "还没有完成的礼包同步记录。", icon: Gift },
|
||||
map: { title: "地图", summary: "来自基地、旗帜、载具和实体坐标的已同步地图点。", empty: "还没有完成的地图同步记录。", icon: MapPinned }
|
||||
};
|
||||
|
||||
export function scumDatasetForPluginRoute(routeKey: string): SCUMPersistedDataset | null {
|
||||
if (routeKey === "players") return "users";
|
||||
if (routeKey === "squads") return "squads";
|
||||
if (routeKey === "workflows") return "activity";
|
||||
if (routeKey === "gifts") return "gifts";
|
||||
if (routeKey === "live-map") return "map";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SCUMPersistedDataView({ serverInstanceId, dataset }: SCUMPersistedDataViewProps) {
|
||||
const copy = datasetCopy[dataset];
|
||||
const load = useCallback(async () => {
|
||||
if (!serverInstanceId) return { status: "error" as const, reason: "插件页面没有绑定服务器。" };
|
||||
try {
|
||||
const response = dataset === "users" ? await platformApiClient.listSCUMUsers(serverInstanceId)
|
||||
: dataset === "squads" ? await platformApiClient.listSCUMSquads(serverInstanceId)
|
||||
: dataset === "activity" ? await platformApiClient.listSCUMActivity(serverInstanceId)
|
||||
: dataset === "gifts" ? await platformApiClient.listSCUMGifts(serverInstanceId)
|
||||
: await platformApiClient.listSCUMMapPoints(serverInstanceId);
|
||||
return { status: "ready" as const, items: response.items.map(normalizePersistedRow) as DatasetRecord[], count: response.count };
|
||||
} catch (error) {
|
||||
return { status: "error" as const, reason: error instanceof Error ? error.message : `${copy.title}数据加载失败。` };
|
||||
}
|
||||
}, [copy.title, dataset, serverInstanceId]);
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
const refresh = useCallback(() => { setState({ status: "loading" }); void load().then(setState); }, [load]);
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
return <section className="console-panel" aria-label={`SCUM ${copy.title}`}>
|
||||
<div className="panel-header">
|
||||
<div><h2><copy.icon size={16} aria-hidden="true" /> {copy.title}</h2><p className="provider-id">{copy.summary}</p></div>
|
||||
<button type="button" className="icon-command" onClick={refresh} disabled={state.status === "loading"}><RefreshCw size={14} aria-hidden="true" /><span>刷新</span></button>
|
||||
</div>
|
||||
{state.status === "loading" && <LoadingState compact label={`正在读取${copy.title}同步数据…`} />}
|
||||
{state.status === "error" && <ErrorState compact title={`${copy.title}数据不可用`} reason={state.reason} onRetry={refresh} />}
|
||||
{state.status === "ready" && (state.items.length ? <SCUMDatasetTable dataset={dataset} items={state.items} count={state.count} /> : <EmptyState title={`暂无${copy.title}数据`} description={copy.empty} icon={<ShieldAlert size={26} />} />)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function SCUMDatasetTable({ dataset, items, count }: { dataset: SCUMPersistedDataset; items: DatasetRecord[]; count: number }) {
|
||||
const columns = useMemo(() => tableColumns(dataset), [dataset]);
|
||||
return <div className="console-record-list">
|
||||
<dl className="console-stat-strip"><div><dt>已同步</dt><dd>{count}</dd></div><div><dt>当前展示</dt><dd>{Math.min(items.length, 100)}</dd></div><div><dt>数据源</dt><dd>平台数据库</dd></div></dl>
|
||||
<div className="resource-table-wrap"><table className="resource-table"><thead><tr>{columns.map((column) => <th key={column.label}>{column.label}</th>)}</tr></thead><tbody>{items.slice(0, 100).map((item) => <tr key={item.id}>{columns.map((column) => <td key={column.label}>{column.value(item)}</td>)}</tr>)}</tbody></table></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
type TableColumn = { label: string; value: (record: DatasetRecord) => string };
|
||||
function tableColumns(dataset: SCUMPersistedDataset): TableColumn[] {
|
||||
if (dataset === "users") return [column("名称", (value) => (value as SCUMUserRecord).displayName || "未命名"), column("Steam", (value) => (value as SCUMUserRecord).steamId), column("声望", (value) => numberText((value as SCUMUserRecord).famePoints)), column("余额", (value) => numberText((value as SCUMUserRecord).normalBalance)), column("最后登录", (value) => dateText((value as SCUMUserRecord).lastLoginTime || value.observedAt))];
|
||||
if (dataset === "squads") return [column("队伍", (value) => (value as SCUMSquadRecord).name || "未命名"), column("成员", (value) => numberText((value as SCUMSquadRecord).memberCount)), column("上限", (value) => numberText((value as SCUMSquadRecord).memberLimit)), column("分数", (value) => numberText((value as SCUMSquadRecord).score)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
|
||||
if (dataset === "activity") return [column("事件", (value) => (value as SCUMActivityEventRecord).kind || "活动"), column("对象", (value) => (value as SCUMActivityEventRecord).subjectName || (value as SCUMActivityEventRecord).subjectId), column("摘要", (value) => (value as SCUMActivityEventRecord).summary), column("发生时间", (value) => dateText((value as SCUMActivityEventRecord).occurredAt || value.observedAt)), column("坐标", (value) => coordinates(value as SCUMActivityEventRecord))];
|
||||
if (dataset === "gifts") return [column("礼包", (value) => (value as SCUMGiftRecord).name || "未命名礼包"), column("状态", (value) => (value as SCUMGiftRecord).status || "未知"), column("接收者", (value) => (value as SCUMGiftRecord).recipientName || (value as SCUMGiftRecord).recipientUserProfileId), column("可用时间", (value) => dateText((value as SCUMGiftRecord).availableAt)), column("过期时间", (value) => dateText((value as SCUMGiftRecord).expiresAt))];
|
||||
return [column("标记", (value) => (value as SCUMMapPointRecord).label || (value as SCUMMapPointRecord).kind || "地图点"), column("类型", (value) => (value as SCUMMapPointRecord).kind), column("地图", (value) => (value as SCUMMapPointRecord).mapId), column("坐标", (value) => coordinates(value as SCUMMapPointRecord)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
|
||||
}
|
||||
|
||||
function column(label: string, getValue: (record: DatasetRecord) => unknown): TableColumn { return { label, value: (record) => text(getValue(record)) }; }
|
||||
function normalizePersistedRow(row: DatasetRecord): DatasetRecord {
|
||||
const fields = row.fields ?? {};
|
||||
return { ...row, ...fields, syncedAt: row.updatedAt ?? row.syncedAt, observedAt: row.freshness?.observedAt ?? row.observedAt };
|
||||
}
|
||||
function text(value: unknown): string { return value === undefined || value === null || value === "" ? "--" : String(value); }
|
||||
function numberText(value: number | undefined): string { return value === undefined || value === null ? "--" : new Intl.NumberFormat("zh-CN").format(value); }
|
||||
function dateText(value: string | undefined): string { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
|
||||
function coordinates(value: Pick<SCUMActivityEventRecord, "locationX" | "locationY" | "locationZ"> | SCUMMapPointRecord): string { return value.locationX === undefined || value.locationY === undefined ? "--" : `X ${value.locationX} / Y ${value.locationY}${value.locationZ === undefined ? "" : ` / Z ${value.locationZ}`}`; }
|
||||
@@ -1,25 +1,6 @@
|
||||
export interface PluginPageWorkspaceActions {
|
||||
listSCUMUsers?: () => Promise<unknown>;
|
||||
listSCUMPlayers?: () => Promise<unknown>;
|
||||
listSCUMSquads?: () => Promise<unknown>;
|
||||
listSCUMActivity?: () => Promise<unknown>;
|
||||
listSCUMGifts?: () => Promise<unknown>;
|
||||
listSCUMMapPoints?: () => Promise<unknown>;
|
||||
getSCUMMapGeometry?: () => 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>;
|
||||
listGameGiftCatalogs?: () => Promise<unknown>;
|
||||
saveGameGiftCatalog?: (request: unknown) => Promise<unknown>;
|
||||
publishGameGiftCatalog?: (catalogId: string) => Promise<unknown>;
|
||||
listGameGiftGrants?: () => Promise<unknown>;
|
||||
requestGameGiftGrant?: (request: unknown) => Promise<unknown>;
|
||||
approveGameGiftGrant?: (grantId: string) => Promise<unknown>;
|
||||
pluginData?: {
|
||||
list: (collection: string, key?: string) => Promise<unknown>;
|
||||
put: (collection: string, key: string, value: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("PluginPageHostPage", () => {
|
||||
expect(html).not.toMatch(/sessionToken|componentKey|hostPath|dsn|runSocket|credential/i);
|
||||
});
|
||||
|
||||
it("loads the declared SCUM bundle without a browser-side game branch", () => {
|
||||
it("does not mount a bundle without client-side availability validation", () => {
|
||||
const html = renderToStaticMarkup(<PluginPageHostPage {...props("")} initialPlugin={plugin} />);
|
||||
expect(html).toContain("未绑定服务器");
|
||||
expect(html).toContain("正在校验并加载插件页面 bundle");
|
||||
@@ -96,18 +96,12 @@ describe("PluginPageHostPage", () => {
|
||||
expect(hostSource).not.toMatch(/ScumFileConfigWorkbench|GamePlayerIntelligencePanel|GameGiftCatalogPanel|ScumMapTrajectoryPanel|game\.scum/);
|
||||
});
|
||||
|
||||
it("keeps typed SCUM workspace callbacks stable across parent operational refreshes", () => {
|
||||
it("keeps generic plugin data callbacks stable across parent operational refreshes", () => {
|
||||
expect(hostSource).toContain("readyPluginRef.current = readyPlugin");
|
||||
expect(hostSource).toContain("hostContextRef.current = hostContext");
|
||||
expect(hostSource).toContain("listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId)");
|
||||
expect(hostSource).toContain("listSCUMUsers: () => platformApiClient.listSCUMUsers(serverId)");
|
||||
expect(hostSource).toContain("listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId)");
|
||||
expect(hostSource).toContain("listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId)");
|
||||
expect(hostSource).toContain("listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId)");
|
||||
expect(hostSource).not.toContain("SCUMPersistedDataView");
|
||||
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).toContain("list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key)");
|
||||
expect(hostSource).toContain("put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value)");
|
||||
expect(hostSource).not.toContain("listSCUMPlayers:");
|
||||
expect(hostSource).not.toContain("refreshWorkspace");
|
||||
expect(hostSource).not.toContain("requestFile");
|
||||
expect(hostSource).not.toContain("writeFile");
|
||||
|
||||
@@ -69,29 +69,10 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
|
||||
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
|
||||
if (!pluginId) return undefined;
|
||||
return {
|
||||
listSCUMUsers: () => platformApiClient.listSCUMUsers(serverId),
|
||||
listSCUMPlayers: () => platformApiClient.listSCUMPlayers(serverId),
|
||||
listSCUMSquads: () => platformApiClient.listSCUMSquads(serverId),
|
||||
listSCUMActivity: () => platformApiClient.listSCUMActivity(serverId),
|
||||
listSCUMGifts: () => platformApiClient.listSCUMGifts(serverId),
|
||||
listSCUMMapPoints: () => platformApiClient.listSCUMMapPoints(serverId),
|
||||
getSCUMMapGeometry: () => readyPluginRef.current?.mapTrajectories,
|
||||
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),
|
||||
listGameGiftCatalogs: () => platformApiClient.listGameGiftCatalogs(serverId),
|
||||
saveGameGiftCatalog: (request) => platformApiClient.saveGameGiftCatalog(serverId, request as never),
|
||||
publishGameGiftCatalog: (catalogId) => platformApiClient.publishGameGiftCatalog(serverId, catalogId),
|
||||
listGameGiftGrants: () => platformApiClient.listGameGiftGrants(serverId),
|
||||
requestGameGiftGrant: (request) => platformApiClient.requestGameGiftGrant(serverId, request as never),
|
||||
approveGameGiftGrant: (grantId) => platformApiClient.approveGameGiftGrant(serverId, grantId)
|
||||
pluginData: {
|
||||
list: (collection, key) => platformApiClient.listPluginData(serverId, collection, key),
|
||||
put: (collection, key, value) => platformApiClient.putPluginData(serverId, collection, key, value)
|
||||
}
|
||||
};
|
||||
}, [pluginId, serverId]);
|
||||
const bundleLoadKey = declaredBundlePage ? [declaredBundlePage.bundleKey, declaredBundlePage.bundleVersion, declaredBundlePage.bundleIntegritySha256, declaredBundlePage.path].join(":") : "";
|
||||
|
||||
Reference in New Issue
Block a user