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:
@@ -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 {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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>(() => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 } : {})
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user