diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index d8921d3..7d33619 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -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} diff --git a/plugins/examples/scum-server-plugin/features/page-data.ts b/plugins/examples/scum-server-plugin/features/page-data.ts index 9561d22..f17a544 100644 --- a/plugins/examples/scum-server-plugin/features/page-data.ts +++ b/plugins/examples/scum-server-plugin/features/page-data.ts @@ -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 = { 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"] }; diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index dd43e4f..1d34e86 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -26,6 +26,7 @@ import type { MapPlaybackFrame, MapPlaybackState, MapSceneData, MapTimeWindow, M type StateSetter = (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(react, { mode: "closed" }); const [mapBaseLayer, setMapBaseLayer] = usePluginState(react, undefined); const [mapZonesOverlay, setMapZonesOverlay] = usePluginState(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; mapTimeFrom: string; setMapTimeFrom: StateSetter; mapTimeTo: string; setMapTimeTo: StateSetter; mapUsers: string[] | undefined; setMapUsers: StateSetter; mapTrackKey: string; setMapTrackKey: StateSetter; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter; - eventEditorOpen: boolean; setEventEditorOpen: StateSetter; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter; + eventEditorOpen: boolean; setEventEditorOpen: StateSetter; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter; giftDialog: GiftDialogState; setGiftDialog: StateSetter; mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter; catalogSearch: string; setCatalogSearch: StateSetter; catalogKind: string; setCatalogKind: StateSetter; catalogOpen: boolean; setCatalogOpen: StateSetter; }; @@ -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)[value] ?? value; } function giftAudienceLabel(value: string): string { return (({ all: "全部玩家", pve: "PVE 玩家", pvp: "PVP 玩家" } as Record)[value] ?? value) || "全部玩家"; } function giftStatusLabel(value: string): string { return (({ active: "启用", enabled: "启用", running: "启用", scheduled: "启用", queued: "启用", disabled: "停用", inactive: "停用" } as Record)[value] ?? value) || "启用"; } diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 79a5726..e863f00 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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 } = {}) { +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 } = {}) { 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];