fix: make SCUM gift management gift-first

This commit is contained in:
npc0-hue
2026-09-20 12:13:06 +08:00
parent e03b33cf25
commit 4b7739a274
4 changed files with 70 additions and 24 deletions
+3 -1
View File
@@ -461,8 +461,10 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.icon-command.is-active{border-color:var(--accent);color:var(--ink);box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 46%,transparent)}
.scum-workbench{padding:12px;overflow:visible}.scum-workbench>.console-record-list{gap:8px}.scum-filter-bar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.scum-filter-bar .console-field{flex:0 1 210px}.scum-filter-bar .console-field:first-child{flex:1 1 320px}.scum-filter-bar .page-status{margin-left:auto}.scum-layer-toggle{display:inline-flex;align-items:center;gap:5px;flex:0 0 auto;min-height:28px;padding:0 8px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:11px;font-weight:760}.scum-filter-bar .scum-layer-toggle{min-height:26px;padding:0 8px;gap:5px;font-size:11px;white-space:nowrap}.scum-filter-bar .scum-layer-toggle input{flex:0 0 auto;width:14px;height:14px;min-width:0;min-height:0;margin:0;accent-color:var(--accent)}.scum-layer-group{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:0 0 auto}.scum-map-summary{margin-left:auto;white-space:nowrap}.scum-table-wrap{border-color:color-mix(in srgb,var(--line) 72%,transparent);background:linear-gradient(180deg,color-mix(in srgb,var(--surface-solid) 84%,transparent),color-mix(in srgb,var(--surface) 66%,transparent));-webkit-backdrop-filter:blur(8px) saturate(.92);backdrop-filter:blur(8px) saturate(.92);box-shadow:inset 0 1px 0 color-mix(in srgb,var(--crystal-rim) 46%,transparent)}.scum-table-wrap::before,.scum-table-wrap::after{content:none}.scum-user-table{min-width:1080px;table-layout:fixed}.scum-user-table caption{caption-side:top;padding:0 0 8px;text-align:left;color:var(--ink-faint);font-size:12px;font-weight:800}.scum-user-table th,.scum-user-table td{height:38px;padding:6px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);white-space:nowrap;font-size:13px;line-height:1.2}.scum-user-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep))}.scum-user-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-user-table th:nth-child(1),.scum-user-table td:nth-child(1){width:150px}.scum-user-table th:nth-child(2),.scum-user-table td:nth-child(2){width:170px}.scum-user-table th:nth-child(3),.scum-user-table td:nth-child(3){width:76px}.scum-user-table th:nth-child(4),.scum-user-table td:nth-child(4){width:116px}.scum-user-table th:nth-child(5),.scum-user-table td:nth-child(5){width:108px}.scum-user-table th:nth-child(6),.scum-user-table td:nth-child(6){width:108px}.scum-user-table th:nth-child(7),.scum-user-table td:nth-child(7){width:156px}.scum-user-table th:last-child,.scum-user-table td:last-child{width:76px;text-align:right}.scum-user-table td>*{max-width:100%;overflow:hidden;text-overflow:ellipsis}.scum-user-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-user-table td.provider-actions-cell>*{overflow:visible}.scum-mono-cell{font-family:var(--font-mono);font-size:12px}.scum-user-table .icon-command{min-height:28px;padding:0 9px}.inline-action-control{position:relative;display:inline-block}.inline-action-control>summary{list-style:none}.inline-action-control>summary::-webkit-details-marker{display:none}.inline-action-control[open]>summary{border-color:var(--accent);color:var(--ink)}.inline-action-control .inline-action-menu{right:0}
.gift-definitions-surface{display:grid;gap:12px;min-width:0}.gift-toolbar{margin:0;padding:0 0 10px;border-bottom:1px solid color-mix(in srgb,var(--line) 68%,transparent)}.gift-toolbar .console-field{flex:0 1 190px}.gift-toolbar .console-field:first-child{flex:1 1 320px}.gift-toolbar .page-status{margin-left:auto}.gift-create-command{width:auto;min-width:112px}.gift-table-wrap{min-width:0}.scum-gift-table{min-width:980px;table-layout:fixed}.scum-gift-table th,.scum-gift-table td{height:54px;padding:9px 10px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent);vertical-align:middle;font-size:12.5px}.scum-gift-table th{background:color-mix(in srgb,var(--accent-soft) 52%,transparent);color:color-mix(in srgb,var(--ink) 70%,var(--accent-deep));white-space:nowrap}.scum-gift-table tbody tr:hover{background:color-mix(in srgb,var(--accent-soft) 24%,transparent)}.scum-gift-table th:nth-child(1),.scum-gift-table td:nth-child(1){width:170px}.scum-gift-table th:nth-child(2),.scum-gift-table td:nth-child(2){width:122px}.scum-gift-table th:nth-child(3),.scum-gift-table td:nth-child(3){width:290px}.scum-gift-table th:nth-child(4),.scum-gift-table td:nth-child(4){width:120px}.scum-gift-table th:nth-child(5),.scum-gift-table td:nth-child(5){width:86px}.scum-gift-table th:nth-child(6),.scum-gift-table td:nth-child(6){width:148px}.scum-gift-table th:last-child,.scum-gift-table td:last-child{width:142px;text-align:right}.scum-gift-table td>*{max-width:100%;overflow-wrap:anywhere}.scum-gift-table td.provider-actions-cell{padding-right:8px;overflow:visible}.scum-gift-table td.provider-actions-cell>*{overflow:visible}.scum-gift-code{font-family:var(--font-mono);font-size:11px}.scum-gift-content-cell{display:flex;align-items:center;gap:8px;min-width:0}.scum-gift-content-cell>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scum-gift-table-actions{display:inline-flex;align-items:center;justify-content:flex-end;gap:6px}.scum-gift-table-actions .icon-command{min-height:30px;padding:0 9px;white-space:nowrap}.gift-form-wide{grid-column:1/-1}.gift-form-section{display:grid;gap:9px;padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 64%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}.gift-form-section-title{display:flex;align-items:baseline;justify-content:space-between;gap:10px;flex-wrap:wrap}.gift-form-section-title strong{color:var(--ink);font-size:13px}.gift-form-section-title span{color:var(--ink-faint);font-size:11px}.gift-dialog-heading{display:grid;gap:3px;min-width:0}.gift-dialog-heading h2{margin:0;color:var(--ink);font-size:18px}.gift-dialog-actions{grid-column:1/-1}.gift-dialog-actions .confirm-primary{min-width:128px}.gift-catalog-reference{margin-top:2px}
.gift-config-details{display:grid;gap:12px}.gift-config-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.gift-config-entry{display:grid;gap:4px;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.gift-config-entry dt{color:var(--ink-faint);font-size:11px;font-weight:750}.gift-config-entry dd{margin:0;color:var(--ink);font-size:13px;font-weight:780;overflow-wrap:anywhere}.gift-config-section{min-width:0}.gift-config-list,.gift-command-list{display:grid;gap:6px}.gift-config-row{display:grid;grid-template-columns:auto minmax(120px,1fr) minmax(160px,1.5fr) auto;align-items:center;gap:8px;min-width:0;padding:7px 8px;border-radius:6px;background:color-mix(in srgb,var(--accent-soft) 28%,transparent)}.gift-config-row code,.gift-command-list code{min-width:0;color:var(--ink-soft);font-family:var(--font-mono);font-size:11px;overflow-wrap:anywhere}.gift-command-list code{display:block;padding:8px;border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,transparent)}
:root[data-custom-background=true] .scum-workbench,:root[data-custom-background=true] .scum-table-wrap{background:linear-gradient(180deg,rgba(5,10,16,.76),rgba(5,10,16,.64));border-color:color-mix(in srgb,var(--line-strong) 54%,rgba(255,255,255,.18))}:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-workbench,:root[data-custom-background=true][data-theme-palette=magical-girl] .scum-table-wrap{background:linear-gradient(180deg,rgba(18,19,26,.74),rgba(13,15,22,.64));border-color:rgba(250,240,249,.34)}
@media (max-width:760px){.scum-filter-bar .page-status{margin-left:0}.scum-user-table{min-width:1040px}}
.scum-workbench.has-management-dialog,:root[data-theme-palette] .scum-workbench.has-management-dialog,:root[data-custom-background=true] .scum-workbench.has-management-dialog{-webkit-backdrop-filter:none;backdrop-filter:none;clip-path:none}
@media (max-width:760px){.scum-filter-bar .page-status{margin-left:0}.scum-user-table{min-width:1040px}.gift-config-grid{grid-template-columns:1fr}.gift-config-row{grid-template-columns:auto minmax(0,1fr) auto}.gift-config-row code{grid-column:2/-1}}
@media (max-width:760px){.scum-map-bar>.console-field,.scum-map-bar>.scum-map-menu-field,.scum-map-bar>.map-playback-bar{flex:1 1 100%;min-width:0}.map-playback-summary{white-space:normal}}
.provider-form input,.provider-form select,.provider-form textarea{width:100%;min-height:38px;border:1px solid var(--line-strong);border-radius:8px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit}
.provider-form textarea{min-height:120px;padding:10px;resize:vertical;font-family:var(--font-mono);font-size:13px}
@@ -85,11 +85,13 @@ type SCUMPlatformSurfaceKey = "players" | "vehicles" | "trajectories" | "vehicle
// Call budget per live-map load/15s refresh: 7 bounded plugin collections + 1 typed SCUM surface.
// Squads and members load once per refresh; hovering, selecting and playback issue 0 API calls.
// Call budget per gifts load/15s refresh: 5 bounded plugin collections + 1 typed SCUM surface.
// Searching, viewing a definition and opening the editor issue 0 additional API calls.
const pageCollections: Record<PageKey, PluginCollectionSurfaceKey[]> = {
players: ["members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
squads: ["squads", "members", "flags"],
"live-map": ["mapPoints", "mapRegions", "mapSettings", "flags", "squads", "members", "tradeGoods"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "tradeGoods"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents"],
workflows: ["events", "eventProduces", "eventRuns", "nativeEventRounds", "tasks", "activityEvents", "tradeGoods", "tradeEvents"]
};
@@ -26,6 +26,7 @@ import type { MapPlaybackFrame, MapPlaybackState, MapSceneData, MapTimeWindow, M
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; currentTarget?: { open?: boolean }; stopPropagation?: () => void };
type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
type GiftDialogState = { mode: "closed" } | { mode: "view" | "edit"; gift?: RecordMap };
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
type MapVehicleFilter = "all" | "land" | "water" | "air" | "other";
type ScumMapLayer = "terrain" | "topo" | "night";
@@ -145,7 +146,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const mapFollowRef = react.useRef ? react.useRef(true) : { current: true };
const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false);
const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false);
const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false);
const [giftDialog, setGiftDialog] = usePluginState<GiftDialogState>(react, { mode: "closed" });
const [mapBaseLayer, setMapBaseLayer] = usePluginState<ScumMapLayer | undefined>(react, undefined);
const [mapZonesOverlay, setMapZonesOverlay] = usePluginState<boolean | undefined>(react, undefined);
const [catalogSearch, setCatalogSearch] = usePluginState(react, "");
@@ -218,11 +219,11 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
setSelectedMapPoint("");
}, [input.serverInstanceId, pageKey, mapUsers, mapTimeFrom, mapTimeTo]);
if (react.useEffect) react.useEffect(() => {
if (!giftEditorOpen || typeof document === "undefined") return;
const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setGiftEditorOpen(false); };
if (giftDialog.mode === "closed" || typeof document === "undefined") return;
const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setGiftDialog({ mode: "closed" }); };
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [giftEditorOpen]);
}, [giftDialog.mode]);
if (react.useEffect) react.useEffect(() => {
if (pageKey !== "live-map") return;
if (!focusUser) {
@@ -263,7 +264,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
hoverMapPoint, setHoverMapPoint,
mapView, setMapView, mapViewportRef, mapPlaybackFrameRef, mapFollowRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo,
mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback,
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen,
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftDialog, setGiftDialog,
mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen,
setAction, refresh: () => setRefreshSignal((value) => value + 1),
mapData, mapSceneContents: null, mapStaticContents: null, mapSelectedTrails: [], mapSelectedLocks: []
@@ -285,7 +286,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const renderMapScene = () => mapData ? mapSceneContents(e, data, view, mapData) : null;
// Pan/zoom only changes the parent transform. Keep the geometry and marker elements stable.
view.mapSceneContents = react.useMemo ? react.useMemo(renderMapScene, [mapData, mapLayers, mapPlayback.value, mapPlayback.startedAt, hoverMapPoint]) : renderMapScene();
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
return e("section", { className: `console-panel scum-workbench${giftDialog.mode === "closed" ? "" : " has-management-dialog"}`, "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,
@@ -318,7 +319,7 @@ type ViewState = {
mapFollowRef: { current: boolean };
mapTimePreset: string; setMapTimePreset: StateSetter<string>; mapTimeFrom: string; setMapTimeFrom: StateSetter<string>; mapTimeTo: string; setMapTimeTo: StateSetter<string>;
mapUsers: string[] | undefined; setMapUsers: StateSetter<string[] | undefined>; mapTrackKey: string; setMapTrackKey: StateSetter<string>; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter<MapPlaybackState>;
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter<boolean>;
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftDialog: GiftDialogState; setGiftDialog: StateSetter<GiftDialogState>;
mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter<ScumMapLayer | undefined>; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter<boolean | undefined>;
catalogSearch: string; setCatalogSearch: StateSetter<string>; catalogKind: string; setCatalogKind: StateSetter<string>; catalogOpen: boolean; setCatalogOpen: StateSetter<boolean>;
};
@@ -601,10 +602,10 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const actions = input.workspaceActions;
const openNewGift = () => {
view.setGiftRecordKey(""); view.setGiftCode(""); view.setGiftName(""); view.setGiftItems(""); view.setGiftCommands(""); view.setGiftClass("5"); view.setGiftAudience("all"); view.setGiftNumber("1"); view.setGiftAchievement("0"); view.setGiftAchievementNumber("0"); view.setGiftEditorOpen(true);
view.setGiftRecordKey(""); view.setGiftCode(""); view.setGiftName(""); view.setGiftItems(""); view.setGiftCommands(""); view.setGiftClass("5"); view.setGiftAudience("all"); view.setGiftNumber("1"); view.setGiftAchievement("0"); view.setGiftAchievementNumber("0"); view.setGiftDialog({ mode: "edit" });
};
const openGiftEditor = (gift: RecordMap) => {
view.setGiftRecordKey(textField(gift, "_recordKey", "code", "id")); 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)); view.setGiftEditorOpen(true);
const openGiftDialog = (gift: RecordMap, mode: "view" | "edit") => {
view.setGiftRecordKey(textField(gift, "_recordKey", "code", "id")); 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)); view.setGiftDialog({ mode, gift });
};
const saveGift = () => runAction(view.setAction, "正在保存礼包定义…", async () => {
const code = view.giftCode.trim();
@@ -623,10 +624,11 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
status: "active", updatedAt: new Date().toISOString()
});
if (view.giftRecordKey && view.giftRecordKey !== code) await deleteGiftDefinition(actions ?? {}, view.giftRecordKey);
view.setGiftEditorOpen(false); view.setGiftRecordKey("");
view.setGiftDialog({ mode: "closed" }); view.setGiftRecordKey("");
view.refresh();
return `礼包 ${name} 已保存。`;
});
const selectedGift = view.giftDialog.mode === "closed" ? undefined : view.giftDialog.gift;
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");
@@ -644,7 +646,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
return matchesText(gift, search, "code", "name", "audience", "status") && (view.giftStatus === "all" || statusFilter === view.giftStatus);
});
return e("div", { className: "console-record-list" },
statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.length], ["物品列表", data.tradeGoods.length]]),
statsStrip(e, [["礼包定义", data.gifts.length], ["领取/待领", data.giftClaims.length + data.pendingGifts.length], ["发放记录", data.giftDeliveries.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", "游戏定时记录")
),
@@ -669,10 +671,11 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
e("td", null, e("span", { className: `status-pill ${activeStatus(status) ? "status-active" : "status-disabled"}` }, giftStatusLabel(status))),
e("td", null, e("span", { className: "provider-id" }, dateField(gift, "updatedAt", "createdAt"))),
e("td", { className: "provider-actions-cell" }, e("div", { className: "scum-gift-table-actions" },
e("button", { type: "button", className: "icon-command", onClick: () => openGiftEditor(gift) }, "编辑"),
e("button", { type: "button", className: "icon-command", onClick: () => openGiftDialog(gift, "view") }, "查看配置"),
e("details", { className: "inline-action-control" },
e("summary", { className: "icon-command", "aria-label": `打开${textField(gift, "name") || key}更多操作` }, "更多"),
e("div", { className: "inline-action-menu", role: "menu", "aria-label": "礼包操作" },
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => openGiftDialog(gift, "edit") }, "编辑礼包"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => { view.setDeliveryGift(key); view.setGiftTab("deliveries"); } }, "选择发放"),
e("button", { type: "button", className: "runtime-action-item danger-command", role: "menuitem", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除")
)
@@ -683,7 +686,6 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
)
),
) : null,
view.giftTab === "definitions" ? itemCatalogPanel(e, data.tradeGoods) : 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)
@@ -700,15 +702,47 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
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,
view.giftEditorOpen ? giftEditorDialog(e, view, actions, saveGift) : null
view.giftDialog.mode === "view" && selectedGift ? giftDetailsDialog(e, selectedGift, view, () => openGiftDialog(selectedGift, "edit")) : null,
view.giftDialog.mode === "edit" ? giftEditorDialog(e, view, actions, saveGift) : null
);
}
function giftDetailsDialog(e: ReactLike["createElement"], gift: RecordMap, view: ViewState, editGift: () => void) {
const items = giftItemRecords(gift);
const commands = giftCommandValues(gift);
const status = textField(gift, "status").toLowerCase() || "active";
return e("div", { className: "confirm-backdrop management-dialog-backdrop gift-dialog-backdrop", role: "presentation", onClick: () => view.setGiftDialog({ mode: "closed" }) },
e("div", { className: "drawer-panel management-dialog-panel management-dialog-wide", role: "dialog", "aria-modal": "true", "aria-label": "查看礼包配置", onClick: (event: InputEvent) => event.stopPropagation?.() },
e("div", { className: "panel-header dialog-panel-header" }, e("div", { className: "gift-dialog-heading" }, e("h2", null, textField(gift, "name") || textField(gift, "code") || "礼包配置"), e("span", { className: "page-status" }, "礼包配置详情")), e("button", { type: "button", className: "theme-upload drawer-close", "aria-label": "关闭礼包配置", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭")),
e("div", { className: "gift-config-details dialog-form" },
e("dl", { className: "gift-config-grid" },
giftConfigEntry(e, "礼包编号", textField(gift, "code", "id") || "未配置"),
giftConfigEntry(e, "状态", giftStatusLabel(status)),
giftConfigEntry(e, "领取周期", giftClassLabel(numField(gift, "class"))),
giftConfigEntry(e, "适用玩家", giftAudienceLabel(textField(gift, "audience"))),
giftConfigEntry(e, "发放次数", numField(gift, "number")),
giftConfigEntry(e, "成就门槛", `${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)
),
e("section", { className: "gift-form-section gift-config-section", "aria-label": "礼包内容" },
e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包内容"), e("span", null, `${items.length} 项物品,${commands.length} 条命令`)),
items.length ? e("div", { className: "gift-config-list" }, items.map((item, index) => {
const code = textField(item, "catalogCode", "catalogItemKey", "key", "className");
const entry = scumCatalogEntryFor(code);
return e("div", { key: `${code}-${index}`, className: "gift-config-row" }, catalogIconStrip(e, catalogIconsForValue(code)), e("span", null, entry?.name || textField(item, "label") || code || "未命名物品"), e("code", null, code || "unknown"), e("strong", null, `× ${numField(item, "quantity")}`));
})) : e("p", { className: "page-status" }, "未配置物品。"),
commands.length ? e("div", { className: "gift-command-list", "aria-label": "礼包命令" }, commands.map((command, index) => e("code", { key: `${command}-${index}` }, command))) : e("p", { className: "page-status" }, "未配置附加命令。")
),
e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭"), e("button", { type: "button", className: "confirm-primary", onClick: editGift }, "编辑礼包"))
)
)
);
}
function giftEditorDialog(e: ReactLike["createElement"], view: ViewState, actions: SCUMWorkspaceActions | undefined, saveGift: () => void) {
const editing = Boolean(view.giftRecordKey);
return e("div", { className: "confirm-backdrop management-dialog-backdrop gift-dialog-backdrop", role: "presentation", onClick: () => view.setGiftEditorOpen(false) },
return e("div", { className: "confirm-backdrop management-dialog-backdrop gift-dialog-backdrop", role: "presentation", onClick: () => view.setGiftDialog({ mode: "closed" }) },
e("div", { className: "drawer-panel management-dialog-panel management-dialog-wide", role: "dialog", "aria-modal": "true", "aria-label": editing ? "编辑礼包" : "新建礼包", onClick: (event: InputEvent) => event.stopPropagation?.() },
e("div", { className: "panel-header dialog-panel-header" }, e("div", { className: "gift-dialog-heading" }, e("h2", null, editing ? "编辑礼包" : "新建礼包"), e("span", { className: "page-status" }, editing ? "更新现有礼包定义" : "创建可领取的运营礼包")), e("button", { type: "button", className: "theme-upload drawer-close", "aria-label": "关闭礼包编辑器", onClick: () => view.setGiftEditorOpen(false) }, "关闭")),
e("div", { className: "panel-header dialog-panel-header" }, e("div", { className: "gift-dialog-heading" }, e("h2", null, editing ? "编辑礼包" : "新建礼包"), e("span", { className: "page-status" }, editing ? "更新现有礼包定义" : "创建可领取的运营礼包")), e("button", { type: "button", className: "theme-upload drawer-close", "aria-label": "关闭礼包编辑器", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "关闭")),
e("div", { className: "gift-form dialog-form management-form" },
e("div", { className: "form-guidance management-form-wide" }, e("strong", null, "先定义规则,再配置内容"), e("span", null, "礼包编号用于数据主键,只支持字母、数字、点、下划线和短横线;礼包至少要包含一项物品或一条命令。")),
labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })),
@@ -720,7 +754,7 @@ function giftEditorDialog(e: ReactLike["createElement"], view: ViewState, action
labeledField(e, "成就值", e("input", { value: view.giftAchievementNumber, "aria-label": "成就值", type: "number", min: "0", placeholder: "0", onChange: (event: InputEvent) => view.setGiftAchievementNumber(inputValue(event)) })),
e("div", { className: "gift-form-wide gift-form-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包物品"), e("span", null, "可用目录选择器快速加入,格式为 目录代码:数量")), labeledField(e, "物品清单", e("input", { value: view.giftItems, "aria-label": "礼包物品", placeholder: "例如 BP_Cash_01:2, Water-Bottle.01:1", onChange: (event: InputEvent) => view.setGiftItems(inputValue(event)) })), catalogPicker(e, view, "gift-items")),
e("div", { className: "gift-form-wide gift-form-section" }, e("div", { className: "gift-form-section-title" }, e("strong", null, "礼包命令"), e("span", null, "每行一条 SCUM RCON 命令,可选")), labeledField(e, "命令清单", e("textarea", { value: view.giftCommands, "aria-label": "礼包命令", placeholder: "例如 #announce Welcome\n每行一条命令", onChange: (event: InputEvent) => view.setGiftCommands(inputValue(event)) }))),
e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftEditorOpen(false) }, "取消"), e("button", { type: "button", className: "confirm-primary", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包"))
e("div", { className: "confirm-actions gift-dialog-actions" }, e("button", { type: "button", onClick: () => view.setGiftDialog({ mode: "closed" }) }, "取消"), e("button", { type: "button", className: "confirm-primary", disabled: !actions?.pluginData, onClick: saveGift }, "保存礼包"))
)
)
);
@@ -1500,6 +1534,9 @@ function giftItemsSummary(gift: RecordMap): string { const items = field(gift, "
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 giftItemRecords(gift: RecordMap): RecordMap[] { const items = field(gift, "items"); return Array.isArray(items) ? items.filter(isRecord) : []; }
function giftCommandValues(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) : []; }
function giftConfigEntry(e: ReactLike["createElement"], label: string, value: string) { return e("div", { className: "gift-config-entry" }, e("dt", null, label), e("dd", null, value)); }
function giftClassLabel(value: string): string { return ({ "1": "每日", "2": "每周", "3": "每月", "4": "每年", "5": "一次", "6": "每日五次" } as Record<string, string>)[value] ?? value; }
function giftAudienceLabel(value: string): string { return (({ all: "全部玩家", pve: "PVE 玩家", pvp: "PVP 玩家" } as Record<string, string>)[value] ?? value) || "全部玩家"; }
function giftStatusLabel(value: string): string { return (({ active: "启用", enabled: "启用", running: "启用", scheduled: "启用", queued: "启用", disabled: "停用", inactive: "停用" } as Record<string, string>)[value] ?? value) || "启用"; }
+9 -4
View File
@@ -94,7 +94,7 @@ describe("SCUM plugin feature module", () => {
const list = vi.fn(async (collection: string) => ({ items: [{ key: `${collection}-1`, value: { collection } }], count: 1 }));
const scum = scumActions();
const data = await loadSCUMSurface({ pluginData: pluginDataActions({ list }), scum }, "gifts");
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents, scumCollections.tradeGoods]);
expect(list.mock.calls.map(([collection]) => collection)).toEqual([scumCollections.gifts, scumCollections.giftClaims, scumCollections.pendingGifts, scumCollections.giftDeliveries, scumCollections.timedGiftEvents]);
expect(scum.surface).toHaveBeenCalledTimes(1);
expect(data.gifts[0]).toMatchObject({ collection: scumCollections.gifts, _recordKey: `${scumCollections.gifts}-1` });
});
@@ -271,11 +271,12 @@ describe("SCUM plugin feature module", () => {
it("renders gift definitions, claims, and delivery records", () => {
const definitions = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(definitions.texts).toContain("礼包定义");
expect(definitions.texts).toContain("物品列表");
expect(definitions.texts).not.toContain("物品列表");
expect(definitions.texts).toContain("Starter Pack");
expect(definitions.texts).toContain("Cargo Drop");
expect(definitions.texts).not.toContain("Cargo Drop");
expect(definitions.texts).toEqual(expect.arrayContaining(["礼包", "领取规则", "礼包内容", "次数 / 门槛", "状态", "最近更新", "操作"]));
expect(definitions.buttons.find((button) => button.label === "新建礼包")?.disabled).toBe(false);
expect(definitions.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["查看配置", "编辑礼包"]));
expect(definitions.buttons.find((button) => button.label === "保存礼包")).toBeUndefined();
expect(definitions.texts).not.toContain("先定义规则,再配置内容");
expect(definitions.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["搜索礼包", "礼包状态"]));
@@ -287,6 +288,9 @@ describe("SCUM plugin feature module", () => {
expect(deliveries.texts).toContain("发放记录");
expect(deliveries.texts).toContain("delivered");
expect(deliveries.buttons.find((button) => button.label === "立即发放")?.disabled).toBe(false);
const details = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理", giftDialog: { mode: "view", gift: surfaceData.gifts[0] } });
expect(details.texts).toEqual(expect.arrayContaining(["礼包配置详情", "礼包编号", "starter-pack", "领取周期", "一次", "礼包内容", "BP_Cash_01", "#announce Starter pack"]));
expect(details.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["关闭", "编辑礼包"]));
});
it("renders map layers, filter controls, points, and selected-point details", () => {
@@ -504,7 +508,7 @@ function scumActions(overrides: Partial<NonNullable<SCUMWorkspaceActions["scum"]
};
}
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string; pageQuery?: Record<string, string> } = {}) {
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; giftDialog?: { mode: "closed" | "view" | "edit"; gift?: RecordMap }; playerSearch?: string; pageQuery?: Record<string, string> } = {}) {
const nodes: string[] = [];
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = [];
@@ -528,6 +532,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
stateCall += 1;
if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } as T, () => undefined];
if (stateCall === 3 && options.playerSearch !== undefined) return [options.playerSearch as T, () => undefined];
if (options.giftDialog && typeof initial === "object" && initial !== null && "mode" in initial && (initial as { mode?: string }).mode === "closed") return [options.giftDialog as T, () => undefined];
const value = typeof initial === "function" ? (initial as () => T)() : initial;
if (options.giftTab && value === "definitions") return [options.giftTab as T, () => undefined];
return [value, () => undefined];