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

- Open 「用户轨迹」 straight on the live map instead of a drawer, carrying the
  server, the focused user and a start/end window through the plugin page hash.
- Default that window to one hour before the user's last seen time and keep the
  plugin page query available to bundles through the host context.
- Replace the time range preset gate with always visible start/end datetime
  fields plus a quick-range select, so the user list rebuilds from the window.
- Drop the bulky trajectory list and draw one compact colour legend under the
  map, keeping a stable colour per identity and restricting vehicle tracks to
  the vehicles the selected users actually rode.
This commit is contained in:
npc0-hue
2026-09-16 19:35:31 +08:00
parent 6488f6b31d
commit bc45188be9
13 changed files with 220 additions and 74 deletions
+39 -9
View File
@@ -14,6 +14,8 @@ const featureRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../example
const pageSource = readFileSync(resolve(featureRoot, "page.ts"), "utf8");
const 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<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 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
});