Send user trajectories from the user list into a focused live map

- Open 「用户轨迹」 straight on the live map instead of a drawer, carrying the
  server, the focused user and a start/end window through the plugin page hash.
- Default that window to one hour before the user's last seen time and keep the
  plugin page query available to bundles through the host context.
- Replace the time range preset gate with always visible start/end datetime
  fields plus a quick-range select, so the user list rebuilds from the window.
- Drop the bulky trajectory list and draw one compact colour legend under the
  map, keeping a stable colour per identity and restricting vehicle tracks to
  the vehicles the selected users actually rode.
This commit is contained in:
npc0-hue
2026-09-16 19:35:31 +08:00
parent 6488f6b31d
commit bc45188be9
13 changed files with 220 additions and 74 deletions
+1
View File
@@ -10,6 +10,7 @@ export interface PageParams {
serverId?: string; serverId?: string;
pluginId?: string; pluginId?: string;
routeKey?: string; routeKey?: string;
pageQuery?: Record<string, string>;
} }
export interface PageRoute { export interface PageRoute {
+1
View File
@@ -121,6 +121,7 @@ export interface PluginBridgeHostContext {
pluginId: string; pluginId: string;
routeKey: string; routeKey: string;
serverInstanceId?: string; serverInstanceId?: string;
pageQuery: Record<string, string>;
themeTokens: PluginBridgeThemeTokens; themeTokens: PluginBridgeThemeTokens;
permissions: PluginPermission[]; permissions: PluginPermission[];
bridgeActions: PluginBridgeAction[]; bridgeActions: PluginBridgeAction[];
+3 -1
View File
@@ -58,12 +58,14 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey); const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
const manifestContract = useMemo(() => (readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin) : undefined), [readyPlugin]); const manifestContract = useMemo(() => (readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin) : undefined), [readyPlugin]);
const declaredBundlePage = manifestContract?.pages.find((candidate) => candidate.key === routeKey); const declaredBundlePage = manifestContract?.pages.find((candidate) => candidate.key === routeKey);
const pageQuery = params.pageQuery;
const hostContext = useMemo(() => manifestContract ? createPluginBridgeHostContext({ const hostContext = useMemo(() => manifestContract ? createPluginBridgeHostContext({
plugin: manifestContract, plugin: manifestContract,
routeKey, routeKey,
serverInstanceId: serverId || undefined, serverInstanceId: serverId || undefined,
pageQuery,
themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" } themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" }
}) : undefined, [manifestContract, routeKey, serverId]); }) : undefined, [manifestContract, routeKey, serverId, pageQuery]);
readyPluginRef.current = readyPlugin; readyPluginRef.current = readyPlugin;
hostContextRef.current = hostContext; hostContextRef.current = hostContext;
const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => { const workspaceActions = useMemo<PluginPageWorkspaceActions | undefined>(() => {
+8
View File
@@ -51,6 +51,14 @@ describe("console shell routes", () => {
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" }); expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
}); });
it("carries hosted plugin page focus queries through the hash", () => {
const pageQuery = { focus: "76561198000000001", from: "2026-09-16T09:30:00Z", to: "2026-09-16T11:00:00Z" };
const hash = hashForPage("pluginPage", { pluginId: "game.scum", routeKey: "live-map", serverId: "server-1", pageQuery });
const resolved = resolveRouteHash(hash, platformAdmin);
expect(resolved.route.id).toBe("pluginPage");
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "live-map", serverId: "server-1", pageQuery });
});
it("keeps route metadata available for shell navigation", () => { it("keeps route metadata available for shell navigation", () => {
expect(firstPartyRoutes.map((route) => route.id)).toContain("maintenance"); expect(firstPartyRoutes.map((route) => route.id)).toContain("maintenance");
expect(firstPartyRoutes.map((route) => route.id)).toContain("profileSettings"); expect(firstPartyRoutes.map((route) => route.id)).toContain("profileSettings");
+16 -2
View File
@@ -110,6 +110,11 @@ export function hashForPage(pageId: PageId, params: PageParams = {}): string {
if (params.serverId) { if (params.serverId) {
query.set("serverInstanceId", params.serverId); query.set("serverInstanceId", params.serverId);
} }
for (const [key, value] of Object.entries(params.pageQuery ?? {})) {
if (value) {
query.set(key, value);
}
}
const suffix = query.toString(); const suffix = query.toString();
return `#/plugin-pages/${encodeURIComponent(params.pluginId)}/${encodeURIComponent(params.routeKey)}${suffix ? `?${suffix}` : ""}`; return `#/plugin-pages/${encodeURIComponent(params.pluginId)}/${encodeURIComponent(params.routeKey)}${suffix ? `?${suffix}` : ""}`;
} }
@@ -151,13 +156,22 @@ export function resolveRouteHash(hash: string, user?: CurrentUserView): Resolved
return { route: routeForPage("serverDetail"), params: { serverId: decodeURIComponent(segments[1]), routeKey } }; return { route: routeForPage("serverDetail"), params: { serverId: decodeURIComponent(segments[1]), routeKey } };
} }
if (segments[0] === "plugin-pages" && segments.length > 2) { if (segments[0] === "plugin-pages" && segments.length > 2) {
const serverId = new URLSearchParams(rawQuery).get("serverInstanceId") ?? undefined; const query = new URLSearchParams(rawQuery);
const serverId = query.get("serverInstanceId") ?? undefined;
query.delete("serverInstanceId");
const pageQuery: Record<string, string> = {};
for (const [key, value] of query.entries()) {
if (value) {
pageQuery[key] = value;
}
}
return { return {
route: routeForPage("pluginPage"), route: routeForPage("pluginPage"),
params: { params: {
pluginId: decodeURIComponent(segments[1]), pluginId: decodeURIComponent(segments[1]),
routeKey: decodeURIComponent(segments[2]), routeKey: decodeURIComponent(segments[2]),
serverId serverId,
...(Object.keys(pageQuery).length ? { pageQuery } : {})
} }
}; };
} }
+9 -2
View File
@@ -794,14 +794,21 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.scum-map-menu>summary{display:inline-flex;align-items:center;min-height:26px;padding:0 9px;font-size:11px;list-style:none;cursor:pointer} .scum-map-menu>summary{display:inline-flex;align-items:center;min-height:26px;padding:0 9px;font-size:11px;list-style:none;cursor:pointer}
.scum-map-menu>summary::-webkit-details-marker{display:none} .scum-map-menu>summary::-webkit-details-marker{display:none}
.scum-map-menu[open]>summary{border-color:var(--accent);color:var(--ink)} .scum-map-menu[open]>summary{border-color:var(--accent);color:var(--ink)}
.scum-map-menu-body{position:absolute;top:calc(100% + 6px);left:0;z-index:9;display:grid;gap:6px;width:max-content;max-width:320px;padding:10px;border:1px solid var(--line-strong);border-radius:10px;background:var(--panel-material),var(--surface-solid);box-shadow:var(--panel-shadow)} .scum-map-menu-body{position:absolute;top:calc(100% + 6px);left:0;z-index:9;display:grid;gap:6px;width:max-content;max-width:320px;max-height:min(60vh,420px);overflow:auto;padding:10px;border:1px solid var(--line-strong);border-radius:10px;background:var(--panel-material),var(--surface-solid);box-shadow:var(--panel-shadow)}
.scum-map-menu-body .scum-layer-toggle{justify-content:flex-start} .scum-map-menu-body .scum-layer-toggle{justify-content:flex-start}
.scum-map-menu-body .page-status{margin:0;font-size:11px;white-space:normal} .scum-map-menu-body .page-status{margin:0;font-size:11px;white-space:normal}
.scum-map-bar{gap:6px} .scum-map-bar{gap:6px}
.scum-map-bar .console-field{flex:0 0 auto} .scum-map-bar .console-field{flex:0 0 auto}
.scum-map-bar select{min-height:26px;min-width:0;padding:0 8px;font-size:12px} .scum-map-bar select{min-height:26px;min-width:0;padding:0 8px;font-size:12px}
.scum-map-bar input[type=datetime-local]{min-height:26px;min-width:0;padding:0 6px;font-size:12px;color:var(--ink)}
.scum-map-bar .page-status{font-size:11px} .scum-map-bar .page-status{font-size:11px}
.scum-map-tracks .console-row.is-selected{border-color:var(--accent);color:var(--ink)} .map-track-legend{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:6px 10px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:10px;background:var(--control-surface)}
.map-track-legend .page-status{margin:0}
.map-track-chip{display:inline-flex;align-items:center;gap:6px;max-width:100%;min-height:24px;padding:0 9px;border:1px solid var(--line);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink-soft);font-size:11px;cursor:pointer}
.map-track-chip:hover,.map-track-chip.is-selected{border-color:var(--accent);color:var(--ink)}
.map-track-chip .map-track-swatch{margin-right:0}
.map-track-chip strong{color:var(--ink);font-size:11px}
.map-track-chip small{color:var(--ink-faint);font-size:10px}
.console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px} .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,.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} .console-row-button,.operations-job-row{width:100%;cursor:pointer}
@@ -49,6 +49,7 @@ describe("plugin bridge host utilities", () => {
pluginId: "game.example", pluginId: "game.example",
routeKey: "logs", routeKey: "logs",
serverInstanceId: "server-1", serverInstanceId: "server-1",
pageQuery: {},
themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }, themeTokens: { colorScheme: "dark", accentColor: "#22c55e" },
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"], permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"], bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"],
+2
View File
@@ -13,6 +13,7 @@ export function createPluginBridgeHostContext(input: {
plugin: PluginBridgeManifestContract; plugin: PluginBridgeManifestContract;
routeKey: string; routeKey: string;
serverInstanceId?: string; serverInstanceId?: string;
pageQuery?: Record<string, string>;
themeTokens: PluginBridgeThemeTokens; themeTokens: PluginBridgeThemeTokens;
}): PluginBridgeHostContext { }): PluginBridgeHostContext {
const page = input.plugin.pages.find((candidate) => candidate.key === input.routeKey); const page = input.plugin.pages.find((candidate) => candidate.key === input.routeKey);
@@ -26,6 +27,7 @@ export function createPluginBridgeHostContext(input: {
pluginId: input.plugin.id, pluginId: input.plugin.id,
routeKey: input.routeKey, routeKey: input.routeKey,
serverInstanceId: input.serverInstanceId, serverInstanceId: input.serverInstanceId,
pageQuery: { ...(input.pageQuery ?? {}) },
themeTokens: input.themeTokens, themeTokens: input.themeTokens,
permissions, permissions,
bridgeActions, bridgeActions,
@@ -27,7 +27,7 @@ type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
type ScumMapLayer = "terrain" | "topo" | "night"; type ScumMapLayer = "terrain" | "topo" | "night";
type CatalogPickTarget = "gift-items" | "produce-item"; type CatalogPickTarget = "gift-items" | "produce-item";
type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory"; type PlayerPanelKind = "closed" | "gifts" | "items" | "history";
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
type MapViewState = { zoom: number; x: number; y: number }; type MapViewState = { zoom: number; x: number; y: number };
type MapPlaybackState = { playing: boolean; value: number; speed: number }; type MapPlaybackState = { playing: boolean; value: number; speed: number };
@@ -50,9 +50,11 @@ const scumMapSegmentGapMs = 15 * 60 * 1000;
const scumMapSegmentJump = 52000; const scumMapSegmentJump = 52000;
const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 }; const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 };
const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1 }; const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1 };
const scumMapTimePresets: ReadonlyArray<readonly [string, string]> = [["all", "全部时段"], ["15m", "最近 15 分钟"], ["1h", "最近 1 小时"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["custom", "自定义起止"]]; const scumMapTimePresets: ReadonlyArray<readonly [string, string]> = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["custom", "自定义起止"]];
const scumMapPresetMs: Record<string, number> = { "15m": 15 * 60 * 1000, "1h": 60 * 60 * 1000, "6h": 6 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000 }; const scumMapPresetMs: Record<string, number> = { "15m": 15 * 60 * 1000, "1h": 60 * 60 * 1000, "6h": 6 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000 };
const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE", "#98D8C8", "#FFA07A", "#45B7D1"]; const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE", "#98D8C8", "#FFA07A", "#45B7D1"];
const scumMapTrackColorCache = new Map<string, string>();
const scumMapDefaultWindowMs = 60 * 60 * 1000;
const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false }; const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false };
export type ReactLike = { export type ReactLike = {
@@ -65,7 +67,9 @@ export type ReactLike = {
export type SCUMPageContext = { export type SCUMPageContext = {
pageKey?: string; pageKey?: string;
pageTitle?: string; pageTitle?: string;
pluginId?: string;
serverInstanceId?: string; serverInstanceId?: string;
pageQuery?: Record<string, string>;
permissions: string[]; permissions: string[];
availability: { available: boolean; reason?: string }; availability: { available: boolean; reason?: string };
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>; featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
@@ -77,6 +81,10 @@ type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: stri
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) { export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement; const e = react.createElement;
const pageQuery = input.pageQuery ?? {};
const focusUser = pageQuery.focus ?? "";
const focusKey = [input.pageKey ?? "players", focusUser, pageQuery.from ?? "", pageQuery.to ?? ""].join("|");
const defaultMapRange = mapQueryRange(pageQuery.from, pageQuery.to);
const [state, setState] = usePluginState<DataState>(react, { status: "loading" }); const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" }); const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerSearch, setPlayerSearch] = usePluginState(react, ""); const [playerSearch, setPlayerSearch] = usePluginState(react, "");
@@ -122,10 +130,10 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [mapLayers, setMapLayers] = usePluginState<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); const [mapLayers, setMapLayers] = usePluginState<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
const [mapView, setMapView] = usePluginState<MapViewState>(react, scumMapDefaultView); const [mapView, setMapView] = usePluginState<MapViewState>(react, scumMapDefaultView);
const [mapTimePreset, setMapTimePreset] = usePluginState(react, "all"); const [mapTimePreset, setMapTimePreset] = usePluginState(react, pageQuery.from || pageQuery.to ? "custom" : "1h");
const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, ""); const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, localDateTimeValue(defaultMapRange.from));
const [mapTimeTo, setMapTimeTo] = usePluginState(react, ""); const [mapTimeTo, setMapTimeTo] = usePluginState(react, localDateTimeValue(defaultMapRange.to));
const [mapUsers, setMapUsers] = usePluginState<string[] | undefined>(react, undefined); const [mapUsers, setMapUsers] = usePluginState<string[] | undefined>(react, focusUser ? [focusUser] : undefined);
const [mapTrackKey, setMapTrackKey] = usePluginState(react, ""); const [mapTrackKey, setMapTrackKey] = usePluginState(react, "");
const [mapPlayback, setMapPlayback] = usePluginState<MapPlaybackState>(react, scumMapPlaybackIdle); const [mapPlayback, setMapPlayback] = usePluginState<MapPlaybackState>(react, scumMapPlaybackIdle);
const mapViewportRef = react.useRef ? react.useRef<HTMLElement | null>(null) : { current: null as HTMLElement | null }; const mapViewportRef = react.useRef ? react.useRef<HTMLElement | null>(null) : { current: null as HTMLElement | null };
@@ -179,6 +187,24 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const timer = setInterval(() => setMapPlayback((previous) => advanceMapPlayback(previous)), 90); const timer = setInterval(() => setMapPlayback((previous) => advanceMapPlayback(previous)), 90);
return () => { clearInterval(timer); }; return () => { clearInterval(timer); };
}, [mapPlayback.playing, setMapPlayback]); }, [mapPlayback.playing, setMapPlayback]);
if (react.useEffect) react.useEffect(() => {
if (pageKey !== "live-map") return;
if (!focusUser) { setMapUsers(undefined); applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); return; }
const range = mapQueryRange(pageQuery.from, pageQuery.to);
setMapUsers([focusUser]);
setMapTimePreset("custom");
setMapTimeFrom(localDateTimeValue(range.from));
setMapTimeTo(localDateTimeValue(range.to));
}, [pageKey, focusKey]);
if (react.useEffect) react.useEffect(() => {
if (pageKey !== "live-map" || !focusUser || state.status !== "ready") return;
const node = mapViewportRef.current;
if (!node) return;
const settings = state.data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? state.data.mapSettings[0];
const range = mapQueryRange(pageQuery.from, pageQuery.to);
const points = mapTrackPointList(mapTracks(state.data, resolveMapBounds(settings), { from: range.from, to: range.to, label: "" }, [focusUser], mapVehicleClassIndex(state.data)));
if (points.length) setMapView(fitMapView(points, node, 0.8));
}, [pageKey, focusKey, state.status]);
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
@@ -274,28 +300,46 @@ function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index:
e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")), e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")),
e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)), e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)),
e("td", null, userDateField(player, "lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")), e("td", null, userDateField(player, "lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")),
e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open)) e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, input, open))
); );
} }
function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view: ViewState, open: (kind: PlayerPanelKind) => void) { function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, input: SCUMPageContext, open: (kind: PlayerPanelKind) => void) {
return e("details", { className: "inline-action-control" }, return e("details", { className: "inline-action-control" },
e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"), e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"),
e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" }, e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" },
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"), e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("trajectory") }, "用户轨迹") e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => openPlayerTrajectoryOnMap(player, input) }, "用户轨迹")
) )
); );
} }
function openPlayerTrajectoryOnMap(player: RecordMap, input: SCUMPageContext) {
if (!input.serverInstanceId || typeof window === "undefined") return;
const identity = textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey");
if (!identity) return;
const to = Date.now();
const from = (playerLastSeenAt(player) ?? to) - scumMapDefaultWindowMs;
const query = new URLSearchParams({ serverInstanceId: input.serverInstanceId, focus: identity, from: new Date(from).toISOString(), to: new Date(to).toISOString() });
window.location.hash = `#/plugin-pages/${encodeURIComponent(input.pluginId ?? "game.scum")}/live-map?${query.toString()}`;
}
function playerLastSeenAt(player: RecordMap): number | undefined {
for (const key of ["lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt"]) {
const stamp = Date.parse(textField(player, key));
if (!Number.isNaN(stamp)) return stamp;
}
return undefined;
}
function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const kind = view.playerPanel.kind; const kind = view.playerPanel.kind;
const name = textField(player, "displayName", "playerName", "name") || playerKey(player); const name = textField(player, "displayName", "playerName", "name") || playerKey(player);
const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" }); const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" });
const content = kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player) : playerTrajectoryPanel(e, player, data); const content = kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : playerHistoryPanel(e, player);
const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹"; const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : "登录历史";
return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close }, return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close },
e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() }, e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() },
e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")), e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")),
@@ -343,11 +387,6 @@ function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap) {
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录状态来自平台 scum_user 表,由 Run 推送的登录事实维护。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: `history-${index}`, className: "console-row" }, e("span", null, row.label), e("strong", null, dateField(row as RecordMap, "at")), e("strong", null, row.meta))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录状态来自平台 scum_user 表,由 Run 推送的登录事实维护。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: `history-${index}`, className: "console-row" }, e("span", null, row.label), e("strong", null, dateField(row as RecordMap, "at")), e("strong", null, row.meta))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
} }
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120);
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自平台 scum_user_trajectory 表;乘车状态使用轨迹记录声明的载具。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = textField(row, "riddenVehicleId", "gameVehicleId"); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", { className: "scum-trajectory-vehicle" }, trajectoryVehicleIcon(row) ? e("img", { className: "scum-catalog-icon", src: trajectoryVehicleIcon(row), alt: "" }) : null, ride ? `乘坐 ${ride}` : textField(row, "source") || "平台轨迹表")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
}
function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); } function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); }
function playerIdentities(player: RecordMap): string[] { return ["steamId", "gamePlayerId", "playerId", "userProfileId", "profileId", "id"].map((key) => textField(player, key)).filter(Boolean); } function playerIdentities(player: RecordMap): string[] { return ["steamId", "gamePlayerId", "playerId", "userProfileId", "profileId", "id"].map((key) => textField(player, key)).filter(Boolean); }
function playerKey(player: RecordMap): string { return textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey"); } function playerKey(player: RecordMap): string { return textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey"); }
@@ -575,7 +614,10 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones")); const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones"));
const window = mapTimeWindow(view); const window = mapTimeWindow(view);
const catalog = mapVehicleClassIndex(data); const catalog = mapVehicleClassIndex(data);
const tracks = mapTracks(data, bounds, window, view, catalog); const users = mapTrackUsers(data, window);
const playerLabels = new Map<string, string>();
for (const player of data.players) { const key = textField(player, "steamId", "gamePlayerId", "id"); const label = textField(player, "displayName", "name", "playerName"); if (key && label) playerLabels.set(key, label); }
const tracks = mapTracks(data, bounds, window, view.mapUsers, catalog);
const span = mapPlaybackSpan(window, tracks); const span = mapPlaybackSpan(window, tracks);
const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value; const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value;
const zoom = view.mapView.zoom; const zoom = view.mapView.zoom;
@@ -585,10 +627,10 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : []; const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : [];
return e("div", { className: "console-record-list scum-map-surface" }, return e("div", { className: "console-record-list scum-map-surface" },
e("div", { className: "resource-filter-bar scum-filter-bar scum-map-bar" }, e("div", { className: "resource-filter-bar scum-filter-bar scum-map-bar" },
labeledField(e, "时间", e("select", { value: view.mapTimePreset, "aria-label": "轨迹时间范围", onChange: (event: InputEvent) => view.setMapTimePreset(inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))), labeledField(e, "快捷", e("select", { value: view.mapTimePreset, "aria-label": "轨迹快捷时间", onChange: (event: InputEvent) => applyMapTimePreset(view.setMapTimePreset, view.setMapTimeFrom, view.setMapTimeTo, inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))),
view.mapTimePreset === "custom" ? labeledField(e, "", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹始时间", onChange: (event: InputEvent) => view.setMapTimeFrom(inputValue(event)) })) : null, labeledField(e, "开始", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹始时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeFrom(inputValue(event)); } })),
view.mapTimePreset === "custom" ? labeledField(e, "", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => view.setMapTimeTo(inputValue(event)) })) : null, labeledField(e, "结束", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeTo(inputValue(event)); } })),
mapUserMenu(e, view, mapTrackUsers(data, window)), mapUserMenu(e, view, users, playerLabels),
mapLayerMenu(e, view, zonesOverlay, (next) => { persistMapLayers(view, actions, baseLayer, next); }), mapLayerMenu(e, view, zonesOverlay, (next) => { persistMapLayers(view, actions, baseLayer, next); }),
labeledField(e, "底图", e("select", { value: baseLayer, "aria-label": "地图底图", onChange: (event: InputEvent) => { const next = mapBaseLayerValue(inputValue(event)); view.setMapBaseLayer(next); persistMapLayers(view, actions, next, zonesOverlay); } }, scumMapBaseLayers.map(([key, label]) => e("option", { key, value: key }, label)))), labeledField(e, "底图", e("select", { value: baseLayer, "aria-label": "地图底图", onChange: (event: InputEvent) => { const next = mapBaseLayerValue(inputValue(event)); view.setMapBaseLayer(next); persistMapLayers(view, actions, next, zonesOverlay); } }, scumMapBaseLayers.map(([key, label]) => e("option", { key, value: key }, label)))),
e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.label}`) e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.label}`)
@@ -615,7 +657,7 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
) )
), ),
mapPlaybackBar(e, view, span, tracks.length > 0), mapPlaybackBar(e, view, span, tracks.length > 0),
mapTrackList(e, view, tracks, catalog), mapTrackLegend(e, view, tracks, playerLabels),
selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null
); );
} }
@@ -627,9 +669,10 @@ function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index:
return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, icon ? e("img", { src: icon, alt: "" }) : ""); return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, icon ? e("img", { src: icon, alt: "" }) : "");
} }
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>) { function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map<string, string>) {
const selected = view.mapUsers; const selected = view.mapUsers;
const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${users.length}` : "未选"; const rows = [...users, ...(selected ?? []).filter((key) => !users.some((user) => user.key === key)).map((key) => ({ key, label: labels.get(key) || key, count: 0 }))];
const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${rows.length}` : "未选";
return e("details", { className: "scum-map-menu", "aria-label": "轨迹用户筛选" }, return e("details", { className: "scum-map-menu", "aria-label": "轨迹用户筛选" },
e("summary", { className: "icon-command" }, `用户 ${summary}`), e("summary", { className: "icon-command" }, `用户 ${summary}`),
e("div", { className: "scum-map-menu-body" }, e("div", { className: "scum-map-menu-body" },
@@ -638,11 +681,25 @@ function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Arra
e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"), e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"),
e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "不限") e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "不限")
), ),
users.length ? users.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), `${user.label} · ${user.count}`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。") e("p", { className: "page-status" }, "列出所选起止时间内有轨迹的用户,颜色与地图轨迹一致。"),
rows.length ? rows.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), e("span", { className: "map-track-swatch", style: { background: mapTrackColor(user.key) } }), `${user.label} · ${user.count}`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。")
) )
); );
} }
function mapTrackLegend(e: ReactLike["createElement"], view: ViewState, tracks: MapTrack[], labels: Map<string, string>) {
const selected = view.mapUsers;
const empty = selected && selected.length ? `${selected.map((key) => labels.get(key) || key).join("、")} 在当前时间范围内没有轨迹记录,可放宽快捷时间范围。` : "当前时间范围内没有轨迹。";
return e("div", { className: "map-track-legend", role: "group", "aria-label": "轨迹颜色图例", title: "轨迹来自平台 scum_user_trajectory 与 scum_vehicle_trajectory 表;虚线为步行,实线为乘车。" },
e("span", { className: "page-status" }, tracks.length ? `${tracks.length} 条轨迹 · 虚线 步行 · 实线 乘车 · 点击色块可定位` : empty),
tracks.map((track) => e("button", { key: track.key, type: "button", className: view.mapTrackKey === track.key ? "map-track-chip is-selected" : "map-track-chip", title: `${track.label} ${timeLabel(track.from)}${timeLabel(track.to)}`, onClick: () => { view.setMapTrackKey(track.key); view.setMapView(fitMapView(track.points, view.mapViewportRef.current, 0.8)); } },
e("span", { className: "map-track-swatch", style: { background: track.color } }),
e("strong", null, track.label),
e("small", null, `${track.kind === "player" ? "用户" : "载具"} · ${track.points.length} 点 · 步行 ${track.walk} / 乘车 ${track.ride}`)
))
);
}
function mapLayerMenu(e: ReactLike["createElement"], view: ViewState, zonesOverlay: boolean, onZones: (next: boolean) => void) { function mapLayerMenu(e: ReactLike["createElement"], view: ViewState, zonesOverlay: boolean, onZones: (next: boolean) => void) {
const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[]; const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[];
const enabled = layers.filter((layer) => view.mapLayers[layer]).length; const enabled = layers.filter((layer) => view.mapLayers[layer]).length;
@@ -672,17 +729,6 @@ function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: {
); );
} }
function mapTrackList(e: ReactLike["createElement"], view: ViewState, tracks: MapTrack[], catalog: Map<string, string>) {
return e("article", { className: "console-module scum-map-tracks" },
e("div", { className: "panel-header" }, e("h2", null, "轨迹列表"), e("span", { className: "page-status" }, `${tracks.length} 条 · 虚线 步行 · 实线 乘车 · 色块对应用户`)),
tracks.length ? e("div", { className: "console-row-list" }, tracks.map((track) => e("button", { key: track.key, type: "button", className: view.mapTrackKey === track.key ? "console-row console-row-button is-selected" : "console-row console-row-button", onClick: () => { view.setMapTrackKey(track.key); view.setMapView(fitMapView(track.points, view.mapViewportRef.current, 0.8)); } },
e("span", null, e("span", { className: "map-track-swatch", style: { background: track.color } }), e("strong", null, track.label), e("small", null, `${track.kind === "player" ? "用户" : "载具"} · ${track.points.length} 点 · 步行 ${track.walk} / 乘车 ${track.ride}`)),
e("strong", null, `${timeLabel(track.from)}${timeLabel(track.to)}`),
e("span", { className: "scum-catalog-icons" }, track.vehicles.slice(0, 3).map((code, index) => { const icon = scumVehicleIconUrl(catalog.get(code) ?? code); return icon ? e("img", { key: `${code}-${index}`, className: "scum-catalog-icon", src: icon, alt: "" }) : null; }))
))) : e("p", { className: "page-status" }, "当前时间范围内的轨迹记录为空。")
);
}
function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, stroke: number) { function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, stroke: number) {
const shapes: unknown[] = []; const shapes: unknown[] = [];
for (const track of tracks) for (const segment of mapTrackSegments(track)) { for (const track of tracks) for (const segment of mapTrackSegments(track)) {
@@ -709,7 +755,7 @@ function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], curs
if (!visible.length) continue; if (!visible.length) continue;
const middle = visible[Math.floor(visible.length / 2)]; const middle = visible[Math.floor(visible.length / 2)];
const code = mapRideCode(middle.row) || track.label; const code = mapRideCode(middle.row) || track.label;
const icon = scumVehicleIconUrl(catalog.get(code) ?? code); const icon = trajectoryVehicleIcon(middle.row) || scumVehicleIconUrl(catalog.get(code) ?? code);
placed += 1; placed += 1;
markers.push(e("span", { key: `${segment.key}:ride`, className: "map-ride-marker", style: mapSceneStyle(middle), title: `${track.label} 乘坐 ${code}` }, icon ? e("img", { src: icon, alt: "" }) : "车")); markers.push(e("span", { key: `${segment.key}:ride`, className: "map-ride-marker", style: mapSceneStyle(middle), title: `${track.label} 乘坐 ${code}` }, icon ? e("img", { src: icon, alt: "" }) : "车"));
} }
@@ -720,35 +766,58 @@ function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], curs
function mapSceneStyle(point: MapTrackPoint): Record<string, string> { return { left: `${Math.max(0, Math.min(100, point.x / scumMapSize * 100))}%`, top: `${Math.max(0, Math.min(100, point.y / scumMapSize * 100))}%` }; } function mapSceneStyle(point: MapTrackPoint): Record<string, string> { return { left: `${Math.max(0, Math.min(100, point.x / scumMapSize * 100))}%`, top: `${Math.max(0, Math.min(100, point.y / scumMapSize * 100))}%` }; }
function mapTimeWindow(view: ViewState): MapTimeWindow { function mapTimeWindow(view: ViewState): MapTimeWindow {
if (view.mapTimePreset === "custom") { const from = parseTimeInput(view.mapTimeFrom);
const from = Date.parse(view.mapTimeFrom); const to = parseTimeInput(view.mapTimeTo);
const to = Date.parse(view.mapTimeTo); if (from === undefined && to === undefined) return { from: -Infinity, to: Infinity, label: "全部时段" };
const safeFrom = Number.isNaN(from) ? -Infinity : from; const lower = from ?? -Infinity;
const safeTo = Number.isNaN(to) ? Infinity : to; const upper = to ?? Infinity;
return { from: safeFrom, to: safeTo, label: `${timeLabel(safeFrom)} ${timeLabel(safeTo)}` }; return { from: lower, to: upper, label: `${shortTimeLabel(lower)} ${shortTimeLabel(upper)}` };
}
const span = scumMapPresetMs[view.mapTimePreset];
if (!span) return { from: -Infinity, to: Infinity, label: "全部时段" };
const to = Date.now();
const label = scumMapTimePresets.find(([key]) => key === view.mapTimePreset)?.[1] ?? "最近";
return { from: to - span, to, label };
} }
function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, view: ViewState, catalog: Map<string, string>): MapTrack[] { function parseTimeInput(value: string | undefined): number | undefined {
const stamp = Date.parse(value ?? "");
return Number.isNaN(stamp) ? undefined : stamp;
}
function mapQueryRange(from: string | undefined, to: string | undefined): { from: number; to: number } {
const upper = parseTimeInput(to) ?? Date.now();
return { from: parseTimeInput(from) ?? upper - scumMapDefaultWindowMs, to: upper };
}
function localDateTimeValue(value: number): string {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function applyMapTimePreset(setPreset: StateSetter<string>, setFrom: StateSetter<string>, setTo: StateSetter<string>, preset: string) {
setPreset(preset);
const span = scumMapPresetMs[preset];
if (span) { const now = Date.now(); setFrom(localDateTimeValue(now - span)); setTo(localDateTimeValue(now)); return; }
if (preset === "all") { setFrom(""); setTo(""); }
}
function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, users: string[] | undefined, catalog: Map<string, string>): MapTrack[] {
const grouped = new Map<string, MapTrack>(); const grouped = new Map<string, MapTrack>();
for (const row of data.trajectories) { const windowRows = data.trajectories.filter((row) => { const time = trajectoryOrder(row); return time >= window.from && time <= window.to; });
const riddenVehicles = new Set<string>();
if (users !== undefined) for (const row of windowRows) {
if (layerOf(row) === "vehicles" || !users.includes(mapPlayerTrackIdentity(row))) continue;
const vehicle = mapRiddenVehicleId(row);
if (vehicle) riddenVehicles.add(vehicle);
}
for (const row of windowRows) {
const position = positionOf(row); const position = positionOf(row);
if (!hasCoordinates(position)) continue; if (!hasCoordinates(position)) continue;
const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player"; const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player";
const identity = kind === "player" ? textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id") : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); const identity = kind === "player" ? mapPlayerTrackIdentity(row) : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id");
if (!identity) continue; if (!identity) continue;
if (kind === "player" && view.mapUsers !== undefined && !view.mapUsers.includes(identity)) continue; if (kind === "player" ? users !== undefined && !users.includes(identity) : users !== undefined && !riddenVehicles.has(identity)) continue;
const time = trajectoryOrder(row); const time = trajectoryOrder(row);
if (time < window.from || time > window.to) continue;
const scene = mapScenePoint(row, bounds); const scene = mapScenePoint(row, bounds);
const rideCode = mapRideCode(row); const rideCode = mapRideCode(row);
const key = `${kind}:${identity}`; const key = `${kind}:${identity}`;
const track = grouped.get(key) ?? { key, kind, label: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: scumMapTrackColors[stableIndex(identity, scumMapTrackColors.length)], points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] }; const track = grouped.get(key) ?? { key, kind, label: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: mapTrackColor(identity), points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] };
track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode) }); track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode) });
if (rideCode && !track.vehicles.includes(rideCode)) track.vehicles.push(rideCode); if (rideCode && !track.vehicles.includes(rideCode)) track.vehicles.push(rideCode);
track.from = Math.min(track.from, time); track.from = Math.min(track.from, time);
@@ -766,11 +835,14 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind
return tracks.sort((left, right) => right.to - left.to).slice(0, scumMapTrackLimit); return tracks.sort((left, right) => right.to - left.to).slice(0, scumMapTrackLimit);
} }
function mapPlayerTrackIdentity(row: RecordMap): string { return textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); }
function mapRiddenVehicleId(row: RecordMap): string { return textField(row, "riddenVehicleId", "gameVehicleId", "vehicleId"); }
function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> { function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> {
const users = new Map<string, { key: string; label: string; count: number }>(); const users = new Map<string, { key: string; label: string; count: number }>();
for (const row of data.trajectories) { for (const row of data.trajectories) {
if (layerOf(row) === "vehicles") continue; if (layerOf(row) === "vehicles") continue;
const identity = textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); const identity = mapPlayerTrackIdentity(row);
if (!identity) continue; if (!identity) continue;
const time = trajectoryOrder(row); const time = trajectoryOrder(row);
if (time < window.from || time > window.to) continue; if (time < window.from || time > window.to) continue;
@@ -855,9 +927,17 @@ function fitMapView(points: MapTrackPoint[], node: HTMLElement | null, padding:
function mapBaseLayerValue(value: string): ScumMapLayer { return value === "topo" || value === "night" ? value : "terrain"; } function mapBaseLayerValue(value: string): ScumMapLayer { return value === "topo" || value === "night" ? value : "terrain"; }
function stableIndex(value: string, length: number): number { let hash = 0; for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) % 1000003; return hash % length; } function mapTrackColor(identity: string): string {
const cached = scumMapTrackColorCache.get(identity);
if (cached) return cached;
const index = scumMapTrackColorCache.size;
const color = index < scumMapTrackColors.length ? scumMapTrackColors[index] : `hsl(${(index * 137) % 360} 72% 62%)`;
scumMapTrackColorCache.set(identity, color);
return color;
}
function timeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "unknown" : date.toLocaleString(); } function timeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "unknown" : date.toLocaleString(); }
function shortTimeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "unknown"; const pad = (part: number) => String(part).padStart(2, "0"); return `${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`; }
function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[]) { function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[]) {
const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : ""; const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : "";
@@ -3,7 +3,7 @@
"id": "game.scum", "id": "game.scum",
"name": "SCUM Server", "name": "SCUM Server",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.", "description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.",
"version": "0.1.22", "version": "0.1.23",
"kind": "game-plugin", "kind": "game-plugin",
"tags": [ "tags": [
"scum", "scum",
@@ -2,4 +2,4 @@ import { renderSCUMFeaturePage } from "../features/page.js";
export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" }; export const pluginPageBundle = { key: "scum-server-plugin", version: "1.0.3", integritySha256: "sha256:3488b316d909e597024f8f31c7bc96ab8019f643d74a528dc442d3df0dc3d54e" };
export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", serverInstanceId: input.context.serverInstanceId, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspaceActions: input.workspaceActions }); } export function renderPluginPage(react: any, input: any) { return renderSCUMFeaturePage(react, { pageKey: input.page?.key ?? "players", pageTitle: input.page?.title ?? "用户管理", pluginId: input.context.pluginId, serverInstanceId: input.context.serverInstanceId, pageQuery: input.context.pageQuery ?? {}, permissions: input.context.permissions, availability: input.availability, featureAvailability: input.availability.features, workspaceActions: input.workspaceActions }); }
+1 -1
View File
@@ -357,7 +357,7 @@ describe("plugin manifest validation", () => {
const serialized = JSON.stringify(manifest).toLowerCase(); const serialized = JSON.stringify(manifest).toLowerCase();
expect(serialized).not.toContain("local-proof"); expect(serialized).not.toContain("local-proof");
expect(manifest.version).toBe("0.1.22"); expect(manifest.version).toBe("0.1.23");
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server"); expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"])); expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([ expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
+39 -9
View File
@@ -14,6 +14,8 @@ const featureRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../example
const pageSource = readFileSync(resolve(featureRoot, "page.ts"), "utf8"); const pageSource = readFileSync(resolve(featureRoot, "page.ts"), "utf8");
const dataClientSource = readFileSync(resolve(featureRoot, "page-data.ts"), "utf8"); const dataClientSource = readFileSync(resolve(featureRoot, "page-data.ts"), "utf8");
const mapTestWindow = { from: "2026-08-09T23:00:00Z", to: "2026-08-10T01:00:00Z" };
const surfaceData: SCUMSurfaceData = { const surfaceData: SCUMSurfaceData = {
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, lastLoginIp: "203.0.113.7", position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }], players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, lastLoginIp: "203.0.113.7", position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }],
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }], squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }],
@@ -202,6 +204,8 @@ describe("SCUM plugin feature module", () => {
expect(view.texts).not.toContain("通用数据/机器动作可用"); expect(view.texts).not.toContain("通用数据/机器动作可用");
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining([`同步 ${"SCUM"}.db`, "重新读取", "编辑属性"])); expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining([`同步 ${"SCUM"}.db`, "重新读取", "编辑属性"]));
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["礼包状态", "他的物品", "登录历史", "用户轨迹"])); expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["礼包状态", "他的物品", "登录历史", "用户轨迹"]));
expect(pageSource).not.toContain('open("trajectory")');
expect(pageSource).toContain("/live-map?${query.toString()}");
expect(view.inputs.map((input) => input.label)).toContain("搜索用户"); expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
expect(pageSource).toContain("console-panel scum-workbench"); expect(pageSource).toContain("console-panel scum-workbench");
expect(pageSource).toContain("resource-filter-bar scum-filter-bar"); expect(pageSource).toContain("resource-filter-bar scum-filter-bar");
@@ -224,7 +228,7 @@ describe("SCUM plugin feature module", () => {
it("keeps user management off SCUM database SQL execution paths", () => { it("keeps user management off SCUM database SQL execution paths", () => {
const source = `${pageSource}\n${dataClientSource}`; const source = `${pageSource}\n${dataClientSource}`;
expect(source).toContain("平台 scum_user_trajectory 表"); expect(source).toContain("平台 scum_user_trajectory 与 scum_vehicle_trajectory 表");
expect(source).toContain("平台 SCUM 用户与载具表"); expect(source).toContain("平台 SCUM 用户与载具表");
expect(source).not.toContain("remote.run.db.sqlite.execute"); expect(source).not.toContain("remote.run.db.sqlite.execute");
expect(source).not.toContain("scum-database"); expect(source).not.toContain("scum-database");
@@ -283,7 +287,7 @@ describe("SCUM plugin feature module", () => {
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" }); const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
expect(view.nodes).toContain("div:SCUM 地图图层"); expect(view.nodes).toContain("div:SCUM 地图图层");
expect(view.nodes).toContain("div:SCUM 地图视图"); expect(view.nodes).toContain("div:SCUM 地图视图");
expect(view.inputs.map((input) => input.label)).toContain("轨迹时间范围"); expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["轨迹快捷时间", "轨迹开始时间", "轨迹结束时间"]));
expect(view.inputs.map((input) => input.label)).not.toContain("筛选地图点"); expect(view.inputs.map((input) => input.label)).not.toContain("筛选地图点");
for (const layer of ["用户", "载具", "旗帜", "区域", "其他"]) expect(view.texts).toContain(layer); for (const layer of ["用户", "载具", "旗帜", "区域", "其他"]) expect(view.texts).toContain(layer);
expect(view.texts).toContain("地图点详情"); expect(view.texts).toContain("地图点详情");
@@ -302,12 +306,13 @@ describe("SCUM plugin feature module", () => {
expect(view.texts.some((text) => /\d+ \/ \d+ 个点 · \d+ 条轨迹/.test(text))).toBe(true); expect(view.texts.some((text) => /\d+ \/ \d+ 个点 · \d+ 条轨迹/.test(text))).toBe(true);
for (const layer of ["地形", "等高线", "夜间", "建筑区"]) expect(view.texts).toContain(layer); for (const layer of ["地形", "等高线", "夜间", "建筑区"]) expect(view.texts).toContain(layer);
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["地形", "等高线", "夜间"])); expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["地形", "等高线", "夜间"]));
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["轨迹时间范围", "地图底图", "轨迹回放进度"])); expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["轨迹快捷时间", "轨迹开始时间", "轨迹结束时间", "地图底图", "轨迹回放进度"]));
expect(view.inputs.map((input) => input.label)).not.toContain("启用自定义地图"); expect(view.inputs.map((input) => input.label)).not.toContain("启用自定义地图");
expect(view.texts).not.toContain("地图范围与图层"); expect(view.texts).not.toContain("地图范围与图层");
expect(view.texts).not.toContain("自定义范围"); expect(view.texts).not.toContain("自定义范围");
expect(view.elements.map((element) => element.label)).toEqual(expect.arrayContaining(["轨迹用户筛选", "地图图层筛选"])); expect(view.elements.map((element) => element.label)).toEqual(expect.arrayContaining(["轨迹用户筛选", "地图图层筛选"]));
expect(view.texts).toContain("轨迹列表"); expect(view.texts).not.toContain("轨迹列表");
expect(view.elements.map((element) => element.label)).toContain("轨迹颜色图例");
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "不限", "回放", "回到最新"])); expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "不限", "回放", "回到最新"]));
expect(pageSource).toContain('new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href'); expect(pageSource).toContain('new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href');
expect(pageSource).toContain("mapBoardStyle(baseLayer, zonesOverlay)"); expect(pageSource).toContain("mapBoardStyle(baseLayer, zonesOverlay)");
@@ -320,12 +325,12 @@ describe("SCUM plugin feature module", () => {
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 120, y: 140, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:03Z" }, { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 120, y: 140, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:03Z" },
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 180, y: 140, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:04Z" } { subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 180, y: 140, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:04Z" }
] }; ] };
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: riding }); const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: riding, pageQuery: mapTestWindow });
expect(view.nodes).toContain("polyline:"); expect(view.nodes).toContain("polyline:");
expect(view.texts).toEqual(expect.arrayContaining(["用户 · 4 点 · 步行 2 / 乘车 2"])); expect(view.texts).toEqual(expect.arrayContaining(["用户 · 4 点 · 步行 2 / 乘车 2"]));
expect(view.elements.filter((element) => element.className.includes("map-ride-marker")).length).toBeGreaterThan(0); expect(view.elements.filter((element) => element.className.includes("map-ride-marker")).length).toBeGreaterThan(0);
expect(view.elements.filter((element) => element.className.includes("map-track-endpoint")).length).toBeGreaterThan(1); expect(view.elements.filter((element) => element.className.includes("map-track-endpoint")).length).toBeGreaterThan(1);
expect(view.texts).toContain("Mira · 4"); expect(view.texts).toContain("Mira · 4");
}); });
it("keeps the extracted icon catalog closed until an editor asks for it", () => { it("keeps the extracted icon catalog closed until an editor asks for it", () => {
@@ -337,8 +342,33 @@ describe("SCUM plugin feature module", () => {
expect(workflows.inputs.map((input) => input.label)).not.toContain("搜索生成物品"); expect(workflows.inputs.map((input) => input.label)).not.toContain("搜索生成物品");
}); });
it("focuses the live map on the user who was picked from the user list", () => {
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", pageQuery: { focus: "76561198000000001", from: "2026-08-09T23:00:00Z", to: "2026-08-10T01:00:00Z" } });
expect(view.texts).toContain("用户 1/1");
expect(view.inputs.find((input) => input.label === "轨迹开始时间")?.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
expect(view.inputs.find((input) => input.label === "轨迹结束时间")?.value).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
expect(pageSource).toContain('"lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt"');
expect(pageSource).toContain("scumMapDefaultWindowMs");
});
it("keeps only the selected user's tracks and the vehicles that user rode", () => {
const data: SCUMSurfaceData = { ...surfaceData, trajectories: [
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:01Z" },
{ subjectType: "player", subjectId: "76561198000000009", steamId: "76561198000000009", displayName: "Nova", x: 30, y: 40, z: 3, sampledAt: "2026-08-10T00:00:02Z" },
{ subjectType: "vehicle", subjectId: "veh-1", vehicleId: "veh-1", gameVehicleId: "veh-1", label: "Laika", className: "BPC_Laika_C", x: 400, y: 200, z: 0, sampledAt: "2026-08-10T00:00:03Z" },
{ subjectType: "vehicle", subjectId: "veh-2", vehicleId: "veh-2", gameVehicleId: "veh-2", label: "WolfsWagen", className: "BPC_WolfsWagen_C", x: 500, y: 300, z: 0, sampledAt: "2026-08-10T00:00:04Z" }
] };
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data, pageQuery: { ...mapTestWindow, focus: "76561198000000001" } });
expect(view.texts).toContain("Mira");
expect(view.texts).toContain("Laika");
expect(view.texts).not.toContain("Nova");
expect(view.texts).not.toContain("WolfsWagen");
const all = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data, pageQuery: mapTestWindow });
expect(all.texts).toEqual(expect.arrayContaining(["Mira", "Nova", "Laika", "WolfsWagen"]));
});
it("loads the vehicle icon for trajectory rows and gift items", () => { it("loads the vehicle icon for trajectory rows and gift items", () => {
expect(pageSource).toContain('trajectoryVehicleIcon(row) ? e("img", { className: "scum-catalog-icon"'); expect(pageSource).toContain("trajectoryVehicleIcon(middle.row)");
expect(pageSource).toContain("catalogIconStrip(e, giftCatalogIcons(gift))"); expect(pageSource).toContain("catalogIconStrip(e, giftCatalogIcons(gift))");
}); });
@@ -395,7 +425,7 @@ function scumActions(overrides: Partial<NonNullable<SCUMWorkspaceActions["scum"]
}; };
} }
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string } = {}) { function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string; pageQuery?: Record<string, string> } = {}) {
const nodes: string[] = []; const nodes: string[] = [];
const texts: string[] = []; const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = []; const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = [];
@@ -425,7 +455,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), scum: scumActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) }; const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), scum: scumActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) };
renderPluginPage(react, { renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] }, context: { pluginId: "game.scum", serverInstanceId: "server-1", pageQuery: options.pageQuery ?? {}, permissions: options.permissions ?? ["server.read", "server.remote.access"] },
availability: { available: true, features: [{ key: "player.intelligence", available: true }] }, availability: { available: true, features: [{ key: "player.intelligence", available: true }] },
workspaceActions: actions workspaceActions: actions
}); });