From bc45188be96fdafe31497b80053b8e1fe34e2ad6 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Wed, 16 Sep 2026 19:35:31 +0800 Subject: [PATCH] Send user trajectories from the user list into a focused live map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- platform_web/contracts/page.ts | 1 + platform_web/contracts/pluginBridge.ts | 1 + platform_web/pages/PluginPageHostPage.tsx | 4 +- platform_web/routes/routes.test.ts | 8 + platform_web/routes/routes.ts | 18 +- platform_web/theme/base.css | 11 +- platform_web/utils/pluginBridgeHost.test.ts | 1 + platform_web/utils/pluginBridgeHost.ts | 2 + .../scum-server-plugin/features/page.ts | 194 +++++++++++++----- .../examples/scum-server-plugin/manifest.json | 2 +- .../scum-server-plugin/page-bundle/index.ts | 2 +- plugins/tests/manifest-validation.test.ts | 2 +- plugins/tests/scum-feature-module.test.ts | 48 ++++- 13 files changed, 220 insertions(+), 74 deletions(-) diff --git a/platform_web/contracts/page.ts b/platform_web/contracts/page.ts index c6271f9..c30a73f 100644 --- a/platform_web/contracts/page.ts +++ b/platform_web/contracts/page.ts @@ -10,6 +10,7 @@ export interface PageParams { serverId?: string; pluginId?: string; routeKey?: string; + pageQuery?: Record; } export interface PageRoute { diff --git a/platform_web/contracts/pluginBridge.ts b/platform_web/contracts/pluginBridge.ts index a3cf0f4..3740879 100644 --- a/platform_web/contracts/pluginBridge.ts +++ b/platform_web/contracts/pluginBridge.ts @@ -121,6 +121,7 @@ export interface PluginBridgeHostContext { pluginId: string; routeKey: string; serverInstanceId?: string; + pageQuery: Record; themeTokens: PluginBridgeThemeTokens; permissions: PluginPermission[]; bridgeActions: PluginBridgeAction[]; diff --git a/platform_web/pages/PluginPageHostPage.tsx b/platform_web/pages/PluginPageHostPage.tsx index 5d5d291..69f1e26 100644 --- a/platform_web/pages/PluginPageHostPage.tsx +++ b/platform_web/pages/PluginPageHostPage.tsx @@ -58,12 +58,14 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey); const manifestContract = useMemo(() => (readyPlugin ? pluginBridgeManifestContractFromResponse(readyPlugin) : undefined), [readyPlugin]); const declaredBundlePage = manifestContract?.pages.find((candidate) => candidate.key === routeKey); + const pageQuery = params.pageQuery; const hostContext = useMemo(() => manifestContract ? createPluginBridgeHostContext({ plugin: manifestContract, routeKey, serverInstanceId: serverId || undefined, + pageQuery, themeTokens: { colorScheme: "dark", accentColor: "#7dd3fc" } - }) : undefined, [manifestContract, routeKey, serverId]); + }) : undefined, [manifestContract, routeKey, serverId, pageQuery]); readyPluginRef.current = readyPlugin; hostContextRef.current = hostContext; const workspaceActions = useMemo(() => { diff --git a/platform_web/routes/routes.test.ts b/platform_web/routes/routes.test.ts index 8d6e44c..53212cc 100644 --- a/platform_web/routes/routes.test.ts +++ b/platform_web/routes/routes.test.ts @@ -51,6 +51,14 @@ describe("console shell routes", () => { 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", () => { expect(firstPartyRoutes.map((route) => route.id)).toContain("maintenance"); expect(firstPartyRoutes.map((route) => route.id)).toContain("profileSettings"); diff --git a/platform_web/routes/routes.ts b/platform_web/routes/routes.ts index ca931b6..e517448 100644 --- a/platform_web/routes/routes.ts +++ b/platform_web/routes/routes.ts @@ -110,6 +110,11 @@ export function hashForPage(pageId: PageId, params: PageParams = {}): string { if (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(); 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 } }; } 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 = {}; + for (const [key, value] of query.entries()) { + if (value) { + pageQuery[key] = value; + } + } return { route: routeForPage("pluginPage"), params: { pluginId: decodeURIComponent(segments[1]), routeKey: decodeURIComponent(segments[2]), - serverId + serverId, + ...(Object.keys(pageQuery).length ? { pageQuery } : {}) } }; } diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index c33baa9..77e712b 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -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::-webkit-details-marker{display:none} .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 .page-status{margin:0;font-size:11px;white-space:normal} .scum-map-bar{gap:6px} .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 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-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,.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} diff --git a/platform_web/utils/pluginBridgeHost.test.ts b/platform_web/utils/pluginBridgeHost.test.ts index 95c34b3..e2c3738 100644 --- a/platform_web/utils/pluginBridgeHost.test.ts +++ b/platform_web/utils/pluginBridgeHost.test.ts @@ -49,6 +49,7 @@ describe("plugin bridge host utilities", () => { pluginId: "game.example", routeKey: "logs", serverInstanceId: "server-1", + pageQuery: {}, themeTokens: { colorScheme: "dark", accentColor: "#22c55e" }, permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"], bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"], diff --git a/platform_web/utils/pluginBridgeHost.ts b/platform_web/utils/pluginBridgeHost.ts index f07a5ab..b7c7179 100644 --- a/platform_web/utils/pluginBridgeHost.ts +++ b/platform_web/utils/pluginBridgeHost.ts @@ -13,6 +13,7 @@ export function createPluginBridgeHostContext(input: { plugin: PluginBridgeManifestContract; routeKey: string; serverInstanceId?: string; + pageQuery?: Record; themeTokens: PluginBridgeThemeTokens; }): PluginBridgeHostContext { const page = input.plugin.pages.find((candidate) => candidate.key === input.routeKey); @@ -26,6 +27,7 @@ export function createPluginBridgeHostContext(input: { pluginId: input.plugin.id, routeKey: input.routeKey, serverInstanceId: input.serverInstanceId, + pageQuery: { ...(input.pageQuery ?? {}) }, themeTokens: input.themeTokens, permissions, bridgeActions, diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index c6aa5bd..06faa78 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -27,7 +27,7 @@ type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; type ScumMapLayer = "terrain" | "topo" | "night"; 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 MapViewState = { zoom: number; x: number; y: number }; type MapPlaybackState = { playing: boolean; value: number; speed: number }; @@ -50,9 +50,11 @@ const scumMapSegmentGapMs = 15 * 60 * 1000; const scumMapSegmentJump = 52000; const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 }; const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1 }; -const scumMapTimePresets: ReadonlyArray = [["all", "全部时段"], ["15m", "最近 15 分钟"], ["1h", "最近 1 小时"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["custom", "自定义起止"]]; +const scumMapTimePresets: ReadonlyArray = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["custom", "自定义起止"]]; const scumMapPresetMs: Record = { "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 scumMapTrackColorCache = new Map(); +const scumMapDefaultWindowMs = 60 * 60 * 1000; const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false }; export type ReactLike = { @@ -65,7 +67,9 @@ export type ReactLike = { export type SCUMPageContext = { pageKey?: string; pageTitle?: string; + pluginId?: string; serverInstanceId?: string; + pageQuery?: Record; permissions: string[]; availability: { 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) { 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(react, { status: "loading" }); const [action, setAction] = usePluginState(react, { status: "idle" }); const [playerSearch, setPlayerSearch] = usePluginState(react, ""); @@ -122,10 +130,10 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [mapLayers, setMapLayers] = usePluginState>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); const [mapView, setMapView] = usePluginState(react, scumMapDefaultView); - const [mapTimePreset, setMapTimePreset] = usePluginState(react, "all"); - const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, ""); - const [mapTimeTo, setMapTimeTo] = usePluginState(react, ""); - const [mapUsers, setMapUsers] = usePluginState(react, undefined); + const [mapTimePreset, setMapTimePreset] = usePluginState(react, pageQuery.from || pageQuery.to ? "custom" : "1h"); + const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, localDateTimeValue(defaultMapRange.from)); + const [mapTimeTo, setMapTimeTo] = usePluginState(react, localDateTimeValue(defaultMapRange.to)); + const [mapUsers, setMapUsers] = usePluginState(react, focusUser ? [focusUser] : undefined); const [mapTrackKey, setMapTrackKey] = usePluginState(react, ""); const [mapPlayback, setMapPlayback] = usePluginState(react, scumMapPlaybackIdle); const mapViewportRef = react.useRef ? react.useRef(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); return () => { clearInterval(timer); }; }, [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; 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", { 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", { 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" }, e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"), 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("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("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) { const kind = view.playerPanel.kind; const name = textField(player, "displayName", "playerName", "name") || playerKey(player); 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 title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹"; + const content = kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : playerHistoryPanel(e, player); + const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : "登录历史"; 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("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" }, "没有该用户的真实登录历史。"))); } -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 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"); } @@ -575,7 +614,10 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones")); const window = mapTimeWindow(view); const catalog = mapVehicleClassIndex(data); - const tracks = mapTracks(data, bounds, window, view, catalog); + const users = mapTrackUsers(data, window); + const playerLabels = new Map(); + 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 cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value; 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) : []; return e("div", { className: "console-record-list scum-map-surface" }, 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)))), - view.mapTimePreset === "custom" ? labeledField(e, "起", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹起始时间", onChange: (event: InputEvent) => view.setMapTimeFrom(inputValue(event)) })) : null, - view.mapTimePreset === "custom" ? labeledField(e, "止", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => view.setMapTimeTo(inputValue(event)) })) : null, - mapUserMenu(e, view, mapTrackUsers(data, window)), + 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)))), + labeledField(e, "开始", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹开始时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeFrom(inputValue(event)); } })), + 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, users, playerLabels), 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)))), 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), - mapTrackList(e, view, tracks, catalog), + mapTrackLegend(e, view, tracks, playerLabels), 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: "" }) : ""); } -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) { 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": "轨迹用户筛选" }, e("summary", { className: "icon-command" }, `用户 ${summary}`), 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(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) { + 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) { const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[]; 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) { - 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) { const shapes: unknown[] = []; 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; const middle = visible[Math.floor(visible.length / 2)]; 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; 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 { 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 { - if (view.mapTimePreset === "custom") { - const from = Date.parse(view.mapTimeFrom); - const to = Date.parse(view.mapTimeTo); - const safeFrom = Number.isNaN(from) ? -Infinity : from; - const safeTo = Number.isNaN(to) ? Infinity : to; - return { from: safeFrom, to: safeTo, label: `${timeLabel(safeFrom)} – ${timeLabel(safeTo)}` }; - } - 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 }; + const from = parseTimeInput(view.mapTimeFrom); + const to = parseTimeInput(view.mapTimeTo); + if (from === undefined && to === undefined) return { from: -Infinity, to: Infinity, label: "全部时段" }; + const lower = from ?? -Infinity; + const upper = to ?? Infinity; + return { from: lower, to: upper, label: `${shortTimeLabel(lower)} – ${shortTimeLabel(upper)}` }; } -function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, view: ViewState, catalog: Map): 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, setFrom: StateSetter, setTo: StateSetter, 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): MapTrack[] { const grouped = new Map(); - 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(); + 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); if (!hasCoordinates(position)) continue; 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 (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); - if (time < window.from || time > window.to) continue; const scene = mapScenePoint(row, bounds); const rideCode = mapRideCode(row); 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) }); if (rideCode && !track.vehicles.includes(rideCode)) track.vehicles.push(rideCode); 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); } +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 }> { const users = new Map(); for (const row of data.trajectories) { if (layerOf(row) === "vehicles") continue; - const identity = textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); + const identity = mapPlayerTrackIdentity(row); if (!identity) continue; const time = trajectoryOrder(row); 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 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 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[]) { const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : ""; diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index ebb210a..3f73ac8 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -3,7 +3,7 @@ "id": "game.scum", "name": "SCUM Server", "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", "tags": [ "scum", diff --git a/plugins/examples/scum-server-plugin/page-bundle/index.ts b/plugins/examples/scum-server-plugin/page-bundle/index.ts index 35e5cbf..c5b4625 100644 --- a/plugins/examples/scum-server-plugin/page-bundle/index.ts +++ b/plugins/examples/scum-server-plugin/page-bundle/index.ts @@ -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 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 }); } diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 42e57b7..d73616d 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -357,7 +357,7 @@ describe("plugin manifest validation", () => { const serialized = JSON.stringify(manifest).toLowerCase(); 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(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([ diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index c0a43c1..084540b 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -14,6 +14,8 @@ const featureRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../example const pageSource = readFileSync(resolve(featureRoot, "page.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 = { 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" } }], @@ -202,6 +204,8 @@ describe("SCUM plugin feature module", () => { 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)).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(pageSource).toContain("console-panel scum-workbench"); 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", () => { 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).not.toContain("remote.run.db.sqlite.execute"); expect(source).not.toContain("scum-database"); @@ -283,7 +287,7 @@ describe("SCUM plugin feature module", () => { const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" }); 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("筛选地图点"); for (const layer of ["用户", "载具", "旗帜", "区域", "其他"]) expect(view.texts).toContain(layer); 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); for (const layer of ["地形", "等高线", "夜间", "建筑区"]) expect(view.texts).toContain(layer); 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.texts).not.toContain("地图范围与图层"); expect(view.texts).not.toContain("自定义范围"); 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(pageSource).toContain('new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href'); 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: 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.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-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", () => { @@ -337,8 +342,33 @@ describe("SCUM plugin feature module", () => { 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", () => { - expect(pageSource).toContain('trajectoryVehicleIcon(row) ? e("img", { className: "scum-catalog-icon"'); + expect(pageSource).toContain("trajectoryVehicleIcon(middle.row)"); expect(pageSource).toContain("catalogIconStrip(e, giftCatalogIcons(gift))"); }); @@ -395,7 +425,7 @@ function scumActions(overrides: Partial } = {}) { const nodes: string[] = []; const texts: string[] = []; 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" } }) }; renderPluginPage(react, { 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 }] }, workspaceActions: actions });