Tighten SCUM plugin console UI

This commit is contained in:
npc0-hue
2026-08-27 09:41:46 +08:00
parent feec02dc14
commit 1c52703b3e
5 changed files with 39 additions and 22 deletions
@@ -175,6 +175,7 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).toContain("plugin:${page.key}");
expect(serverDetailPageSource).toContain("ServerDetailSectionNav");
expect(serverDetailPageSource).toContain('aria-label="游戏运营功能"');
expect(serverDetailPageSource).toContain("server-plugin-section-picker-active");
expect(serverDetailPageSource).toContain('section === "files"');
expect(serverDetailPageSource).toContain("PluginPageSection");
expect(serverDetailPageSource).toContain("PluginPageHostPage");
+1 -1
View File
@@ -272,7 +272,7 @@ function ServerDetailSectionNav({ entries, section, onChange }: { entries: Array
return (
<nav className="section-tabs" aria-label="server sections">
{pluginEntries.length > 0 && (
<label className="server-plugin-section-picker">
<label className={cx("server-plugin-section-picker", activePluginSection && "server-plugin-section-picker-active")}>
<span></span>
<select aria-label="游戏运营功能" value={activePluginSection} onChange={(event) => event.target.value && onChange(event.target.value as ServerDetailSection)}>
<option value="" disabled></option>
+6 -3
View File
@@ -312,7 +312,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
){overflow:visible}
:root[data-custom-background=true] .global-particle-layer{opacity:.42}
:root[data-custom-background=true][data-theme-palette=magical-girl] .global-particle-layer{opacity:.54}
:root[data-custom-background=true] .log-filter-bar input,:root[data-custom-background=true] .log-filter-bar select,:root[data-custom-background=true] .profile-form input,:root[data-custom-background=true] .profile-settings-form input,:root[data-custom-background=true] .profile-settings-form textarea,:root[data-custom-background=true] .provider-form input,:root[data-custom-background=true] .provider-form select,:root[data-custom-background=true] .provider-form textarea,:root[data-custom-background=true] .server-toolbar input[type=search],:root[data-custom-background=true] .server-toolbar select{background:rgba(5,10,16,.94);border-color:color-mix(in srgb,var(--accent) 48%,rgba(255,255,255,.18));color:rgba(247,253,255,.96);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 8px 18px rgba(0,0,0,.18)}
:root[data-custom-background=true] .console-field input:not([type=checkbox]):not([type=radio]),:root[data-custom-background=true] .console-field select,:root[data-custom-background=true] .console-field textarea,:root[data-custom-background=true] .log-filter-bar input,:root[data-custom-background=true] .log-filter-bar select,:root[data-custom-background=true] .profile-form input,:root[data-custom-background=true] .profile-settings-form input,:root[data-custom-background=true] .profile-settings-form textarea,:root[data-custom-background=true] .provider-form input,:root[data-custom-background=true] .provider-form select,:root[data-custom-background=true] .provider-form textarea,:root[data-custom-background=true] .server-toolbar input[type=search],:root[data-custom-background=true] .server-toolbar select{background:rgba(5,10,16,.94);border-color:color-mix(in srgb,var(--accent) 48%,rgba(255,255,255,.18));color:rgba(247,253,255,.96);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 8px 18px rgba(0,0,0,.18)}
:root[data-custom-background=true] .action-strip button,:root[data-custom-background=true] .app-account-button,:root[data-custom-background=true] .app-session,:root[data-custom-background=true] .background-preset-option,:root[data-custom-background=true] .console-row-actions button,:root[data-custom-background=true] .icon-command,:root[data-custom-background=true] .palette-option,:root[data-custom-background=true] .row-actions button,:root[data-custom-background=true] .segmented-button,:root[data-custom-background=true] .state-action,:root[data-custom-background=true] .theme-upload{background:linear-gradient(135deg,rgba(255,255,255,.055),transparent 34%),rgba(5,10,16,.84);border-color:color-mix(in srgb,var(--line-strong) 40%,rgba(255,255,255,.14));color:rgba(229,246,251,.9);box-shadow:inset 0 1px 0 rgba(255,255,255,.12),0 7px 16px rgba(0,0,0,.16)}
:root[data-custom-background=true] .background-preset-option-active,:root[data-custom-background=true] .palette-option-active,:root[data-custom-background=true] .segmented-button-active{background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 10%,transparent),rgba(5,10,16,.78));border-color:color-mix(in srgb,var(--accent) 58%,rgba(255,255,255,.18));color:rgba(247,253,255,.96);box-shadow:inset 0 1px 0 rgba(255,255,255,.16),0 0 0 1px color-mix(in srgb,var(--accent) 18%,transparent),0 9px 20px rgba(0,0,0,.2)}
:root[data-custom-background=true] .primary-command,:root[data-custom-background=true] .profile-save-button{background:linear-gradient(135deg,color-mix(in srgb,var(--accent) 78%,#fff 6%),color-mix(in srgb,var(--pink) 58%,#0b1620 42%));border-color:color-mix(in srgb,var(--accent-deep) 72%,rgba(255,255,255,.22));color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.36);box-shadow:0 10px 24px rgba(0,0,0,.28),0 0 18px color-mix(in srgb,var(--accent) 24%,transparent)}
@@ -440,8 +440,11 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.field-help{color:var(--ink-faint);font-size:11.5px;font-weight:600;line-height:1.4}
.form-helper-actions{display:flex;gap:8px;flex-wrap:wrap}
.provider-form label{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:700}
.console-field{display:grid;gap:5px;min-width:0;color:var(--ink-soft);font-size:12px;font-weight:700}.console-field>span{line-height:1.25}.console-field input,.console-field select,.console-field textarea{min-width:0}
.console-field{display:grid;gap:5px;min-width:0;color:var(--ink-soft);font-size:12px;font-weight:760}.console-field>span{line-height:1.25}.console-field input:not([type=checkbox]):not([type=radio]),.console-field select,.console-field textarea{width:100%;min-width:0;min-height:36px;border:1px solid var(--line-strong);border-radius:6px;padding:0 10px;background:var(--surface-solid);color:var(--ink);font:inherit;font-size:13px;box-shadow:inset 0 1px 0 var(--crystal-rim)}.console-field textarea{min-height:112px;padding:9px;resize:vertical;font-family:var(--font-mono)}.console-field input::placeholder,.console-field textarea::placeholder{color:color-mix(in srgb,var(--ink-faint) 82%,transparent)}.console-field input:focus,.console-field select:focus,.console-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.scum-editor>summary{display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:pointer;list-style:none}.scum-editor>summary::-webkit-details-marker{display:none}.scum-editor>summary::after{content:"+";color:var(--accent);font-size:18px;font-weight:400}.scum-editor[open]>summary::after{content:"-"}.scum-editor[open]>summary{margin-bottom:12px}.scum-editor .console-row-actions{align-items:flex-end}.scum-editor .console-row-actions>.console-field{flex:1 1 140px;max-width:260px}.scum-player-overview{margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--line)}
.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:6px;min-height:34px;padding:0 9px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:760}.scum-layer-toggle input{accent-color:var(--accent)}.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:720px;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{padding:9px 14px;border-bottom-color:color-mix(in srgb,var(--line) 64%,transparent)}.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:24%}.scum-user-table th:nth-child(2),.scum-user-table td:nth-child(2){width:22%}.scum-user-table th:nth-child(3),.scum-user-table td:nth-child(3){width:16%}.scum-user-table th:nth-child(4),.scum-user-table td:nth-child(4){width:26%}.scum-user-table th:last-child,.scum-user-table td:last-child{width:120px;text-align:right}.scum-user-table .icon-command{min-height:32px;padding:0 10px}.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}
: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){.server-plugin-section-picker select{max-width:120px}.scum-filter-bar .page-status{margin-left:0}.scum-user-table{min-width:640px}}
.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}
.provider-form input:focus,.provider-form select:focus,.provider-form textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
@@ -493,7 +496,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-detail-title-row>div:first-child{min-width:0}
.server-detail-title-row h1{margin:0;font-size:24px;color:var(--ink);overflow-wrap:anywhere}
.section-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 72%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.server-plugin-section-picker{display:flex;align-items:center;gap:8px;min-height:38px;padding:0 10px;border:1px solid var(--line-strong);border-radius:8px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:700;white-space:nowrap}.server-plugin-section-picker select{min-width:132px;height:30px;border:0;border-left:1px solid var(--line);border-radius:0;padding:0 24px 0 9px;background:transparent;color:var(--ink);font:inherit;outline:0}.server-plugin-section-picker:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}
.server-plugin-section-picker{display:inline-flex;align-items:center;gap:7px;min-height:38px;padding:0 12px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap}.server-plugin-section-picker>span{color:inherit;opacity:.82}.server-plugin-section-picker select{width:auto;min-width:96px;max-width:146px;height:30px;border:0;border-left:1px solid color-mix(in srgb,var(--line) 72%,transparent);border-radius:0;padding:0 20px 0 8px;background:transparent;color:var(--ink);font:inherit;outline:0}.server-plugin-section-picker select option{background:#061019;color:#eaf7fb}.server-plugin-section-picker-active{border-color:var(--accent-deep);background:var(--primary-command-surface);color:#fff;box-shadow:0 10px 24px var(--candy-glow),inset 0 1px 0 var(--crystal-rim),0 0 18px var(--moonbeam)}.server-plugin-section-picker-active select{color:#fff}.server-plugin-section-picker:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}
.section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap}
.section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0}
.section-tab-active{background:var(--primary-command-surface);border-color:var(--accent-deep);color:#fff;font-weight:700;box-shadow:0 10px 24px var(--candy-glow),inset 0 1px 0 var(--crystal-rim),0 0 18px var(--moonbeam)}
@@ -24,7 +24,7 @@ import {
} from "./page-data.js";
type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
type InputEvent = { target?: { value?: string; checked?: boolean }; stopPropagation?: () => void };
type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void };
type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
type PlayerPanelKind = "closed" | "attributes" | "gifts" | "items" | "history" | "trajectory";
@@ -103,6 +103,10 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [mapCenterY, setMapCenterY] = usePluginState(react, "");
const [mapWidthKm, setMapWidthKm] = usePluginState(react, "");
const [mapHeightKm, setMapHeightKm] = usePluginState(react, "");
const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false);
const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false);
const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false);
const [mapSettingsOpen, setMapSettingsOpen] = usePluginState(react, false);
const pageKey = input.pageKey ?? "players";
const refresh = () => {
@@ -123,7 +127,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind]);
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null,
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
@@ -135,6 +139,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber,
deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint,
mapCustomEnabled, setMapCustomEnabled, mapCenterX, setMapCenterX, mapCenterY, setMapCenterY, mapWidthKm, setMapWidthKm, mapHeightKm, setMapHeightKm,
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen, mapSettingsOpen, setMapSettingsOpen,
setAction, refresh
}) : null
);
@@ -158,6 +163,7 @@ type ViewState = {
mapSearch: string; setMapSearch: StateSetter<string>; mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
mapCustomEnabled: boolean | undefined; setMapCustomEnabled: StateSetter<boolean | undefined>; mapCenterX: string; setMapCenterX: StateSetter<string>; mapCenterY: string; setMapCenterY: StateSetter<string>; mapWidthKm: string; setMapWidthKm: StateSetter<string>; mapHeightKm: string; setMapHeightKm: StateSetter<string>;
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter<boolean>; mapSettingsOpen: boolean; setMapSettingsOpen: StateSetter<boolean>;
};
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
@@ -176,12 +182,13 @@ function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, in
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" },
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" }, "离线/未知"))
return e("div", { className: "console-record-list scum-surface" },
e("div", { className: "resource-filter-bar scum-filter-bar" },
labeledField(e, "搜索用户", e("input", { type: "search", value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) })),
labeledField(e, "在线状态", e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知"))),
e("span", { className: "page-status" }, `${players.length} / ${data.players.length} 用户`)
),
e("div", { className: "provider-table-wrap" },
e("div", { className: "provider-table-wrap scum-table-wrap" },
e("table", { className: "resource-table scum-user-table" },
e("caption", { className: "provider-id" }, "SCUM 用户真实记录"),
e("thead", null, e("tr", null, ["用户名", "队伍", "状态", "最后活动", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
@@ -205,7 +212,7 @@ function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index:
}
function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view: ViewState, open: (kind: PlayerPanelKind) => void) {
return e("details", { className: "provider-actions-cell" },
return e("details", { className: "inline-action-control" },
e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"),
e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" },
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("attributes") }, "编辑属性"),
@@ -299,7 +306,7 @@ function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, vie
return e("div", { className: "overview-two-col" },
e("article", { className: "console-module" },
e("div", { className: "panel-header" }, e("h2", null, "队伍"), e("span", { className: "page-status" }, `${squads.length}`)),
e("input", { value: view.squadSearch, "aria-label": "搜索队伍", placeholder: "队名 / 队长 / 队伍 ID", onChange: (event: InputEvent) => view.setSquadSearch(inputValue(event)) }),
e("div", { className: "resource-filter-bar scum-filter-bar" }, labeledField(e, "搜索队伍", e("input", { type: "search", value: view.squadSearch, "aria-label": "搜索队伍", placeholder: "队名 / 队长 / 队伍 ID", onChange: (event: InputEvent) => view.setSquadSearch(inputValue(event)) }))),
e("div", { className: "console-row-list" }, squads.length ? squads.map((squad, index) => {
const squadId = textField(squad, "squadId", "id");
const memberCount = data.members.filter((member) => textField(member, "squadId") === squadId).length;
@@ -356,7 +363,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
return e("div", { className: "console-record-list" },
statsStrip(e, [["活动定义", data.events.length], ["运行记录", data.eventRuns.length], ["原生赛事轮次", data.nativeEventRounds.length], ["任务", data.tasks.length]]),
e("div", { className: "overview-two-col" },
e("details", { className: "console-module scum-editor", open: Boolean(view.eventId || view.eventName) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")),
e("details", { className: "console-module scum-editor", open: view.eventEditorOpen, onToggle: (event: InputEvent) => view.setEventEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新活动"), e("span", { className: "page-status" }, view.eventId || view.eventName ? "编辑中" : "点击展开")),
labeledField(e, "活动编号", e("input", { value: view.eventId, "aria-label": "活动编号", placeholder: "例如 event_supply_drop", onChange: (event: InputEvent) => view.setEventId(inputValue(event)) })),
labeledField(e, "活动名称", e("input", { value: view.eventName, "aria-label": "活动名称", placeholder: "例如 空投补给", onChange: (event: InputEvent) => view.setEventName(inputValue(event)) })),
labeledField(e, "生成类型", e("select", { value: view.eventClass, "aria-label": "生成类型", onChange: (event: InputEvent) => view.setEventClass(inputValue(event)) }, e("option", { value: "1" }, "范围生成"), e("option", { value: "2" }, "固定坐标生成"))),
@@ -376,7 +383,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
e("p", { className: "page-status" }, "活动定义属于插件;SCUM 原生 event_round 和 quest/task 只作为运行事实展示。")
)
),
e("details", { className: "console-module scum-editor", open: Boolean(view.produceEventId || view.produceId || view.produceTradeGoodsId) }, e("summary", null, e("strong", null, "活动生成项"), e("span", { className: "page-status" }, view.produceEventId || view.produceId ? "编辑中" : "点击展开")),
e("details", { className: "console-module scum-editor", open: view.produceEditorOpen, onToggle: (event: InputEvent) => view.setProduceEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "活动生成项"), e("span", { className: "page-status" }, view.produceEventId || view.produceId ? "编辑中" : "点击展开")),
e("div", { className: "console-row-actions" },
labeledField(e, "活动编号", e("input", { value: view.produceEventId, "aria-label": "生成项活动编号", placeholder: "默认使用上方活动", onChange: (event: InputEvent) => view.setProduceEventId(inputValue(event)) })),
labeledField(e, "生成项编号", e("input", { value: view.produceId, "aria-label": "生成项编号", placeholder: "更新时填写", onChange: (event: InputEvent) => view.setProduceId(inputValue(event)) })),
@@ -393,7 +400,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
e("span", null, `${textField(produce, "eventId", "event")} / ${textField(produce, "tradeGoodsId", "trade_goods_id")}`),
e("strong", null, `${numField(produce, "percent")}% × ${numField(produce, "value")}`),
e("strong", null, `R ${numField(produce, "r")} · ${coords(produce)}`),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setProduceEditorOpen(true); view.setProduceEventId(textField(produce, "eventId", "event")); view.setProduceId(textField(produce, "id", "produceId")); view.setProduceTradeGoodsId(textField(produce, "tradeGoodsId", "trade_goods_id")); view.setProducePercent(numField(produce, "percent")); view.setProduceValue(numField(produce, "value")); view.setProduceRadius(numField(produce, "r")); view.setProduceX(numField(produce, "x")); view.setProduceY(numField(produce, "y")); view.setProduceZ(numField(produce, "z")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除生成项…", async () => { await deleteEventProduce(actions ?? {}, produce); view.refresh(); return "生成项已删除。"; }) }, "删除"))) : e("p", { className: "page-status" }, "暂无活动生成项。"))
),
events.length ? events.map((event, index) => {
@@ -405,7 +412,7 @@ function activitiesSurface(e: ReactLike["createElement"], data: SCUMSurfaceData,
e("div", { className: "console-record-meta" }, e("span", null, `生成 ${Number(field(event, "class")) === 2 ? "固定坐标" : "范围"}`), e("span", null, `计划 ${textField(event, "schedule", "corn") || "手动"}`), e("span", null, `概率 ${numField(event, "percent", "probability")}%`), e("span", null, `NPC/物品/僵尸/动物 ${numField(event, "npc")}/${numField(event, "item")}/${numField(event, "zombie")}/${numField(event, "animal")}`), e("span", null, textField(event, "placard", "announcement") || "无公告")),
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "primary-command", disabled: !actions?.gameClient || !actions?.pluginData, onClick: () => runAction(view.setAction, "正在启动活动…", async () => { await startEvent(actions ?? {}, event, data.eventProduces.filter((produce) => textField(produce, "eventId", "event") === eventId)); view.refresh(); return "活动命令已进入执行队列。"; }) }, "立即启动"),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", onClick: () => { view.setEventEditorOpen(true); view.setEventId(eventId); view.setEventName(textField(event, "name")); view.setEventClass(numField(event, "class") === "--" ? "1" : numField(event, "class")); view.setEventSchedule(textField(event, "schedule", "corn")); view.setEventPlacard(textField(event, "placard", "announcement")); view.setEventPercent(numField(event, "percent", "probability")); view.setEventDuration(numField(event, "durationSeconds") === "--" ? "1800" : numField(event, "durationSeconds")); view.setEventNpc(numField(event, "npc")); view.setEventItem(numField(event, "item")); view.setEventZombie(numField(event, "zombie")); view.setEventAnimal(numField(event, "animal")); } }, "编辑"),
e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除活动…", async () => { await deleteEventDefinition(actions ?? {}, eventId, data.eventProduces); view.refresh(); return "活动定义已删除。"; }) }, "删除")
)
);
@@ -451,7 +458,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
giftTabButton(e, view, "definitions", "礼包定义"), giftTabButton(e, view, "claims", "领取/待领"), giftTabButton(e, view, "deliveries", "发放记录"), giftTabButton(e, view, "timed", "游戏定时记录")
),
view.giftTab === "definitions" ? e("div", { className: "overview-two-col" },
e("details", { className: "console-module scum-editor", open: Boolean(view.giftCode || view.giftName) }, e("summary", null, e("strong", null, "新建或更新礼包"), e("span", { className: "page-status" }, view.giftCode || view.giftName ? "编辑中" : "点击展开")),
e("details", { className: "console-module scum-editor", open: view.giftEditorOpen, onToggle: (event: InputEvent) => view.setGiftEditorOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "新建或更新礼包"), e("span", { className: "page-status" }, view.giftCode || view.giftName ? "编辑中" : "点击展开")),
labeledField(e, "礼包编号", e("input", { value: view.giftCode, "aria-label": "礼包编号", placeholder: "例如 welcome_pack", onChange: (event: InputEvent) => view.setGiftCode(inputValue(event)) })),
labeledField(e, "礼包名称", e("input", { value: view.giftName, "aria-label": "礼包名称", placeholder: "例如 新人礼包", onChange: (event: InputEvent) => view.setGiftName(inputValue(event)) })),
labeledField(e, "礼包周期", e("select", { value: view.giftClass, "aria-label": "礼包周期", onChange: (event: InputEvent) => view.setGiftClass(inputValue(event)) }, [["1", "每日"], ["2", "每周"], ["3", "每月"], ["4", "每年"], ["5", "一次"], ["6", "每日五次"]].map(([value, label]) => e("option", { key: value, value }, label)))),
@@ -469,7 +476,7 @@ function giftsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, inpu
e("div", { className: "console-record-meta" }, e("span", null, `周期 ${giftClassLabel(numField(gift, "class"))}`), e("span", null, `适用 ${textField(gift, "audience") || "all"}`), e("span", null, `次数 ${numField(gift, "number")}`), e("span", null, `成就 ${numField(gift, "achievement")} / ${numField(gift, "achievementNumber", "achievement_number")}`)),
e("span", { className: "provider-id" }, giftItemsSummary(gift)),
e("span", { className: "provider-id" }, giftCommandsSummary(gift)),
e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除"))
e("div", { className: "console-row-actions" }, e("button", { type: "button", className: "icon-command", onClick: () => view.setDeliveryGift(key) }, "选择发放"), e("button", { type: "button", className: "icon-command", onClick: () => { view.setGiftEditorOpen(true); view.setGiftCode(textField(gift, "code")); view.setGiftName(textField(gift, "name")); view.setGiftClass(numField(gift, "class")); view.setGiftAudience(textField(gift, "audience") || "all"); view.setGiftNumber(numField(gift, "number")); view.setGiftAchievement(numField(gift, "achievement")); view.setGiftAchievementNumber(numField(gift, "achievementNumber", "achievement_number")); view.setGiftItems(giftItemsInput(gift)); view.setGiftCommands(giftCommandsInput(gift)); } }, "编辑"), e("button", { type: "button", className: "icon-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在删除礼包…", async () => { await deleteGiftDefinition(actions ?? {}, key); view.refresh(); return "礼包定义已删除。"; }) }, "删除"))
);
}) : e("p", { className: "page-status" }, "暂无礼包定义。"))
) : null,
@@ -507,11 +514,11 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0];
return e("div", { className: "console-record-list" },
statsStrip(e, [["地图点", points.length], ["用户", data.players.length], ["载具", data.vehicles.length], ["旗帜/区域", data.flags.length + data.mapRegions.length]]),
e("div", { className: "console-row-actions" },
e("div", { className: "resource-filter-bar scum-filter-bar" },
labeledField(e, "筛选地图点", e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) })),
(["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer)))
(["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer)))
),
e("details", { className: "console-module scum-editor" }, e("summary", null, e("strong", null, "地图范围"), e("span", { className: "page-status" }, customEnabled ? "自定义范围" : "SCUM 默认范围")),
e("details", { className: "console-module scum-editor", open: view.mapSettingsOpen, onToggle: (event: InputEvent) => view.setMapSettingsOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "地图范围"), e("span", { className: "page-status" }, customEnabled ? "自定义范围" : "SCUM 默认范围")),
e("div", { className: "console-row-actions" },
e("label", null, e("input", { type: "checkbox", checked: customEnabled, "aria-label": "启用自定义地图", onChange: (event: InputEvent) => view.setMapCustomEnabled(Boolean(event.target?.checked)) }), "启用自定义地图"),
labeledField(e, "中心 X", e("input", { value: centerX, "aria-label": "地图中心 X", type: "number", onChange: (event: InputEvent) => view.setMapCenterX(inputValue(event)) })),
@@ -528,6 +535,7 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
}
function labeledField(e: ReactLike["createElement"], label: string, control: unknown) { return e("label", { className: "console-field" }, e("span", null, label), control); }
function detailOpen(event: InputEvent): boolean { return Boolean(event.target?.open); }
function giftTabButton(e: ReactLike["createElement"], view: ViewState, tab: GiftTab, label: string) { return e("button", { type: "button", role: "tab", "aria-selected": view.giftTab === tab, className: view.giftTab === tab ? "primary-command" : "icon-command", onClick: () => view.setGiftTab(tab) }, label); }
function statsStrip(e: ReactLike["createElement"], items: Array<[string, number]>) { return e("div", { className: "console-stat-strip" }, items.map(([label, value]) => e("span", { key: label, className: "server-card-stat" }, e("span", null, label), e("strong", null, String(value))))); }
function tablePanel(e: ReactLike["createElement"], title: string, rows: RecordMap[], render: (row: RecordMap) => unknown[]) { return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, render(row).map((part, partIndex) => partIndex === 0 ? e("span", { key: partIndex }, String(part ?? "unknown")) : e("strong", { key: partIndex }, String(part ?? "unknown"))))) : e("p", { className: "page-status" }, "暂无真实记录。"))); }
@@ -183,6 +183,9 @@ describe("SCUM plugin feature module", () => {
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"]));
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["编辑属性", "礼包状态", "他的物品", "登录历史", "用户轨迹"]));
expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
expect(pageSource).toContain("console-panel scum-workbench");
expect(pageSource).toContain("resource-filter-bar scum-filter-bar");
expect(pageSource).toContain("provider-table-wrap scum-table-wrap");
expect(view.texts).toContain("Mira");
expect(view.texts).not.toContain("76561198000000001");
expect(view.texts.join("\n")).not.toContain("Fame 42");
@@ -224,6 +227,8 @@ describe("SCUM plugin feature module", () => {
expect(view.texts).toContain("最近活动记录");
expect(view.texts).toContain("Mira");
expect(view.texts).toContain("活动生成项");
expect(pageSource).toContain("setEventEditorOpen(detailOpen(event))");
expect(pageSource).not.toContain("open: Boolean(view.eventId || view.eventName)");
});
it("renders gift definitions, claims, and delivery records", () => {