601 lines
70 KiB
TypeScript
601 lines
70 KiB
TypeScript
import {
|
||
deleteGiftDefinition,
|
||
deleteEventDefinition,
|
||
deleteEventProduce,
|
||
emptySCUMSurfaceData,
|
||
loadSCUMSurface,
|
||
parseGiftItems,
|
||
parseGiftCommands,
|
||
playerAttributeDrafts,
|
||
playerAttributeSqlPreview,
|
||
queuePlayerAttributePatch,
|
||
queueGiftDelivery,
|
||
resetGiftClaim,
|
||
resetPendingGift,
|
||
resolveMapBounds,
|
||
saveEventDefinition,
|
||
saveEventProduce,
|
||
saveGiftDefinition,
|
||
saveMapSettings,
|
||
startEvent,
|
||
type RecordMap,
|
||
type SCUMSurfaceData,
|
||
type SCUMWorkspaceActions
|
||
} from "./page-data.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 PlayerPanelKind = "closed" | "attributes" | "gifts" | "items" | "history" | "trajectory";
|
||
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
|
||
type AttributeDraft = { fieldKey: string; label: string; before: string; after: string };
|
||
const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
|
||
|
||
export type ReactLike = {
|
||
createElement: (...args: any[]) => any;
|
||
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
|
||
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
|
||
};
|
||
|
||
export type SCUMPageContext = {
|
||
pageKey?: string;
|
||
pageTitle?: string;
|
||
serverInstanceId?: string;
|
||
permissions: string[];
|
||
availability: { available: boolean; reason?: string };
|
||
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
|
||
workspaceActions?: SCUMWorkspaceActions;
|
||
};
|
||
|
||
type DataState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: SCUMSurfaceData };
|
||
type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: string };
|
||
|
||
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
|
||
const e = react.createElement;
|
||
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
|
||
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
|
||
const [playerSearch, setPlayerSearch] = usePluginState(react, "");
|
||
const [playerStatus, setPlayerStatus] = usePluginState(react, "all");
|
||
const [playerPanel, setPlayerPanel] = usePluginState<PlayerPanelState>(react, { kind: "closed", playerId: "" });
|
||
const [attributeDrafts, setAttributeDrafts] = usePluginState<AttributeDraft[]>(react, []);
|
||
const [squadSearch, setSquadSearch] = usePluginState(react, "");
|
||
const [selectedSquadId, setSelectedSquadId] = usePluginState(react, "");
|
||
const [activityStatus, setActivityStatus] = usePluginState(react, "all");
|
||
const [eventId, setEventId] = usePluginState(react, "");
|
||
const [eventName, setEventName] = usePluginState(react, "");
|
||
const [eventType, setEventType] = usePluginState(react, "range");
|
||
const [eventSchedule, setEventSchedule] = usePluginState(react, "");
|
||
const [eventClass, setEventClass] = usePluginState(react, "1");
|
||
const [eventPlacard, setEventPlacard] = usePluginState(react, "");
|
||
const [eventPercent, setEventPercent] = usePluginState(react, "100");
|
||
const [eventDuration, setEventDuration] = usePluginState(react, "1800");
|
||
const [eventNpc, setEventNpc] = usePluginState(react, "0");
|
||
const [eventItem, setEventItem] = usePluginState(react, "0");
|
||
const [eventZombie, setEventZombie] = usePluginState(react, "0");
|
||
const [eventAnimal, setEventAnimal] = usePluginState(react, "0");
|
||
const [produceEventId, setProduceEventId] = usePluginState(react, "");
|
||
const [produceId, setProduceId] = usePluginState(react, "");
|
||
const [produceTradeGoodsId, setProduceTradeGoodsId] = usePluginState(react, "");
|
||
const [producePercent, setProducePercent] = usePluginState(react, "100");
|
||
const [produceValue, setProduceValue] = usePluginState(react, "1");
|
||
const [produceRadius, setProduceRadius] = usePluginState(react, "0");
|
||
const [produceX, setProduceX] = usePluginState(react, "0");
|
||
const [produceY, setProduceY] = usePluginState(react, "0");
|
||
const [produceZ, setProduceZ] = usePluginState(react, "0");
|
||
const [giftTab, setGiftTab] = usePluginState<GiftTab>(react, "definitions");
|
||
const [giftCode, setGiftCode] = usePluginState(react, "");
|
||
const [giftName, setGiftName] = usePluginState(react, "");
|
||
const [giftItems, setGiftItems] = usePluginState(react, "");
|
||
const [giftCommands, setGiftCommands] = usePluginState(react, "");
|
||
const [giftClass, setGiftClass] = usePluginState(react, "5");
|
||
const [giftAudience, setGiftAudience] = usePluginState(react, "all");
|
||
const [giftNumber, setGiftNumber] = usePluginState(react, "1");
|
||
const [giftAchievement, setGiftAchievement] = usePluginState(react, "0");
|
||
const [giftAchievementNumber, setGiftAchievementNumber] = usePluginState(react, "0");
|
||
const [deliveryGift, setDeliveryGift] = usePluginState(react, "");
|
||
const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, "");
|
||
const [mapSearch, setMapSearch] = usePluginState(react, "");
|
||
const [mapLayers, setMapLayers] = usePluginState<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
|
||
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
|
||
const [mapCustomEnabled, setMapCustomEnabled] = usePluginState<boolean | undefined>(react, undefined);
|
||
const [mapCenterX, setMapCenterX] = usePluginState(react, "");
|
||
const [mapCenterY, setMapCenterY] = usePluginState(react, "");
|
||
const [mapWidthKm, setMapWidthKm] = usePluginState(react, "");
|
||
const [mapHeightKm, setMapHeightKm] = usePluginState(react, "");
|
||
const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false);
|
||
const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false);
|
||
const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false);
|
||
const [mapSettingsOpen, setMapSettingsOpen] = usePluginState(react, false);
|
||
const pageKey = input.pageKey ?? "players";
|
||
|
||
const refresh = () => {
|
||
if (!input.serverInstanceId || !input.workspaceActions?.pluginData) {
|
||
setState({ status: "error", reason: "插件页面没有绑定服务器或通用 pluginData 能力。" });
|
||
return;
|
||
}
|
||
void loadSCUMSurface(input.workspaceActions, pageKey)
|
||
.then((data) => setState({ status: "ready", data }))
|
||
.catch((error) => setState({ status: "error", reason: errorMessage(error, "SCUM 插件数据读取失败。") }));
|
||
};
|
||
|
||
if (react.useEffect) react.useEffect(() => {
|
||
if (playerPanel.kind === "closed") refresh();
|
||
if (playerPanel.kind !== "closed") return;
|
||
const interval = setInterval(refresh, 10000);
|
||
return () => clearInterval(interval);
|
||
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]);
|
||
|
||
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
|
||
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
|
||
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
|
||
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null,
|
||
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
|
||
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
|
||
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
|
||
activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule,
|
||
eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal,
|
||
produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ,
|
||
giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber,
|
||
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,
|
||
setAction, refresh
|
||
}) : null
|
||
);
|
||
}
|
||
|
||
type ViewState = {
|
||
playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
|
||
playerPanel: PlayerPanelState; setPlayerPanel: StateSetter<PlayerPanelState>; attributeDrafts: AttributeDraft[]; setAttributeDrafts: StateSetter<AttributeDraft[]>;
|
||
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
|
||
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
|
||
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
|
||
eventType: string; setEventType: StateSetter<string>; eventSchedule: string; setEventSchedule: StateSetter<string>;
|
||
eventClass: string; setEventClass: StateSetter<string>; eventPlacard: string; setEventPlacard: StateSetter<string>; eventPercent: string; setEventPercent: StateSetter<string>;
|
||
eventDuration: string; setEventDuration: StateSetter<string>;
|
||
eventNpc: string; setEventNpc: StateSetter<string>; eventItem: string; setEventItem: StateSetter<string>; eventZombie: string; setEventZombie: StateSetter<string>; eventAnimal: string; setEventAnimal: StateSetter<string>;
|
||
produceEventId: string; setProduceEventId: StateSetter<string>; produceId: string; setProduceId: StateSetter<string>; produceTradeGoodsId: string; setProduceTradeGoodsId: StateSetter<string>;
|
||
producePercent: string; setProducePercent: StateSetter<string>; produceValue: string; setProduceValue: StateSetter<string>; produceRadius: string; setProduceRadius: StateSetter<string>; produceX: string; setProduceX: StateSetter<string>; produceY: string; setProduceY: StateSetter<string>; produceZ: string; setProduceZ: StateSetter<string>;
|
||
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: string; setGiftItems: StateSetter<string>; giftCommands: string; setGiftCommands: StateSetter<string>;
|
||
giftClass: string; setGiftClass: StateSetter<string>; giftAudience: string; setGiftAudience: StateSetter<string>; giftNumber: string; setGiftNumber: StateSetter<string>; giftAchievement: string; setGiftAchievement: StateSetter<string>; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter<string>;
|
||
deliveryGift: string; setDeliveryGift: StateSetter<string>; deliveryPlayer: string; setDeliveryPlayer: StateSetter<string>;
|
||
mapSearch: string; setMapSearch: StateSetter<string>; mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
|
||
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>;
|
||
};
|
||
|
||
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
switch (pageKey) {
|
||
case "players": return playersSurface(e, data, input, view);
|
||
case "squads": return squadsSurface(e, data, view);
|
||
case "live-map": return mapSurface(e, data, input, view);
|
||
case "gifts": return giftsSurface(e, data, input, view);
|
||
case "workflows":
|
||
case "activity": return activitiesSurface(e, data, input, view);
|
||
default: return playersSurface(e, data, input, view);
|
||
}
|
||
}
|
||
|
||
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const search = view.playerSearch.trim().toLowerCase();
|
||
const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "steamId", "squadName") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player)));
|
||
const selectedPlayer = data.players.find((player) => playerKey(player) === view.playerPanel.playerId);
|
||
return e("div", { className: "console-record-list scum-surface" },
|
||
e("div", { className: "resource-filter-bar scum-filter-bar" },
|
||
labeledField(e, "搜索用户", e("input", { type: "search", value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) })),
|
||
labeledField(e, "在线状态", e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知"))),
|
||
e("span", { className: "page-status" }, `${players.length} / ${data.players.length} 用户`)
|
||
),
|
||
e("div", { className: "provider-table-wrap scum-table-wrap" },
|
||
e("table", { className: "resource-table scum-user-table" },
|
||
e("caption", { className: "provider-id" }, "SCUM 用户真实记录"),
|
||
e("thead", null, e("tr", null, ["用户名", "队伍", "状态", "最后活动", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
|
||
e("tbody", null, players.length ? players.map((player, index) => playerTableRow(e, player, index, data, input, view)) : e("tr", null, e("td", { colSpan: 5 }, e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。"))))
|
||
)
|
||
),
|
||
selectedPlayer && view.playerPanel.kind !== "closed" ? playerDrawer(e, selectedPlayer, data, input, view) : null
|
||
);
|
||
}
|
||
|
||
function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index: number, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const name = textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户";
|
||
const open = (kind: PlayerPanelKind) => { view.setPlayerPanel({ kind, playerId: playerKey(player) }); if (kind === "attributes") view.setAttributeDrafts(playerAttributeDrafts(player)); };
|
||
return e("tr", { key: idOf(player, `player-${index}`) },
|
||
e("td", null, e("strong", null, name)),
|
||
e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")),
|
||
e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)),
|
||
e("td", null, userDateField(player, "lastSeenAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")),
|
||
e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open))
|
||
);
|
||
}
|
||
|
||
function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view: ViewState, open: (kind: PlayerPanelKind) => void) {
|
||
return e("details", { className: "inline-action-control" },
|
||
e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"),
|
||
e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" },
|
||
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("attributes") }, "编辑属性"),
|
||
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"),
|
||
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"),
|
||
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"),
|
||
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("trajectory") }, "用户轨迹")
|
||
)
|
||
);
|
||
}
|
||
|
||
function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const kind = view.playerPanel.kind;
|
||
const name = textField(player, "displayName", "playerName", "name") || playerKey(player);
|
||
const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" });
|
||
const content = kind === "attributes" ? playerAttributesPanel(e, player, input, view) : kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player, data) : playerTrajectoryPanel(e, player, data);
|
||
const title = kind === "attributes" ? "编辑属性" : kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹";
|
||
return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close },
|
||
e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() },
|
||
e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")),
|
||
e("div", { className: "console-record-meta scum-player-overview" },
|
||
e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`),
|
||
e("span", null, `登录 IP ${textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") || "未同步"}`),
|
||
e("span", null, `Fame ${numField(player, "famePoints")}`),
|
||
e("span", null, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`),
|
||
e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)),
|
||
content
|
||
)
|
||
);
|
||
}
|
||
|
||
function playerAttributesPanel(e: ReactLike["createElement"], player: RecordMap, input: SCUMPageContext, view: ViewState) {
|
||
const preview = playerAttributeSqlPreview(view.attributeDrafts);
|
||
const canSubmit = view.attributeDrafts.some((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()) && view.attributeDrafts.every((draft) => !draft.after.trim() || Number.isFinite(Number(draft.after)));
|
||
const save = () => runAction(view.setAction, "正在生成 SQL 并提交到 Run…", async () => { await queuePlayerAttributePatch(input.workspaceActions ?? {}, player, view.attributeDrafts); view.refresh(); return "SQL 已进入平台到 Run 的执行队列。"; });
|
||
return e("div", { className: "console-record-list" },
|
||
e("p", { className: "dialog-description" }, "快捷项会生成 SCUM.db SQL,并通过平台 remote.access.request 提交给 Run 执行;当前值未同步也可以直接提交。"),
|
||
e("div", { className: "provider-form" },
|
||
e("div", { className: "form-grid" }, view.attributeDrafts.map((draft) => e("label", { key: draft.fieldKey }, `${draft.label}(当前 ${draft.before || "未同步"})`, e("input", { type: "number", value: draft.after, "aria-label": `${draft.label}目标值`, onChange: (event: InputEvent) => view.setAttributeDrafts((previous) => previous.map((candidate) => candidate.fieldKey === draft.fieldKey ? { ...candidate, after: inputValue(event) } : candidate)) })))
|
||
)
|
||
),
|
||
e("div", { className: "diff-view", "aria-label": "SQL 预览" }, e("strong", null, "SQL 预览"), e("code", null, preview)),
|
||
e("p", { className: "field-help" }, "执行任务使用 remote.run.db.sqlite.execute;SQL 文本会随任务进入 Run 队列。"),
|
||
e("div", { className: "confirm-actions" }, e("button", { type: "button", className: "drawer-close", onClick: () => view.setPlayerPanel({ kind: "closed", playerId: "" }) }, "取消"), e("button", { type: "button", className: "primary-command", disabled: !canSubmit || !input.workspaceActions?.dispatch, onClick: save }, "生成并执行"))
|
||
);
|
||
}
|
||
|
||
function playerGiftPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const claims = playerRecords(data.giftClaims, player);
|
||
const pending = playerRecords(data.pendingGifts, player);
|
||
const deliveries = playerRecords(data.giftDeliveries, player);
|
||
const reset = (row: RecordMap, mode: "claim" | "pending") => runAction(view.setAction, "正在重置礼包状态…", async () => { if (mode === "claim") await resetGiftClaim(input.workspaceActions ?? {}, row); else await resetPendingGift(input.workspaceActions ?? {}, row); view.refresh(); return "礼包状态已重置。"; });
|
||
return e("div", { className: "console-record-list" },
|
||
e("p", { className: "dialog-description" }, "状态来自插件礼包集合。重置会让该记录回到可领取状态,不会生成礼包定义或样例记录。"),
|
||
giftStatusRecord(e, "已领取", claims, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "claim") }, "重置状态")),
|
||
giftStatusRecord(e, "待领取", pending, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "pending") }, "重置状态")),
|
||
giftStatusRecord(e, "发放记录", deliveries)
|
||
);
|
||
}
|
||
|
||
function giftStatusRecord(e: ReactLike["createElement"], title: string, rows: RecordMap[], action?: (row: RecordMap) => unknown) {
|
||
return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "giftName", "giftCode", "giftType") || "礼包未命名"), e("strong", null, textField(row, "status") || "未知"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "deliveredAt", "createdAt")), action ? e("span", { className: "console-row-actions" }, action(row)) : null)) : e("p", { className: "page-status" }, "暂无该用户的真实礼包记录。")));
|
||
}
|
||
|
||
function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) {
|
||
const items = field(player, "items", "inventory", "inventoryItems");
|
||
const rows = Array.isArray(items) ? items : [];
|
||
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "物品清单只展示插件已同步到用户记录的内容。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((item, index) => e("div", { key: `item-${index}`, className: "console-row" }, e("span", null, isRecord(item) ? textField(item, "name", "label", "itemId", "className") || "未命名物品" : String(item)), e("strong", null, isRecord(item) ? `× ${numField(item, "quantity", "count")}` : ""))) : e("p", { className: "page-status" }, "没有该用户的真实物品记录,等待插件同步。")));
|
||
}
|
||
|
||
function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
|
||
const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase()));
|
||
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件日志同步事件;网络信息按插件声明字段展示。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "loginIp", "ipAddress", "lastIp") || "IP 未同步"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
|
||
}
|
||
|
||
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
|
||
const rows = playerRecords(data.activityEvents, player).filter((row) => hasCoordinates(positionOf(row)));
|
||
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹只展示插件声明并已同步的位置事件,不从机器文件或 SCUM.db 外部猜测。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, textField(row, "source") || "plugin log"))) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
|
||
}
|
||
|
||
function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); }
|
||
function playerIdentities(player: RecordMap): string[] { return ["steamId", "gamePlayerId", "playerId", "userProfileId", "profileId", "id"].map((key) => textField(player, key)).filter(Boolean); }
|
||
function playerKey(player: RecordMap): string { return textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey"); }
|
||
function userDateField(row: RecordMap | undefined, ...keys: string[]): string { return textField(row, ...keys) ? dateField(row, ...keys) : "未同步"; }
|
||
|
||
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
|
||
const search = view.squadSearch.trim().toLowerCase();
|
||
const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId"));
|
||
const activeId = view.selectedSquadId || textField(squads[0], "squadId", "id");
|
||
const roster = data.members.filter((member) => textField(member, "squadId") === activeId);
|
||
return e("div", { className: "overview-two-col" },
|
||
e("article", { className: "console-module" },
|
||
e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${squads.length} 支`)),
|
||
e("div", { className: "resource-filter-bar scum-filter-bar" }, labeledField(e, "搜索队伍", e("input", { type: "search", value: view.squadSearch, "aria-label": "搜索队伍", placeholder: "队名 / 队长 / 队伍 ID", onChange: (event: InputEvent) => view.setSquadSearch(inputValue(event)) }))),
|
||
e("div", { className: "console-row-list" }, squads.length ? squads.map((squad, index) => {
|
||
const squadId = textField(squad, "squadId", "id");
|
||
const memberCount = data.members.filter((member) => textField(member, "squadId") === squadId).length;
|
||
return e("button", { key: idOf(squad, `squad-${index}`), type: "button", className: "console-row", onClick: () => view.setSelectedSquadId(squadId) },
|
||
e("span", null, textField(squad, "name") || squadId || "未命名队伍"),
|
||
e("strong", null, `成员 ${memberCount || numField(squad, "memberCount")} / ${numField(squad, "memberLimit", "member_limit")}`),
|
||
e("strong", null, `队长 ${textField(squad, "leaderName", "leaderProfileId") || "unknown"}`),
|
||
e("strong", null, `分数 ${numField(squad, "score")}`),
|
||
e("strong", null, textField(squad, "message", "info") || "无队伍公告"));
|
||
}) : e("p", { className: "page-status" }, "没有符合筛选条件的真实队伍记录。"))
|
||
),
|
||
e("div", { className: "console-record-list" },
|
||
tablePanel(e, "队伍成员", roster, (member) => [textField(member, "displayName", "gamePlayerId") || "unknown", textField(member, "rank") || "member", `Score ${numField(member, "score")}`, dateField(member, "lastLoginAt", "lastMemberLogin", "lastSeenAt")]),
|
||
tablePanel(e, "领地旗帜", data.flags.filter((flag) => !activeId || textField(flag, "ownerSquadId", "squadId") === activeId), (flag) => [textField(flag, "name", "flagId") || "flag", textField(flag, "ownershipConfidence") || "unknown", coords(positionOf(flag)), freshness(flag)])
|
||
)
|
||
);
|
||
}
|
||
|
||
function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const actions = input.workspaceActions;
|
||
const runsByEvent = new Map<string, RecordMap>();
|
||
for (const run of data.eventRuns) runsByEvent.set(textField(run, "eventId", "activityId"), run);
|
||
const events = data.events.filter((event) => {
|
||
const run = runsByEvent.get(textField(event, "id", "eventId"));
|
||
const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown";
|
||
return view.activityStatus === "all" || status === view.activityStatus;
|
||
});
|
||
const statuses = unique(data.events.map((event) => textField(runsByEvent.get(textField(event, "id", "eventId")), "status", "state") || textField(event, "status", "state")).filter(Boolean));
|
||
const saveEvent = () => runAction(view.setAction, "正在保存活动定义…", async () => {
|
||
const id = view.eventId.trim();
|
||
const name = view.eventName.trim();
|
||
if (!id || !name) throw new Error("活动编号和名称不能为空。");
|
||
await saveEventDefinition(actions ?? {}, {
|
||
id, name, eventType: view.eventClass === "2" ? "fixed" : "range", class: integerInput(view.eventClass, 1), schedule: view.eventSchedule.trim(), corn: view.eventSchedule.trim(),
|
||
placard: view.eventPlacard.trim(), announcement: view.eventPlacard.trim(), percent: integerInput(view.eventPercent, 100), probability: integerInput(view.eventPercent, 100),
|
||
npc: minimumIntegerInput(view.eventNpc, 0, "NPC 数量"), item: minimumIntegerInput(view.eventItem, 0, "物品数量"), zombie: minimumIntegerInput(view.eventZombie, 0, "僵尸数量"), animal: minimumIntegerInput(view.eventAnimal, 0, "动物数量"),
|
||
status: "enabled", announce: Boolean(view.eventPlacard.trim()), durationSeconds: minimumIntegerInput(view.eventDuration, 1, "活动持续秒数"), updatedAt: new Date().toISOString()
|
||
});
|
||
view.refresh();
|
||
return `活动 ${name} 已保存。`;
|
||
});
|
||
const saveProduce = () => runAction(view.setAction, "正在保存活动生成项…", async () => {
|
||
const eventId = view.produceEventId.trim() || view.eventId.trim() || textField(data.events[0], "id", "eventId");
|
||
const tradeGoodsId = view.produceTradeGoodsId.trim();
|
||
if (!eventId || !tradeGoodsId) throw new Error("活动编号和物品编号不能为空。");
|
||
await saveEventProduce(actions ?? {}, {
|
||
id: view.produceId.trim(), eventId, tradeGoodsId, percent: integerInput(view.producePercent, 100), value: integerInput(view.produceValue, 1),
|
||
r: numberInput(view.produceRadius, 0), x: numberInput(view.produceX, 0), y: numberInput(view.produceY, 0), z: numberInput(view.produceZ, 0), updatedAt: new Date().toISOString()
|
||
});
|
||
view.refresh();
|
||
return "活动生成项已保存。";
|
||
});
|
||
const activityHistory = data.activityEvents.filter((event) => Boolean(textField(event, "occurredAt", "createdAt")) && textField(event, "taskKind").toLowerCase() !== "active-task");
|
||
return e("div", { className: "console-record-list" },
|
||
statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["原生赛事轮次", data.nativeEventRounds.length], ["任务", data.tasks.length]]),
|
||
e("div", { className: "overview-two-col" },
|
||
e("details", { className: "console-module scum-editor", open: view.eventEditorOpen, onToggle: (event: InputEvent) => view.setEventEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")),
|
||
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })),
|
||
labeledField(e, "活动名称", e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })),
|
||
labeledField(e, "生成类型", e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成"))),
|
||
labeledField(e, "活动计划", e("input", { value: view.eventSchedule, "aria-label": "活动计划", placeholder: "Cron(留空为手动)", onChange: (event: InputEvent) => view.setEventSchedule(inputValue(event)) })),
|
||
labeledField(e, "活动公告", e("input", { value: view.eventPlacard, "aria-label": "活动公告", placeholder: "活动开始时显示的公告", onChange: (event: InputEvent) => view.setEventPlacard(inputValue(event)) })),
|
||
labeledField(e, "触发概率 (%)", e("input", { value: view.eventPercent, "aria-label": "活动概率", type: "number", placeholder: "100", onChange: (event: InputEvent) => view.setEventPercent(inputValue(event)) })),
|
||
labeledField(e, "持续秒数", e("input", { value: view.eventDuration, "aria-label": "活动持续秒数", type: "number", min: 1, placeholder: "1800", onChange: (event: InputEvent) => view.setEventDuration(inputValue(event)) })),
|
||
e("div", { className: "console-row-actions" },
|
||
labeledField(e, "NPC 数量", e("input", { value: view.eventNpc, "aria-label": "NPC 数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventNpc(inputValue(event)) })),
|
||
labeledField(e, "物品数量", e("input", { value: view.eventItem, "aria-label": "物品数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventItem(inputValue(event)) })),
|
||
labeledField(e, "僵尸数量", e("input", { value: view.eventZombie, "aria-label": "僵尸数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventZombie(inputValue(event)) })),
|
||
labeledField(e, "动物数量", e("input", { value: view.eventAnimal, "aria-label": "动物数量", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setEventAnimal(inputValue(event)) }))),
|
||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: saveEvent }, "保存活动")
|
||
),
|
||
e("article", { className: "console-module" }, e("h2", null, "状态筛选"),
|
||
e("select", { value: view.activityStatus, "aria-label": "活动状态", onChange: (event: InputEvent) => view.setActivityStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), statuses.map((status) => e("option", { key: status, value: status }, status))),
|
||
e("p", { className: "page-status" }, "活动定义属于插件;SCUM 原生 event_round 和 quest/task 只作为运行事实展示。")
|
||
)
|
||
),
|
||
e("details", { className: "console-module scum-editor", open: view.produceEditorOpen, onToggle: (event: InputEvent) => view.setProduceEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "活动生成项"), e("span", { className: "page-status" }, view.produceEventId || view.produceId ? "编辑中" : "点击展开")),
|
||
e("div", { className: "console-row-actions" },
|
||
labeledField(e, "活动编号", e("input", { value: view.produceEventId, "aria-label": "生成项活动编号", placeholder: "默认使用上方活动", onChange: (event: InputEvent) => view.setProduceEventId(inputValue(event)) })),
|
||
labeledField(e, "生成项编号", e("input", { value: view.produceId, "aria-label": "生成项编号", placeholder: "更新时填写", onChange: (event: InputEvent) => view.setProduceId(inputValue(event)) })),
|
||
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)) }))),
|
||
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)) })),
|
||
labeledField(e, "Y", e("input", { value: view.produceY, "aria-label": "生成 Y", type: "number", placeholder: "0", onChange: (event: InputEvent) => view.setProduceY(inputValue(event)) })),
|
||
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" },
|
||
e("span", null, `${textField(produce, "eventId", "event")} / ${textField(produce, "tradeGoodsId", "trade_goods_id")}`),
|
||
e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`),
|
||
e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`),
|
||
e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEditorOpen(true); view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"),
|
||
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除生成项…", async () => { await deleteEventProduce(actions ?? {}, produce); view.refresh(); return "生成项已删除。"; }) }, "删除"))) : e("p", { className: "page-status" }, "暂无活动生成项。"))
|
||
),
|
||
events.length ? events.map((event, index) => {
|
||
const eventId = textField(event, "id", "eventId");
|
||
const run = runsByEvent.get(eventId);
|
||
const status = textField(run, "status", "state") || textField(event, "status", "state") || "unknown";
|
||
return e("article", { key: idOf(event, `event-${index}`), className: "console-record" },
|
||
e("div", { className: "console-record-head" }, e("strong", null, textField(event, "name", "title") || eventId || "未命名活动"), e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, status)),
|
||
e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")),
|
||
e("div", { className: "console-row-actions" },
|
||
e("button", { type: "button", className: "primary-command", disabled: !actions?.gameClient || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"),
|
||
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
|
||
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除")
|
||
)
|
||
);
|
||
}) : e("p", { className: "page-status" }, "没有符合状态筛选的真实活动。"),
|
||
e("div", { className: "overview-two-col" },
|
||
tablePanel(e, "原生赛事轮次", data.nativeEventRounds, (event) => [textField(event, "eventId") || "event", textField(event, "state") || "unknown", `Kills ${numField(event, "enemyKills")}`, dateField(event, "startTime")]),
|
||
tablePanel(e, "Quest / Task", data.tasks, (task) => [textField(task, "taskKind") || "task", textField(task, "dataAssetPath") || "unknown", textField(task, "state") || "unknown", textField(task, "userProfileId") || "unknown"])
|
||
),
|
||
tablePanel(e, "最近活动记录", [...data.eventRuns, ...activityHistory], (event) => [textField(event, "eventName", "type", "kind", "activityType") || "event", textField(event, "subjectName", "eventId", "subjectId", "subject") || "unknown", textField(event, "status", "result", "state") || "unknown", dateField(event, "startedAt", "occurredAt", "createdAt")])
|
||
);
|
||
}
|
||
|
||
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const actions = input.workspaceActions;
|
||
const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => {
|
||
const code = view.giftCode.trim();
|
||
const name = view.giftName.trim();
|
||
if (!code || !name) throw new Error("礼包编号和名称不能为空。");
|
||
const items = parseGiftItems(view.giftItems);
|
||
const commands = parseGiftCommands(view.giftCommands);
|
||
if (!items.length && !commands.length) throw new Error("礼包至少需要一项物品或命令。");
|
||
await saveGiftDefinition(actions ?? {}, {
|
||
code, name, class: integerInput(view.giftClass, 5), audience: view.giftAudience, number: integerInput(view.giftNumber, 1),
|
||
achievement: integerInput(view.giftAchievement, 0), achievementNumber: integerInput(view.giftAchievementNumber, 0), items, commands,
|
||
status: "active", updatedAt: new Date().toISOString()
|
||
});
|
||
view.refresh();
|
||
return `礼包 ${name} 已保存。`;
|
||
});
|
||
const queueDelivery = () => runAction(view.setAction, "正在创建发放记录…", async () => {
|
||
const giftCode = view.deliveryGift || textField(data.gifts[0], "code", "id");
|
||
const playerId = view.deliveryPlayer || textField(data.players[0], "gamePlayerId", "steamId", "id");
|
||
const gift = data.gifts.find((item) => textField(item, "code", "id") === giftCode);
|
||
const player = data.players.find((item) => textField(item, "gamePlayerId", "steamId", "id") === playerId);
|
||
if (!gift || !player) throw new Error("请选择礼包和用户。");
|
||
await queueGiftDelivery(actions ?? {}, gift, player);
|
||
view.refresh();
|
||
return "礼包发放命令已进入执行队列。";
|
||
});
|
||
return e("div", { className: "console-record-list" },
|
||
statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["原生定时记录", data.timedGiftEvents.length]]),
|
||
e("div", { className: "console-row-actions", role: "tablist", "aria-label": "礼包视图" },
|
||
giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录")
|
||
),
|
||
view.giftTab === "definitions" ? e("div", { className: "overview-two-col" },
|
||
e("details", { className: "console-module scum-editor", open: view.giftEditorOpen, onToggle: (event: InputEvent) => view.setGiftEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新礼包"), e("span", { className: "page-status" }, view.giftCode || view.giftName ? "编辑中" : "点击展开")),
|
||
labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })),
|
||
labeledField(e, "礼包名称", e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "例如 新人礼包", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) })),
|
||
labeledField(e, "礼包周期", e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label)))),
|
||
labeledField(e, "适用玩家", e("select", { value: view.giftAudience, "aria-label": "适用玩家", onChange: (event: InputEvent) => view.setGiftAudience(inputValue(event)) }, e("option", { value: "all" }, "全部玩家"), e("option", { value: "pve" }, "PVE 玩家"), e("option", { value: "pvp" }, "PVP 玩家"))),
|
||
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)) })),
|
||
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 }, "保存礼包")
|
||
),
|
||
e("div", { className: "console-record-list" }, data.gifts.length ? data.gifts.map((gift, index) => {
|
||
const key = textField(gift, "_recordKey", "code", "id");
|
||
return e("article", { key: idOf(gift, `gift-${index}`), className: "console-record" },
|
||
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)),
|
||
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 "礼包定义已删除。"; }) }, "删除"))
|
||
);
|
||
}) : e("p", { className: "page-status" }, "暂无礼包定义。"))
|
||
) : null,
|
||
view.giftTab === "claims" ? e("div", { className: "overview-two-col" },
|
||
resettableGiftPanel(e, "领取记录", data.giftClaims, "重置领取", (claim) => runAction(view.setAction, "正在重置领取记录…", async () => { await resetGiftClaim(actions ?? {}, claim); view.refresh(); return "领取记录已重置。"; }), actions),
|
||
resettableGiftPanel(e, "待领礼包", data.pendingGifts, "重置待领", (pending) => runAction(view.setAction, "正在重置待领记录…", async () => { await resetPendingGift(actions ?? {}, pending); view.refresh(); return "待领状态已重置。"; }), actions)
|
||
) : null,
|
||
view.giftTab === "deliveries" ? e("div", { className: "console-record-list" },
|
||
e("article", { className: "console-module" }, e("h2", null, "创建发放记录"),
|
||
labeledField(e, "礼包", e("select", { value: view.deliveryGift, "aria-label": "选择礼包", onChange: (event: InputEvent) => view.setDeliveryGift(inputValue(event)) }, e("option", { value: "" }, "选择礼包"), data.gifts.map((gift, index) => { const key = textField(gift, "code", "id"); return e("option", { key: idOf(gift, `gift-option-${index}`), value: key }, textField(gift, "name") || key); }))),
|
||
labeledField(e, "用户", e("select", { value: view.deliveryPlayer, "aria-label": "选择用户", onChange: (event: InputEvent) => view.setDeliveryPlayer(inputValue(event)) }, e("option", { value: "" }, "选择用户"), data.players.map((player, index) => { const key = textField(player, "gamePlayerId", "steamId", "id"); return e("option", { key: idOf(player, `player-option-${index}`), value: key }, textField(player, "displayName") || key); }))),
|
||
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData || !actions?.gameClient, onClick: queueDelivery }, "立即发放")
|
||
),
|
||
tablePanel(e, "发放记录", data.giftDeliveries, (delivery) => [textField(delivery, "playerName", "playerId") || "unknown", textField(delivery, "giftName", "giftCode") || "unknown", textField(delivery, "status") || "unknown", dateField(delivery, "deliveredAt", "createdAt")])
|
||
) : null,
|
||
view.giftTab === "timed" ? e("div", { className: "console-record-list" },
|
||
e("p", { className: "page-status" }, "这里是 SCUM 原生 finished_timed_gift_spawner 完成记录,不是插件运营礼包定义。"),
|
||
tablePanel(e, "游戏原生定时礼包", data.timedGiftEvents, (event) => [textField(event, "userProfileId") || "unknown", textField(event, "mapId") || "unknown", textField(event, "spawnTime") || "unknown", dateField(event, "spawnAt")])
|
||
) : null
|
||
);
|
||
}
|
||
|
||
function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||
const actions = input.workspaceActions;
|
||
const points = collectMapPoints(data);
|
||
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"));
|
||
const centerX = view.mapCenterX || textField(settings, "centerX", "mapX") || String((bounds.worldMinX + bounds.worldMaxX) / 2);
|
||
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 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];
|
||
return e("div", { className: "console-record-list" },
|
||
statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["旗帜/区域", data.flags.length + data.mapRegions.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("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("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("div", { className: "overview-two-col" },
|
||
e("div", { className: "map-projection-board", "aria-label": "SCUM 地图图层", style: { backgroundImage: `url(${scumMapBackground})` } }, visible.map((point, index) => e("button", { key: idOf(point, `point-${index}`), type: "button", className: "map-projection-dot", title: `${pointTitle(point)} ${coords(point)}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, ""))),
|
||
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") || "plugin collection"}`), e("span", null, freshness(selected)))) : e("p", { className: "page-status" }, "当前图层和筛选条件下没有真实地图点。"))
|
||
)
|
||
);
|
||
}
|
||
|
||
function labeledField(e: ReactLike["createElement"], label: string, control: unknown) { return e("label", { className: "console-field" }, e("span", null, label), control); }
|
||
function detailOpen(event: InputEvent): boolean { return Boolean(event.target?.open); }
|
||
function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: GiftTab, label: string) { return e("button", { type: "button", role: "tab", "aria-selected": view.giftTab === tab, className: view.giftTab === tab ? "primary-command" : "icon-command", onClick: () => view.setGiftTab(tab) }, label); }
|
||
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
|
||
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, render(row).map((part, partIndex) => partIndex === 0 ? e("span", { key: partIndex }, String(part ?? "unknown")) : e("strong", { key: partIndex }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实记录。"))); }
|
||
function resettableGiftPanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], actionLabel: string, onReset: (row: RecordMap) => void, actions: SCUMWorkspaceActions | undefined) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length} 条`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "playerName", "playerId", "displayName", "userProfileId") || "unknown"), e("strong", null, textField(row, "giftName", "giftCode", "giftType") || "unknown"), e("strong", null, textField(row, "status") || "unknown"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "createdAt")), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => onReset(row) }, actionLabel))) : e("p", { className: "page-status" }, "暂无真实记录。"))); }
|
||
|
||
export function collectMapPoints(data: SCUMSurfaceData): RecordMap[] {
|
||
const direct = data.mapPoints.map((point) => ({ ...point, layer: textField(point, "layer", "subjectType", "type") || "other" }));
|
||
const players = data.players.flatMap((player) => withPosition(player, "players", textField(player, "displayName"), textField(player, "steamId", "gamePlayerId", "id")));
|
||
const vehicles = data.vehicles.flatMap((vehicle) => withPosition(vehicle, "vehicles", textField(vehicle, "label", "name"), textField(vehicle, "vehicleId", "id")));
|
||
const flags = data.flags.flatMap((flag) => withPosition(flag, "flags", textField(flag, "name"), textField(flag, "flagId", "id")));
|
||
const regions = data.mapRegions.flatMap((region) => withPosition(region, "regions", textField(region, "name"), textField(region, "id", "regionId")));
|
||
const uniquePoints = new Map<string, RecordMap>();
|
||
for (const point of [...direct, ...players, ...vehicles, ...flags, ...regions].filter(hasCoordinates)) {
|
||
const key = mapPointIdentity(point);
|
||
const current = uniquePoints.get(key);
|
||
uniquePoints.set(key, current ? { ...point, ...current, name: textField(point, "name") || textField(current, "name") } : point);
|
||
}
|
||
return [...uniquePoints.values()];
|
||
}
|
||
|
||
function withPosition(row: RecordMap, layer: MapLayer, name: string, subjectId: string): RecordMap[] { const position = positionOf(row); return hasCoordinates(position) ? [{ ...position, layer, name, subjectId, _recordKey: `${layer}:${subjectId}`, source: textField(row, "source") }] : []; }
|
||
function positionOf(row: RecordMap | undefined): RecordMap | undefined { const nested = field(row, "position", "location"); return isRecord(nested) ? nested : row; }
|
||
function hasCoordinates(row: RecordMap | undefined): row is RecordMap { return Boolean(row) && Number.isFinite(Number(field(row, "x", "locationX"))) && Number.isFinite(Number(field(row, "y", "locationY"))); }
|
||
function layerOf(point: RecordMap): MapLayer { const value = textField(point, "layer", "subjectType", "type").toLowerCase(); if (value.includes("player") || value.includes("user")) return "players"; if (value.includes("vehicle")) return "vehicles"; if (value.includes("flag")) return "flags"; if (value.includes("region") || value.includes("zone") || value === "base") return "regions"; return "other"; }
|
||
function layerLabel(layer: MapLayer): string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他"; }
|
||
function pointTitle(point: RecordMap): string { return textField(point, "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; }
|
||
function mapPointIdentity(point: RecordMap): string { const subject = textField(point, "subjectId", "gamePlayerId", "vehicleId", "flagId", "regionId"); if (subject) return `${layerOf(point)}:${subject}`; const record = textField(point, "id", "_recordKey"); if (record) return `${layerOf(point)}:${record}`; return `${layerOf(point)}:${pointTitle(point).toLowerCase()}:${numField(point, "x", "locationX")}:${numField(point, "y", "locationY")}`; }
|
||
export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record<string, string> {
|
||
const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0);
|
||
const minX = Number(field(bounds, "worldMinX")); const minY = Number(field(bounds, "worldMinY")); const maxX = Number(field(bounds, "worldMaxX")); const maxY = Number(field(bounds, "worldMaxY"));
|
||
const left = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? 100 - (x - minX) / (maxX - minX) * 100 : 50;
|
||
const top = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? 100 - (y - minY) / (maxY - minY) * 100 : 50;
|
||
return { left: `${Math.max(1, Math.min(99, left))}%`, top: `${Math.max(1, Math.min(99, top))}%` };
|
||
}
|
||
|
||
function runAction(setAction: StateSetter<ActionState>, pending: string, task: () => Promise<string>) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); }
|
||
function usePluginState<T>(react: ReactLike, initial: T): [T, StateSetter<T>] { return react.useState ? react.useState<T>(initial) : [initial, () => undefined]; }
|
||
function inputValue(event: InputEvent): string { return event.target?.value ?? ""; }
|
||
function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!row) return undefined; for (const key of keys) { if (row[key] !== undefined) return row[key]; const normalized = normalizeKey(key); const found = Object.keys(row).find((candidate) => normalizeKey(candidate) === normalized); if (found && row[found] !== undefined) return row[found]; } return undefined; }
|
||
function normalizeKey(value: string): string { return value.replace(/[_-]/g, "").toLowerCase(); }
|
||
function textField(row: RecordMap | undefined, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null ? "" : String(value); }
|
||
function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === 1 || value === "1" || value === "true"; }
|
||
function playerOnline(player: RecordMap): boolean { const status = textField(player, "status").toLowerCase(); return boolField(player, "online") || ["online", "active", "connected"].includes(status); }
|
||
function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); }
|
||
function idOf(row: RecordMap | undefined, fallback: string): string { return textField(row, "_recordKey", "id", "gamePlayerId", "squadId", "vehicleId", "flagId", "eventId", "code") || fallback; }
|
||
function freshness(row: RecordMap): string { const fresh = field(row, "freshness"); return isRecord(fresh) ? textField(fresh, "status") || "unknown" : textField(row, "freshnessStatus", "updatedAt") || "unknown"; }
|
||
function coords(row?: RecordMap): string { if (!row || !hasCoordinates(row)) return "坐标 unknown"; return `X ${numField(row, "x", "locationX")} / Y ${numField(row, "y", "locationY")} / Z ${numField(row, "z", "locationZ")}`; }
|
||
function dateField(row: RecordMap | undefined, ...keys: string[]): string { const value = textField(row, ...keys); if (!value) return "unknown"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); }
|
||
function matchesText(row: RecordMap, search: string, ...keys: string[]): boolean { return !search || keys.some((key) => textField(row, key).toLowerCase().includes(search)); }
|
||
function unique(values: string[]): string[] { return [...new Set(values)]; }
|
||
function activeStatus(status: string): boolean { return ["active", "running", "scheduled", "enabled", "queued"].includes(status.toLowerCase()); }
|
||
function giftItemsSummary(gift: RecordMap): string { const items = field(gift, "items"); if (!Array.isArray(items)) return "物品清单未记录"; return items.map((item) => isRecord(item) ? `${textField(item, "label", "catalogCode", "catalogItemKey", "className", "key") || "item"} × ${numField(item, "quantity")}` : String(item)).join(" · "); }
|
||
function giftCommandsSummary(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) && commands.length ? `${commands.length} 条命令` : "无命令"; }
|
||
function giftItemsInput(gift: RecordMap): string { const items = field(gift, "items"); return Array.isArray(items) ? items.map((item) => isRecord(item) ? `${textField(item, "catalogCode", "catalogItemKey", "key")}:${numField(item, "quantity")}` : "").filter(Boolean).join(", ") : ""; }
|
||
function giftCommandsInput(gift: RecordMap): string { const commands = field(gift, "commands"); return Array.isArray(commands) ? commands.map((item) => isRecord(item) ? textField(item, "command", "value") : String(item)).filter(Boolean).join("\n") : ""; }
|
||
function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record<string, string>)[value] ?? value; }
|
||
function integerInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isInteger(parsed) ? parsed : fallback; }
|
||
function minimumIntegerInput(value: string, minimum: number, label: string): number { const parsed = Number(value); if (!Number.isSafeInteger(parsed) || parsed < minimum) throw new Error(`${label}必须是不小于 ${minimum} 的整数。`); return parsed; }
|
||
function numberInput(value: string, fallback: number): number { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
|
||
function isRecord(value: unknown): value is RecordMap { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
|
||
function errorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; }
|
||
function surfaceTitle(pageKey: string): string { return pageKey === "squads" ? "队伍管理" : pageKey === "live-map" ? "实时地图" : pageKey === "gifts" ? "礼包管理" : pageKey === "workflows" || pageKey === "activity" ? "活动管理" : "用户管理"; }
|