Give the SCUM live map the full width and add catalog icon pickers
- Drop the live-map stat strip and the half-width detail column: the map board now spans the row, is taller, and the selected point renders as a compact strip under it. - Fit the map filters into a single row with small layer chips, and switch the board between terrain, topo and night base layers plus the extracted building-zone overlay. - Add a searchable SCUM catalog picker with icons for gift items and event produces, show gift item and vehicle icons in the lists, and keep the picker grid closed until it is opened so no icon is requested up front.
This commit is contained in:
@@ -19,15 +19,20 @@ import {
|
||||
type SCUMSurfaceData,
|
||||
type SCUMWorkspaceActions
|
||||
} from "./page-data.js";
|
||||
import { scumVehicleIconUrl } from "./scum-catalog.js";
|
||||
import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js";
|
||||
|
||||
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
|
||||
type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void };
|
||||
type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
|
||||
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
|
||||
type ScumMapLayer = "terrain" | "topo" | "night";
|
||||
type CatalogPickTarget = "gift-items" | "produce-item";
|
||||
type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory";
|
||||
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
|
||||
const scumMapBackground = new URL("../assets/map/scum-map-terrain-4096.webp", import.meta.url).href;
|
||||
const scumMapLayerImages = { terrain: new URL("../assets/map/scum-map-terrain-4096.webp", import.meta.url).href, topo: new URL("../assets/map/scum-map-topo-4096.webp", import.meta.url).href, night: new URL("../assets/map/scum-map-night-4096.webp", import.meta.url).href, zones: new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href } as const;
|
||||
const scumMapBaseLayers: ReadonlyArray<readonly [ScumMapLayer, string]> = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]];
|
||||
const scumCatalogKinds: ReadonlyArray<readonly [string, string]> = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]];
|
||||
const scumCatalogPickLimit = 48;
|
||||
const scumMapSize = 4096;
|
||||
const scumSurfaceRefreshMs = 15000;
|
||||
|
||||
@@ -106,6 +111,11 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false);
|
||||
const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false);
|
||||
const [mapSettingsOpen, setMapSettingsOpen] = usePluginState(react, false);
|
||||
const [mapBaseLayer, setMapBaseLayer] = usePluginState<ScumMapLayer | undefined>(react, undefined);
|
||||
const [mapZonesOverlay, setMapZonesOverlay] = usePluginState<boolean | undefined>(react, undefined);
|
||||
const [catalogSearch, setCatalogSearch] = usePluginState(react, "");
|
||||
const [catalogKind, setCatalogKind] = usePluginState(react, "all");
|
||||
const [catalogOpen, setCatalogOpen] = usePluginState(react, false);
|
||||
const [refreshSignal, setRefreshSignal] = usePluginState(react, 0);
|
||||
const pageKey = input.pageKey ?? "players";
|
||||
if (react.useEffect) react.useEffect(() => {
|
||||
@@ -146,6 +156,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint,
|
||||
mapCustomEnabled, setMapCustomEnabled, mapCenterX, setMapCenterX, mapCenterY, setMapCenterY, mapWidthKm, setMapWidthKm, mapHeightKm, setMapHeightKm,
|
||||
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen, mapSettingsOpen, setMapSettingsOpen,
|
||||
mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen,
|
||||
setAction, refresh: () => setRefreshSignal((value) => value + 1)
|
||||
}) : null
|
||||
);
|
||||
@@ -171,6 +182,8 @@ type ViewState = {
|
||||
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
|
||||
mapCustomEnabled: boolean | undefined; setMapCustomEnabled: StateSetter<boolean | undefined>; mapCenterX: string; setMapCenterX: StateSetter<string>; mapCenterY: string; setMapCenterY: StateSetter<string>; mapWidthKm: string; setMapWidthKm: StateSetter<string>; mapHeightKm: string; setMapHeightKm: StateSetter<string>;
|
||||
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter<boolean>; mapSettingsOpen: boolean; setMapSettingsOpen: StateSetter<boolean>;
|
||||
mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter<ScumMapLayer | undefined>; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter<boolean | undefined>;
|
||||
catalogSearch: string; setCatalogSearch: StateSetter<string>; catalogKind: string; setCatalogKind: StateSetter<string>; catalogOpen: boolean; setCatalogOpen: StateSetter<boolean>;
|
||||
};
|
||||
|
||||
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||||
@@ -291,7 +304,7 @@ function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap) {
|
||||
|
||||
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", null, ride ? `乘坐 ${ride}` : textField(row, "source") || "平台轨迹表")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
|
||||
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"))); }
|
||||
@@ -394,6 +407,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
|
||||
labeledField(e, "物品编号", e("input", { value: view.produceTradeGoodsId, "aria-label": "生成物品编号", placeholder: "TradeGoods ID", onChange: (event: InputEvent) => view.setProduceTradeGoodsId(inputValue(event)) })),
|
||||
labeledField(e, "概率 (%)", e("input", { value: view.producePercent, "aria-label": "生成概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setProducePercent(inputValue(event)) })),
|
||||
labeledField(e, "数量", e("input", { value: view.produceValue, "aria-label": "生成数量", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setProduceValue(inputValue(event)) }))),
|
||||
catalogPicker(e, view, "produce-item"),
|
||||
e("div", { className: "console-row-actions" },
|
||||
labeledField(e, "半径", e("input", { value: view.produceRadius, "aria-label": "生成半径", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceRadius(inputValue(event)) })),
|
||||
labeledField(e, "X", e("input", { value: view.produceX, "aria-label": "生成 X", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceX(inputValue(event)) })),
|
||||
@@ -401,6 +415,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
|
||||
labeledField(e, "Z", e("input", { value: view.produceZ, "aria-label": "生成 Z", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceZ(inputValue(event)) })),
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveProduce }, "保存生成项")),
|
||||
e("div", { className: "console-row-list" }, data.eventProduces.length ? data.eventProduces.map((produce, index) => e("div", { key: idOf(produce, `produce-${index}`), className: "console-row" },
|
||||
catalogIconStrip(e, catalogIconsForValue(textField(produce, "tradeGoodsId", "trade_goods_id"))),
|
||||
e("span", null, `${textField(produce, "eventId", "event")} / ${tradeGoodsLabel(data.tradeGoods, textField(produce, "tradeGoodsId", "trade_goods_id"))}`),
|
||||
e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`),
|
||||
e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`),
|
||||
@@ -474,6 +489,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
|
||||
labeledField(e, "发放次数", e("input", { value: view.giftNumber, "aria-label": "发放次数", type: "number", placeholder: "1", onChange: (event: InputEvent) => view.setGiftNumber(inputValue(event)) })),
|
||||
e("div", { className: "console-row-actions" }, labeledField(e, "成就类型", e("input", { value: view.giftAchievement, "aria-label": "成就类型", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievement(inputValue(event)) })), labeledField(e, "成就值", e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) }))),
|
||||
labeledField(e, "礼包物品", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "SCUM 目录代码:数量", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) })),
|
||||
catalogPicker(e, view, "gift-items"),
|
||||
labeledField(e, "礼包命令", e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) })),
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包")
|
||||
),
|
||||
@@ -483,6 +499,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(gift, "name") || key), e("span", { className: "status-pill status-active" }, textField(gift, "status") || "active")),
|
||||
e("div", { className: "console-record-meta" }, e("span", null, `周期 ${giftClassLabel(numField(gift, "class"))}`), e("span", null, `适用 ${textField(gift, "audience") || "all"}`), e("span", null, `次数 ${numField(gift, "number")}`), e("span", null, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)),
|
||||
e("span", { className: "provider-id" }, giftItemsSummary(gift)),
|
||||
catalogIconStrip(e, giftCatalogIcons(gift)),
|
||||
e("span", { className: "provider-id" }, giftCommandsSummary(gift)),
|
||||
e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftEditorOpen(true); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除"))
|
||||
);
|
||||
@@ -511,7 +528,6 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
|
||||
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||||
const actions = input.workspaceActions;
|
||||
const points = collectMapPoints(data);
|
||||
const vehicleCatalog = data.tradeGoods.filter((item) => isVehicleCatalogItem(item));
|
||||
const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0];
|
||||
const bounds = resolveMapBounds(settings);
|
||||
const customEnabled = view.mapCustomEnabled ?? Boolean(settings && boolField(settings, "customMapEnabled"));
|
||||
@@ -519,6 +535,8 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
|
||||
const centerY = view.mapCenterY || textField(settings, "centerY", "mapY") || String((bounds.worldMinY + bounds.worldMaxY) / 2);
|
||||
const widthKm = view.mapWidthKm || textField(settings, "widthKm", "mapWidth") || String((bounds.worldMaxX - bounds.worldMinX) / 100000);
|
||||
const heightKm = view.mapHeightKm || textField(settings, "heightKm", "mapHeight") || String((bounds.worldMaxY - bounds.worldMinY) / 100000);
|
||||
const baseLayer = view.mapBaseLayer ?? mapLayerSetting(settings);
|
||||
const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones"));
|
||||
const search = view.mapSearch.trim().toLowerCase();
|
||||
const visible = points.filter((point) => view.mapLayers[layerOf(point)] && matchesText(point, search, "name", "label", "subjectId", "subjectType", "layer"));
|
||||
const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0];
|
||||
@@ -526,24 +544,100 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
|
||||
const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : [];
|
||||
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" },
|
||||
statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["载具目录", vehicleCatalog.length]]),
|
||||
e("div", { className: "resource-filter-bar scum-filter-bar" },
|
||||
labeledField(e, "筛选地图点", e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) })),
|
||||
(["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer)))
|
||||
e("div", { className: "scum-layer-group", role: "group", "aria-label": "地图图层开关" }, (["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer)))),
|
||||
e("div", { className: "scum-layer-group", role: "group", "aria-label": "地图底图" },
|
||||
scumMapBaseLayers.map(([key, label]) => e("button", { key, type: "button", className: baseLayer === key ? "icon-command is-active" : "icon-command", "aria-pressed": baseLayer === key, onClick: () => view.setMapBaseLayer(key) }, label)),
|
||||
e("label", { className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: zonesOverlay, onChange: (event: InputEvent) => view.setMapZonesOverlay(Boolean(event.target?.checked)) }), "建筑区")),
|
||||
e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个可见点`)
|
||||
),
|
||||
e("details", { className: "console-module scum-editor", open: view.mapSettingsOpen, onToggle: (event: InputEvent) => view.setMapSettingsOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "地图范围"), e("span", { className: "page-status" }, customEnabled ? "自定义范围" : "SCUM 默认范围")),
|
||||
e("details", { className: "console-module scum-editor scum-map-settings", open: view.mapSettingsOpen, onToggle: (event: InputEvent) => view.setMapSettingsOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "地图范围与图层"), e("span", { className: "page-status" }, `${mapLayerLabel(baseLayer)}${zonesOverlay ? " + 建筑区" : ""} · ${customEnabled ? "自定义范围" : "SCUM 默认范围"}`)),
|
||||
e("div", { className: "console-row-actions" },
|
||||
e("label", null, e("input", { type: "checkbox", checked: customEnabled, "aria-label": "启用自定义地图", onChange: (event: InputEvent) => view.setMapCustomEnabled(Boolean(event.target?.checked)) }), "启用自定义地图"),
|
||||
labeledField(e, "中心 X", e("input", { value: centerX, "aria-label": "地图中心 X", type: "number", onChange: (event: InputEvent) => view.setMapCenterX(inputValue(event)) })),
|
||||
labeledField(e, "中心 Y", e("input", { value: centerY, "aria-label": "地图中心 Y", type: "number", onChange: (event: InputEvent) => view.setMapCenterY(inputValue(event)) })),
|
||||
labeledField(e, "宽度 (公里)", e("input", { value: widthKm, "aria-label": "地图宽度公里", type: "number", onChange: (event: InputEvent) => view.setMapWidthKm(inputValue(event)) })),
|
||||
labeledField(e, "高度 (公里)", e("input", { value: heightKm, "aria-label": "地图高度公里", type: "number", onChange: (event: InputEvent) => view.setMapHeightKm(inputValue(event)) })),
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24) }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围"))
|
||||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24), baseLayer, buildingZones: zonesOverlay }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围"))
|
||||
),
|
||||
e("div", { className: "overview-two-col" },
|
||||
e("div", { className: "map-surface-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; return e("button", { key: idOf(point, `point-${index}`), 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: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })),
|
||||
e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, "地图点详情"), e("span", { className: "page-status" }, `${visible.length} 个可见点`)), selected ? e("div", { className: "console-record" }, e("strong", null, pointTitle(selected)), e("span", { className: "status-pill status-active" }, layerLabel(layerOf(selected))), e("span", { className: "provider-id" }, coords(selected)), e("div", { className: "console-record-meta" }, e("span", null, `ID ${textField(selected, "subjectId", "id", "_recordKey") || "unknown"}`), e("span", null, `来源 ${textField(selected, "source") || "平台表"}`), e("span", null, freshness(selected))), layerOf(selected) === "vehicles" ? e("div", { className: "console-record-meta" }, e("span", null, `类型 ${textField(selected, "className", "vehicleClass", "vehicleType") || "unknown"}`), e("span", null, `状态 ${textField(selected, "status", "state", "isFunctional") || "unknown"}`), e("span", null, `锁 ${boolField(selected, "locked") ? "已上锁" : "未上锁/未知"}`), e("span", null, `访问 ${dateField(selected, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`)) : null, selectedLocks.length ? e("div", { className: "console-row-list" }, selectedLocks.map((row, index) => e("div", { key: `selected-lock-${index}`, className: "console-row" }, e("span", null, dateField(row, "lockedAt", "createdAt")), e("strong", null, textField(row, "scumUserId") || "unknown"), e("strong", null, textField(row, "steamId") || "unknown")))) : null, selectedTrails.length ? e("div", { className: "console-row-list" }, selectedTrails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "平台轨迹表")))) : null) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。"))
|
||||
)
|
||||
e("div", { className: "map-surface-board", "aria-label": "SCUM 地图图层", style: mapBoardStyle(baseLayer, zonesOverlay) }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; return e("button", { key: idOf(point, `point-${index}`), 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: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })),
|
||||
selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null
|
||||
);
|
||||
}
|
||||
|
||||
function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[]) {
|
||||
const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : "";
|
||||
return e("article", { className: "console-module map-point-strip" },
|
||||
e("div", { className: "map-point-strip-head" },
|
||||
e("h2", null, "地图点详情"),
|
||||
icon ? e("img", { className: "scum-catalog-icon", src: icon, alt: "" }) : null,
|
||||
e("strong", null, pointTitle(point)),
|
||||
e("span", { className: "status-pill status-active" }, layerLabel(layerOf(point))),
|
||||
e("span", { className: "provider-id" }, coords(point)),
|
||||
e("span", { className: "provider-id" }, `ID ${textField(point, "subjectId", "id", "_recordKey") || "unknown"}`),
|
||||
e("span", { className: "provider-id" }, `来源 ${textField(point, "source") || "平台表"}`),
|
||||
e("span", { className: "provider-id" }, freshness(point)),
|
||||
layerOf(point) === "vehicles" ? e("span", { className: "provider-id" }, `${textField(point, "className", "vehicleClass", "vehicleType") || "unknown"} · ${textField(point, "status", "state", "isFunctional") || "unknown"} · ${boolField(point, "locked") ? "已上锁" : "未上锁/未知"} · 访问 ${dateField(point, "lastAccessTime", "vehicleObservedAt", "sampledAt")}`) : null),
|
||||
locks.length ? e("div", { className: "console-row-list" }, locks.map((row, index) => e("div", { key: `selected-lock-${index}`, className: "console-row" }, e("span", null, dateField(row, "lockedAt", "createdAt")), e("strong", null, textField(row, "scumUserId") || "unknown"), e("strong", null, textField(row, "steamId") || "unknown")))) : null,
|
||||
trails.length ? e("div", { className: "console-row-list" }, trails.map((row, index) => e("div", { key: `selected-trail-${index}`, className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt")), e("strong", null, coords(row)), e("strong", null, textField(row, "source") || "平台轨迹表")))) : null
|
||||
);
|
||||
}
|
||||
|
||||
function mapBoardStyle(base: ScumMapLayer, zones: boolean): Record<string, string> {
|
||||
const layers = [zones ? `url(${scumMapLayerImages.zones})` : "", `url(${scumMapLayerImages[base]})`].filter(Boolean);
|
||||
return { backgroundImage: layers.join(", ") };
|
||||
}
|
||||
|
||||
function mapLayerSetting(settings: RecordMap | undefined): ScumMapLayer {
|
||||
const value = textField(settings, "baseLayer", "base_layer").toLowerCase();
|
||||
return value === "topo" || value === "night" ? value : "terrain";
|
||||
}
|
||||
|
||||
function mapLayerLabel(layer: ScumMapLayer): string { return layer === "topo" ? "等高线" : layer === "night" ? "夜间" : "地形"; }
|
||||
|
||||
function catalogMatches(query: string, kind: string): ScumCatalogEntry[] {
|
||||
const search = query.trim().toLowerCase();
|
||||
return scumCatalogEntries.filter((entry) => (kind === "all" || entry.kind === kind) && (!search || entry.name.toLowerCase().includes(search) || entry.code.toLowerCase().includes(search) || entry.command.toLowerCase().includes(search))).slice(0, scumCatalogPickLimit);
|
||||
}
|
||||
|
||||
function appendCatalogItem(current: string, code: string): string {
|
||||
const parts = current.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
const index = parts.findIndex((part) => part.split(":")[0]?.trim().toLowerCase() === code.toLowerCase());
|
||||
if (index >= 0) { const [key, quantity] = parts[index].split(":"); parts[index] = `${key.trim()}:${Math.max(1, Number(quantity) || 1) + 1}`; }
|
||||
else parts.push(`${code}:1`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function catalogIconsForValue(value: string): string[] {
|
||||
const entry = scumCatalogEntryFor(value);
|
||||
const url = entry ? scumCatalogIconUrl(entry.icon) : "";
|
||||
return url ? [url] : [];
|
||||
}
|
||||
|
||||
function catalogIconStrip(e: ReactLike["createElement"], icons: string[]) {
|
||||
return icons.length ? e("span", { className: "scum-catalog-icons" }, icons.map((url, index) => e("img", { key: `${url}-${index}`, className: "scum-catalog-icon", src: url, alt: "" }))) : null;
|
||||
}
|
||||
|
||||
function giftCatalogIcons(gift: RecordMap): string[] {
|
||||
const items = field(gift, "items");
|
||||
return Array.isArray(items) ? items.flatMap((item) => isRecord(item) ? catalogIconsForValue(textField(item, "catalogCode", "catalogItemKey", "key", "className")) : []).slice(0, 12) : [];
|
||||
}
|
||||
|
||||
function trajectoryVehicleIcon(row: RecordMap): string {
|
||||
const code = textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId");
|
||||
return code ? scumVehicleIconUrl(code) : "";
|
||||
}
|
||||
|
||||
function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) {
|
||||
const matches = catalogMatches(view.catalogSearch, view.catalogKind);
|
||||
return e("details", { className: "console-module scum-editor scum-catalog-picker", open: view.catalogOpen, onToggle: (event: InputEvent) => view.setCatalogOpen(detailOpen(event)) },
|
||||
e("summary", null, e("strong", null, target === "gift-items" ? "从 SCUM 目录选择礼包物品" : "从 SCUM 目录选择物品"), e("span", { className: "page-status" }, "图标 / 名称 / 代码")),
|
||||
view.catalogOpen ? e("div", { className: "scum-catalog-picker-body" },
|
||||
e("input", { type: "search", value: view.catalogSearch, "aria-label": target === "gift-items" ? "搜索礼包物品" : "搜索生成物品", placeholder: "名称 / 代码,例如 Laika", onChange: (event: InputEvent) => view.setCatalogSearch(inputValue(event)) }),
|
||||
e("div", { className: "scum-catalog-kinds", role: "group", "aria-label": "目录类型" }, scumCatalogKinds.map(([key, label]) => e("button", { key, type: "button", className: view.catalogKind === key ? "icon-command is-active" : "icon-command", "aria-pressed": view.catalogKind === key, onClick: () => view.setCatalogKind(key) }, label))),
|
||||
e("div", { className: "scum-catalog-grid" }, matches.length ? matches.map((entry) => { const icon = scumCatalogIconUrl(entry.icon); return e("button", { key: entry.code, type: "button", className: "scum-catalog-chip", title: `${entry.name} · ${entry.command}`, onClick: () => target === "gift-items" ? view.setGiftItems(appendCatalogItem(view.giftItems, entry.code)) : view.setProduceTradeGoodsId(entry.code) }, icon ? e("img", { className: "scum-catalog-icon", src: icon, alt: "" }) : e("span", { className: "scum-catalog-icon scum-catalog-icon-empty" }), e("span", { className: "scum-catalog-meta" }, e("strong", null, entry.name), e("span", { className: "provider-id" }, entry.code))); }) : e("p", { className: "page-status" }, "没有匹配的目录条目。"))
|
||||
) : null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user