0.2.4-dev: round-29 community fixes and the official shared card pool

- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
sganggs committed 2026-10-11 01:58:52 +08:00
1 parent 1db8e51023
commit 6bfc08270c
129 files changed
+3477 -1011

No files matched your search

+4 -3
View File
@@ -91,13 +91,13 @@ export function pickBusy(busyIdx, card, mine) {
/**
* Whether I may pick a card right now: my turn (solo: always), no pick of mine yet, the card free and no pick in flight.
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null } | null | undefined} card
* @param {any} sp normalizeSp(...) @param {{ idx: number, takenBy?: string|null, soldOut?: boolean } | null | undefined} card
* @param {{ myId: string, solo: boolean, busyIdx?: number|null }} o
*/
export function cardPickable(sp, card, { myId, solo, busyIdx = null }) {
if (!sp || !card) return false;
const myTurn = solo || sp.turnPid === myId;
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && busyIdx == null;
return myTurn && sp.pickOf.get(myId) == null && !card.takenBy && !card.soldOut && busyIdx == null;
}
/**
@@ -218,7 +218,7 @@ export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = nu
const busy = pickBusy(busyIdx, card, mine);
const isArmed = can && armed === card.idx;
const takerName = taker ? (card.takenBy === myId ? t('你') : taker.name) : null;
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, card.takenBy && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
return html`<button key=${card.idx} type="button" class=${cx('spcard', `spcard--${r.kind}`, (card.takenBy || card.soldOut) && 'is-taken', card.takenBy === myId && 'is-mine', can && 'is-pickable', isArmed && 'is-armed', busy && 'is-busy')}
aria-busy=${busy ? 'true' : undefined} aria-pressed=${can ? String(isArmed) : undefined} disabled=${!can} onClick=${() => can && onTap(card.idx)}
aria-label=${isArmed ? t('{name},已选中,再次点击确认', { name: r.name }) : takerName ? t('{name},{takerName}已选择', { name: r.name, takerName }) : r.name} title=${`${r.name}\n${richTextPlain(r.desc)}`}>
<span class="spcard__glow" aria-hidden="true"></span>
@@ -233,6 +233,7 @@ export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = nu
</span>
</span>
<${RichText} text=${r.desc} class="spcard__desc" />
${card.soldOut && !taker ? html`<span class="spcard__busy" role="status">${t('库存不足')}</span>` : null}
${r.tier ? html`<${TierChip} tier=${r.tier} size="md" class="spcard__tier" />` : null}
${taker ? html`<span class="spcard__taker" title=${t('{name} 已选择', { name: taker.name })}><${PlayerAvatar} player=${taker} size="sm" /></span>` : null}
${isArmed ? html`<span class="spcard__confirm" role="status"><b>${t('确认选择')}</b><small>${t('再次点击')}</small></span>` : null}
+2
View File
@@ -76,6 +76,8 @@ export const ICONS = {
wifiOff: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3zM2.4 1 23 21.6 21.6 23 1 2.4z' },
expand: { d: 'M3 3h7v2H5v5H3zm11 0h7v7h-2V5h-5zM3 14h2v5h5v2H3zm16 0h2v7h-7v-2h5z' },
collapse: { d: 'M8 3h2v7H3V8h5zm6 0h2v5h5v2h-7zM3 14h7v7H8v-5H3zm11 0h7v2h-5v5h-2z' },
// 添加到桌面 (add to home screen): a house outline with a + inside
homePlus: { d: 'M12 2 22 10.5V21H2V10.5zm0 2.6-8 6.8V19h16v-7.6zM11 10h2v2.5h2.5v2H13V17h-2v-2.5H8.5v-2H11z', eo: true },
rotate: { d: 'M7 2h8a2 2 0 0 1 2 2v7h-2V4H7v16h4v2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 12h5a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-4a2 2 0 0 1 2-2zm0 2v4h5v-4z', eo: true },
book: { d: 'M2 4h7.5A3.5 3.5 0 0 1 12 5.1 3.5 3.5 0 0 1 14.5 4H22v16h-7.5a1.5 1.5 0 0 0-1.5 1.5h-2A1.5 1.5 0 0 0 9.5 20H2zm2 2v12h5.5c.5 0 1 .1 1.5.3V7.5A1.5 1.5 0 0 0 9.5 6zm10.5 0A1.5 1.5 0 0 0 13 7.5v10.8c.5-.2 1-.3 1.5-.3H20V6z', eo: true },
// 观战 (spectator seats; the same eye as gameComponents.js GIcon 'eye')
+8 -4
View File
@@ -163,13 +163,17 @@ export function FullscreenButton({ class: cls = '' }) {
</button>`;
}
/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */
export function PwaInstallButton({ class: cls = '', size = 'sm' }) {
/**
* Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet.
* `square`: the glyph alone (the lobby's top bar, beside ⚙ / 📖), the name stays title / aria-label.
*/
export function PwaInstallButton({ class: cls = '', size = 'sm', square = false }) {
const [available, setAvailable] = useState(pwaInstall.available);
useEffect(() => pwaInstall.subscribe(setAvailable), []);
if (!available) return null;
return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls}
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}<//>`;
return html`<${Button} variant="secondary" size=${size} icon="homePlus" square=${square} class=${cls}
title=${t('添加到桌面')} aria-label=${t('添加到桌面')}
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${square ? null : t('添加到桌面')}<//>`;
}
export function updateRotateHintPwaI18n(doc = globalThis.document) {
+2 -2
View File
@@ -147,9 +147,9 @@ export function pickOptions(slotId, picks, data, kitted) {
* The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite).
* @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts]
*/
export function slotRecord(slotId, pick, data, { elite = false } = {}) {
export function slotRecord(slotId, pick, data, { elite = false, potential = null } = {}) {
if (!isObj(pick)) return null;
try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; }
try { return diyRecord(slotId, pick, { elite, data, potential }); } catch { return null; }
}
/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */
+10
View File
@@ -332,6 +332,16 @@ export function createFallbackView(host, opts = {}) {
const now = performance.now();
if (st.mode === 'prep' && st.priv) {
const heldPos = (p) => { const t = st.held.get(p.uid); return t ? tilePos(L, t.row, t.col) : null; };
for (const u of Array.isArray(st.priv.prepMapChars) ? st.priv.prepMapChars : []) {
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y) || typeof u.defId !== 'string') continue;
const pos = tilePos(L, u.y, u.x);
const src = tokenAvatarUrl(m(), u.defId);
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
title=${u.name || u.defId}>
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
</div>`);
}
for (const p of Array.isArray(st.priv.board) ? st.priv.board : []) {
if (!p || !Number.isInteger(p.row)) continue;
const pos = heldPos(p) || tilePos(L, p.row, p.col);
+17 -2
View File
@@ -268,31 +268,46 @@ export async function mountFieldView(host, { signal } = {}) {
*/
export function useFieldView(hostRef) {
const [state, setState] = useState({ view: null, kind: 'loading' });
const [attempt, setAttempt] = useState(0);
const retries = useRef(0);
const viewRef = useRef(null);
useEffect(() => {
let dead = false;
let dead = false, retrying = false;
const host = hostRef.current;
if (!host) return undefined;
setState({ view: null, kind: 'loading' });
const controller = new AbortController();
mountFieldView(host, { signal: controller.signal }).then((view) => {
if (dead) { view.destroy(); return; }
viewRef.current = view;
if (view.kind === 'engine') retries.current = 0;
globalThis.__SP_VIEW__ = view; // dev / E2E introspection (view.raw.stats?.())
setState({ view, kind: view.kind });
}, (err) => { if (!dead && !controller.signal.aborted) console.error('[field] mount failed', err); });
const unsub = settingsStore.subscribe((s) => viewRef.current?.setSettings?.(s));
const onResize = () => viewRef.current?.resize();
// A temporary network/vendor failure must not pin this mounted match to fallback forever. Retry on a useful
// user event, at most twice in succession; the effect cleanup disposes the old view before creating its successor.
const onRetry = () => {
if (dead || retrying || viewRef.current?.kind !== 'fallback' || renderPref() === 'fallback'
|| document.visibilityState !== 'visible' || globalThis.navigator?.onLine === false || retries.current >= 2) return;
retrying = true; retries.current++; setAttempt((n) => n + 1);
};
window.addEventListener('resize', onResize);
window.addEventListener('online', onRetry);
document.addEventListener('visibilitychange', onRetry);
return () => {
dead = true;
controller.abort();
unsub();
window.removeEventListener('resize', onResize);
window.removeEventListener('online', onRetry);
document.removeEventListener('visibilitychange', onRetry);
// the dev hook must not keep the destroyed view — and through its host the whole detached match screen — alive
if (viewRef.current && globalThis.__SP_VIEW__ === viewRef.current) globalThis.__SP_VIEW__ = null;
viewRef.current?.destroy();
viewRef.current = null;
};
}, []);
}, [attempt]);
return state;
}
+1 -1
View File
@@ -35,7 +35,7 @@ export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldL
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt, readyShopFold } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, equipReplaces, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLogic/terrain.js';
export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits, deviceUnitsForEntry } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
+11 -7
View File
@@ -3,6 +3,7 @@
import { GEO } from '../../../../shared/constants.js';
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { meleeOnHighGround } from '../../../../shared/highGround.js';
import { summonPlacementGrid } from '../../../../shared/summonPlacement.js';
import { pieceDir, rangeTiles } from '../facing.js';
import { isObj, tileKey } from './shared.js';
import { boardTileOf, fieldTile } from './camera.js';
@@ -250,15 +251,18 @@ export function tileAllows(ctx, piece, row, col) {
/**
* Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`:
* 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when
* loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile).
* Jessica's shield uses the four-adjacent-tile grid instead, independent of facing/skill/module. Null when
* the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's
* current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange.
* @param {ReturnType<typeof placementContext>} ctx
* @returns {Set<string> | null}
*/
export function summonRange(ctx, piece, owner = null) {
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
return ownerRangeOf(ctx, piece, owner);
if (!isObj(piece) || piece.kind !== 'token') return null;
const grid = summonPlacementGrid(piece.id);
if (!grid && ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
return ownerRangeOf(ctx, piece, owner, grid);
}
/**
@@ -273,14 +277,14 @@ export function summonExcluded(ctx, piece, owner = null) {
return ownerRangeOf(ctx, piece, owner);
}
/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */
function ownerRangeOf(ctx, piece, owner = null) {
/** The attack or fixed placement range of a summon piece's owner on the board, or null. */
function ownerRangeOf(ctx, piece, owner = null, placementGrid = null) {
let at = owner;
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
const rec = at && ctx.getChess(at.piece.id);
if (!rec) return null;
let grid = null;
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
try { grid = placementGrid || attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
}
@@ -367,7 +371,7 @@ export function canPlace(ctx, uid, target) {
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
const range = summonRange(ctx, piece, ownerSwap);
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围内'));
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', summonPlacementGrid(piece.id) ? t('只能部署在召唤者相邻四格') : t('只能部署在召唤者攻击范围内'));
const out = summonExcluded(ctx, piece, ownerSwap);
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', t('只能部署在召唤者攻击范围外'));
if (src.area === 'board') {
+13
View File
@@ -296,3 +296,16 @@ export function noteDeviceUnits(map, { units = null, events = null } = {}) {
for (const e of Array.isArray(events) ? events : []) if (Array.isArray(e) && e[0] === 'spawn' && isObj(e[1]) && e[1].kind === 'device' && e[1].id != null) map.set(e[1].id, e[1]);
return map;
}
/**
* The device-unit map after entering a field. A refresh of the same field whose new meta names no device
* (the start-of-battle list: crates and turrets arrive as `spawn` and are not repeated) keeps the units
* already noted. A meta that names devices replaces the map. A different field does not keep the previous one.
* @param {Map<number, any>|null|undefined} previous
* @param {Map<number, any>} noted
* @param {boolean} sameField
*/
export function deviceUnitsForEntry(previous, noted, sameField) {
if (!(noted instanceof Map) || noted.size > 0 || !sameField) return noted instanceof Map ? noted : new Map();
return previous instanceof Map && previous.size > 0 ? previous : noted;
}
+61
View File
@@ -0,0 +1,61 @@
// Shared operator stat preview used by roster loadout and DIY selection.
import { html, MicroLabel } from './components.js';
import { RichText } from './gameComponents.js';
import { chessStatsBlock, traitText, chessTalents } from './detailPanel.js';
import { chessLoadout } from './gameLogic.js';
import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...parts) => parts.filter(Boolean).join(' ');
const getChessRec = (id) => data.lookup('chess', id);
/**
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
* the normal one — as the stored loadout makes it. chessLoadout (ui/gameLogic.js) resolves the skill and module the way
* the in-match detail card and the sim do (shared/loadoutRecord.js): the elite's chosen module's stats / 特性 / talents
* (不装备: the base ones), the chosen skill's passive range; a normal chess has no module, the skill does not change its
* stats. Nothing is recomputed here.
* @param {any} base normal chess record @param {any} golden its elite record or null
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
* @param {(id: string) => any} getChess
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
*/
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
const elite = level === 'elite' && !!golden;
const chess = elite ? golden : base;
if (!chess) return null;
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
const record = lo?.record || chess;
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
}
/**
* 局内数值: the stats, 攻击范围, 特性 and 天赋 of the selected chess under its chosen skill and module — the detail
* card's stats block (ui/detailPanel.js chessStatsBlock) without live numbers, so what a player reads here is what the
* shop / board card shows before a battle (not the equipment, bond or skill-cast changes of a running match). The
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
*/
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
const pv = statsPreview(base, golden, entries, level, getChess, ops);
if (!pv) return null;
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
<header class="lo-sec__head">
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
<div class="lo-seg" role="tablist" aria-label=${t('数值版本')}>
<button type="button" role="tab" aria-selected=${pv.elite ? 'false' : 'true'} class=${cx(!pv.elite && 'is-on')} data-variant="normal" onClick=${() => onLevel('normal')}>${t('普通')}</button>
<button type="button" role="tab" aria-selected=${pv.elite ? 'true' : 'false'} class=${cx(pv.elite && 'is-on')} data-variant="elite" disabled=${!golden} onClick=${() => onLevel('elite')}>${t('精锐')}</button>
</div>
</header>
${chessStatsBlock({ rec: pv.record, chess: pv.chess })}
${pv.trait || pv.talents.length ? html`<div class="lo-minfo lo-minfo--kit">
${pv.trait ? html`<div class="lo-minfo__row"><span class="lo-minfo__k">${t('特性')}</span><${RichText} class="lo-minfo__v" text=${pv.trait} /></div>` : null}
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
</div>` : null}
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
</section>`;
}
+5 -4
View File
@@ -289,11 +289,12 @@ export function SettingsModal({ open, onClose }) {
* The 设置 button of the lobby and the room (GitHub #238 — before it the settings were reachable only from the title
* screen and a running match): the twin of the 玩法说明 button (ui/guide.js GuideButton), with the settings modal behind it
* (mounted only while open). The same modal as the title screen's and the match's: nothing in it is match-only.
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string }} props
* `square`: the gear alone (the lobby's top bar), the name stays title / aria-label — like GuideButton's.
* @param {{ class?: string, size?: 'sm'|'md'|'lg'|'xl', variant?: string, label?: string, square?: boolean }} props
*/
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置') }) {
export function SettingsButton({ class: cls, size = 'sm', variant = 'ghost', label = t('设置'), square = false }) {
const [open, setOpen] = useState(false);
return html`<${Button} variant=${variant} size=${size} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${label}<//>
return html`<${Button} variant=${variant} size=${size} square=${square} class=${cx('settings-btn', cls)} onClick=${() => setOpen(true)}
title=${t('设置')} aria-label=${t('设置')} data-testid="settings-btn"><${GIcon} name="gear" class="btn__icon" />${square ? null : label}<//>
${open ? html`<${SettingsModal} open=${true} onClose=${() => setOpen(false)} />` : null}`;
}