Ship SCUM user SQL management page
This commit is contained in:
@@ -17,6 +17,69 @@ export type PluginGameClientQueueRequest = {
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
|
||||
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } };
|
||||
|
||||
export const playerAttributeCatalog = [
|
||||
{ key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] },
|
||||
{ key: "dexterity", label: "敏捷", column: "dexterity", sourceKeys: ["attributes.dexterity", "dexterity", "敏捷"] },
|
||||
{ key: "intelligence", label: "智力", column: "intelligence", sourceKeys: ["attributes.intelligence", "intelligence", "智力"] }
|
||||
] as const;
|
||||
|
||||
export type PlayerAttributeDraft = { fieldKey: string; label: string; before: string; after: string };
|
||||
|
||||
export function playerAttributeDrafts(player: RecordMap): PlayerAttributeDraft[] {
|
||||
return playerAttributeCatalog.map((field) => ({ fieldKey: field.key, label: field.label, before: firstText(player, ...field.sourceKeys), after: firstText(player, ...field.sourceKeys) }));
|
||||
}
|
||||
|
||||
export function playerAttributeSqlPreview(drafts: PlayerAttributeDraft[]): string {
|
||||
const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim());
|
||||
if (!changes.length) return "等待输入要提交的属性变更。";
|
||||
return buildPlayerAttributeSqlText({ gamePlayerId: ":playerId" }, changes.flatMap((draft) => {
|
||||
const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey);
|
||||
const after = Number(draft.after);
|
||||
return definition && Number.isFinite(after) ? [{ column: definition.column, after }] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPlayerAttributeMutation(player: RecordMap, drafts: PlayerAttributeDraft[]): RecordMap {
|
||||
const playerId = firstText(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id");
|
||||
if (!playerId) throw new Error("用户没有可用的 Steam ID 或游戏用户编号。");
|
||||
const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()).map((draft) => {
|
||||
const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey);
|
||||
const before = draft.before.trim() ? Number(draft.before) : Number.NaN;
|
||||
const after = Number(draft.after);
|
||||
if (!definition || !Number.isFinite(after)) throw new Error(`${draft.label}目标值必须是数字。`);
|
||||
return { fieldKey: draft.fieldKey, label: draft.label, column: definition.column, before: Number.isFinite(before) ? before : null, after };
|
||||
});
|
||||
if (!changes.length) throw new Error("至少填写一项与当前值不同的属性。");
|
||||
const idempotencyKey = safeCommandId(`player-attributes:${playerId}:${changes.map((change) => `${change.fieldKey}:${change.after}`).join(",")}:${Date.now()}`);
|
||||
return { playerId, reason: "管理员在 SCUM 用户管理中编辑属性", sqlText: buildPlayerAttributeSqlText(player, changes), changes, idempotencyKey };
|
||||
}
|
||||
|
||||
export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, player: RecordMap, drafts: PlayerAttributeDraft[]): Promise<unknown> {
|
||||
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法提交 SQL 执行任务。");
|
||||
const mutation = buildPlayerAttributeMutation(player, drafts);
|
||||
const idempotencyKey = textValue(mutation.idempotencyKey);
|
||||
const result = await actions.dispatch({
|
||||
requestId: idempotencyKey,
|
||||
action: "remote.access.request",
|
||||
payload: {
|
||||
capability: "remote.run.db.sqlite.execute",
|
||||
declarationKey: "scum-database",
|
||||
targetKey: "scum-database",
|
||||
idempotencyKey,
|
||||
timeoutSeconds: "60",
|
||||
maxAttempts: "1",
|
||||
"input.mode": "execute",
|
||||
"input.sqlText": textValue(mutation.sqlText),
|
||||
"input.reason": textValue(mutation.reason)
|
||||
}
|
||||
});
|
||||
if (result?.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SQL 执行任务未进入 Run 队列。");
|
||||
return result;
|
||||
}
|
||||
|
||||
export type SCUMWorkspaceActions = {
|
||||
pluginData?: PluginDataActions;
|
||||
gameClient?: {
|
||||
@@ -25,6 +88,7 @@ export type SCUMWorkspaceActions = {
|
||||
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
|
||||
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
|
||||
};
|
||||
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
|
||||
};
|
||||
|
||||
export type SCUMSurfaceData = {
|
||||
@@ -80,7 +144,7 @@ type SurfaceKey = keyof SCUMSurfaceData;
|
||||
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
|
||||
|
||||
const pageCollections: Record<PageKey, SurfaceKey[]> = {
|
||||
players: ["players", "members"],
|
||||
players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
|
||||
squads: ["squads", "members", "flags"],
|
||||
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"],
|
||||
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"],
|
||||
@@ -292,6 +356,23 @@ function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions {
|
||||
return actions.pluginData;
|
||||
}
|
||||
|
||||
function buildPlayerAttributeSqlText(player: RecordMap, changes: Array<{ column: string; after: number }>): string {
|
||||
const playerId = firstText(player, "gamePlayerId", "playerId", "id");
|
||||
const profileId = firstText(player, "userProfileId", "profileId");
|
||||
const steamId = firstText(player, "steamId", "providerId");
|
||||
const where = playerId && playerId !== ":playerId"
|
||||
? `id = ${sqlLiteral(playerId)}`
|
||||
: profileId
|
||||
? `id = (SELECT prisoner_id FROM user_profile WHERE CAST(id AS TEXT) = ${sqlLiteral(profileId)} LIMIT 1)`
|
||||
: steamId
|
||||
? `id = (SELECT profile.prisoner_id FROM user_profile profile WHERE profile.user_id = ${sqlLiteral(steamId)} LIMIT 1)`
|
||||
: "id = :playerId";
|
||||
return changes.map((change) => `UPDATE prisoner SET ${change.column} = ${sqlNumber(change.after)} WHERE ${where};`).join("\n");
|
||||
}
|
||||
|
||||
function sqlLiteral(value: string): string { return value === ":playerId" ? value : `'${value.replace(/'/g, "''")}'`; }
|
||||
function sqlNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(value); }
|
||||
|
||||
function requiredKey(value: RecordMap, key: string, label: string): string {
|
||||
const result = textValue(value[key]);
|
||||
if (!result) throw new Error(`${label}不能为空。`);
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
loadSCUMSurface,
|
||||
parseGiftItems,
|
||||
parseGiftCommands,
|
||||
playerAttributeDrafts,
|
||||
playerAttributeSqlPreview,
|
||||
queuePlayerAttributePatch,
|
||||
queueGiftDelivery,
|
||||
resetGiftClaim,
|
||||
resetPendingGift,
|
||||
@@ -21,9 +24,12 @@ import {
|
||||
} from "./page-data.js";
|
||||
|
||||
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
|
||||
type InputEvent = { target?: { value?: string; checked?: boolean } };
|
||||
type InputEvent = { target?: { value?: string; checked?: 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 = {
|
||||
@@ -51,6 +57,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
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");
|
||||
@@ -116,16 +124,13 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
|
||||
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
|
||||
e("div", { className: "panel-header" },
|
||||
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))),
|
||||
e("div", { className: "console-row-actions" },
|
||||
e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion")
|
||||
)
|
||||
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(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, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
|
||||
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,
|
||||
@@ -139,6 +144,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
|
||||
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>;
|
||||
@@ -158,33 +164,134 @@ type ViewState = {
|
||||
|
||||
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||||
switch (pageKey) {
|
||||
case "players": return playersSurface(e, data, view);
|
||||
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, view);
|
||||
default: return playersSurface(e, data, input, view);
|
||||
}
|
||||
}
|
||||
|
||||
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
|
||||
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" },
|
||||
statsStrip(e, [["用户", data.players.length], ["在线", data.players.filter(playerOnline).length], ["筛选结果", players.length], ["队伍成员", data.members.length]]),
|
||||
e("div", { className: "console-row-actions" },
|
||||
e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }),
|
||||
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" }, "离线/未知"))
|
||||
),
|
||||
players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" },
|
||||
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"), e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知")),
|
||||
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "steamId", "providerId") || "unknown"}`), e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "squadName", "squadId") || "未加入"}`), e("span", null, freshness(player))),
|
||||
e("span", { className: "provider-id" }, `Fame ${numField(player, "famePoints")} · Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")} · ${coords(positionOf(player))}`)
|
||||
)) : e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。")
|
||||
e("div", { className: "provider-table-wrap" },
|
||||
e("table", { className: "resource-table scum-user-table", style: { minWidth: "1180px" } },
|
||||
e("caption", { className: "provider-id" }, "SCUM 用户真实记录"),
|
||||
e("thead", null, e("tr", null, ["用户名", "Steam", "队伍", "状态", "上次登录", "登录 IP", "最后活动", "概况", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
|
||||
e("tbody", null, players.length ? players.slice(0, 500).map((player, index) => playerTableRow(e, player, index, data, input, view)) : e("tr", null, e("td", { colSpan: 9 }, 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 steamId = textField(player, "steamId", "providerId") || "未同步";
|
||||
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("span", { className: "provider-id" }, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`)),
|
||||
e("td", null, e("code", null, steamId)),
|
||||
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, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")),
|
||||
e("td", null, e("span", null, textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") || "未同步"), e("span", { className: "provider-id" }, textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") ? "日志同步" : "等待日志字段")),
|
||||
e("td", null, userDateField(player, "lastSeenAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")),
|
||||
e("td", null, e("span", null, `Fame ${numField(player, "famePoints")}`), e("span", { className: "provider-id" }, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`)),
|
||||
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: "provider-actions-cell" },
|
||||
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 }, "关闭")),
|
||||
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"));
|
||||
|
||||
@@ -10,9 +10,9 @@ export const configurationCatalog: readonly SCUMConfigField[] = [
|
||||
{ key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
|
||||
];
|
||||
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
|
||||
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }];
|
||||
export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); }
|
||||
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }, { key: "attributes.stamina", label: "体力", minimum: 0, maximum: 100000 }, { key: "attributes.dexterity", label: "敏捷", minimum: 0, maximum: 100000 }, { key: "attributes.intelligence", label: "智力", minimum: 0, maximum: 100000 }];
|
||||
export function supportsStateField(field: string): boolean { return /^[A-Za-z0-9_.:-]{1,120}$/.test(field); }
|
||||
export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; }
|
||||
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在插件目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
|
||||
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 未在插件运行时目录中声明。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围。`; } return null; }
|
||||
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { if (!supportsStateField(field.fieldKey)) return `字段 ${field.fieldKey} 格式无效。`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after)) return `字段 ${field.fieldKey} 必须是数字。`; } return null; }
|
||||
export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在插件目录中声明。"; return null; }
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"remote.run.files.read",
|
||||
"remote.run.files.write",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.db.sqlite.execute",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer",
|
||||
@@ -95,6 +96,7 @@
|
||||
"remote.run.files.read",
|
||||
"remote.run.files.write",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.db.sqlite.execute",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer",
|
||||
@@ -193,15 +195,6 @@
|
||||
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 4096
|
||||
},
|
||||
{
|
||||
"type": "game-state.patch",
|
||||
"title": "Patch SCUM player state",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json",
|
||||
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"maxPayloadBytes": 4096
|
||||
}
|
||||
],
|
||||
"snapshots": [
|
||||
@@ -485,14 +478,6 @@
|
||||
"player.notify"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "state.patch",
|
||||
"title": "SCUM player state patch",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"requiredHandlers": [
|
||||
"game-state.patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "vehicle.spawn",
|
||||
"title": "SCUM catalogued vehicle spawn",
|
||||
@@ -522,8 +507,7 @@
|
||||
"scum.positions"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"state.patch"
|
||||
"player.intelligence"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -898,8 +882,7 @@
|
||||
"remote.access.request"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"state.patch"
|
||||
"player.intelligence"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1357,7 +1340,8 @@
|
||||
"kind": "sqlite",
|
||||
"targetKey": "scum-database",
|
||||
"capabilities": [
|
||||
"remote.run.db.sqlite.query"
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.db.sqlite.execute"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -9,6 +9,6 @@
|
||||
"expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"reason": { "type": "string", "minLength": 4, "maxLength": 240 },
|
||||
"changes": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["fieldKey", "before", "after"], "properties": { "fieldKey": { "enum": ["skills.running", "attributes.strength"] }, "before": { "type": "number", "minimum": 0, "maximum": 10 }, "after": { "type": "number", "minimum": 0, "maximum": 10 } } } }
|
||||
"changes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["fieldKey", "before", "after"], "properties": { "fieldKey": { "type": "string", "minLength": 1, "maxLength": 120, "pattern": "^[A-Za-z0-9_.:-]+$" }, "before": { "type": "number" }, "after": { "type": "number" } } } }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"properties": {
|
||||
"status": { "enum": ["confirmed", "rejected", "failed"] },
|
||||
"confirmedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"confirmedFields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } },
|
||||
"confirmedFields": { "type": "object", "additionalProperties": false, "patternProperties": { "^[A-Za-z0-9_.:-]+$": { "type": "number" } } },
|
||||
"message": { "type": "string", "maxLength": 200 }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@
|
||||
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"maintenanceVerified": { "type": "boolean" },
|
||||
"playerOnline": { "type": "boolean" },
|
||||
"fields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } }
|
||||
"fields": { "type": "object", "additionalProperties": false, "patternProperties": { "^[A-Za-z0-9_.:-]+$": { "type": "number" } } }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user