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

- Open 「用户轨迹」 straight on the live map instead of a drawer, carrying the
  server, the focused user and a start/end window through the plugin page hash.
- Default that window to one hour before the user's last seen time and keep the
  plugin page query available to bundles through the host context.
- Replace the time range preset gate with always visible start/end datetime
  fields plus a quick-range select, so the user list rebuilds from the window.
- Drop the bulky trajectory list and draw one compact colour legend under the
  map, keeping a stable colour per identity and restricting vehicle tracks to
  the vehicles the selected users actually rode.
This commit is contained in:
npc0-hue
2026-09-16 19:35:31 +08:00
parent 6488f6b31d
commit bc45188be9
13 changed files with 220 additions and 74 deletions
+1
View File
@@ -10,6 +10,7 @@ export interface PageParams {
serverId?: string;
pluginId?: string;
routeKey?: string;
pageQuery?: Record<string, string>;
}
export interface PageRoute {
+1
View File
@@ -121,6 +121,7 @@ export interface PluginBridgeHostContext {
pluginId: string;
routeKey: string;
serverInstanceId?: string;
pageQuery: Record<string, string>;
themeTokens: PluginBridgeThemeTokens;
permissions: PluginPermission[];
bridgeActions: PluginBridgeAction[];
+3 -1
View File
@@ -58,12 +58,14 @@ export function PluginPageHostPage({ params, onNavigate, initialPlugin, embedded
const declaredPage = readyPlugin?.pages.find((candidate) => candidate.key === routeKey);
const 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<PluginPageWorkspaceActions | undefined>(() => {
+8
View File
@@ -51,6 +51,14 @@ describe("console shell routes", () => {
expect(resolved.params).toEqual({ pluginId: "game.scum", routeKey: "operations", serverId: "server/scum-1" });
});
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");
+16 -2
View File
@@ -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<string, string> = {};
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 } : {})
}
};
}
+9 -2
View File
@@ -794,14 +794,21 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.scum-map-menu>summary{display:inline-flex;align-items:center;min-height:26px;padding:0 9px;font-size:11px;list-style:none;cursor:pointer}
.scum-map-menu>summary::-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}
@@ -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"],
+2
View File
@@ -13,6 +13,7 @@ export function createPluginBridgeHostContext(input: {
plugin: PluginBridgeManifestContract;
routeKey: string;
serverInstanceId?: string;
pageQuery?: Record<string, string>;
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,