新增:导出 / 导入干员调配 (#14)
This commit is contained in:
1 parent
e90e7a30fc
commit
502f183e43
9 files changed
+395
-33
No files matched your search
@@ -15,7 +15,7 @@
|
||||
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline } from '../ui/components.js';
|
||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
|
||||
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
|
||||
@@ -25,15 +25,54 @@ import { useStore } from '../store.js';
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import {
|
||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries } from '../ui/loadoutSync.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries } from '../ui/loadoutSync.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
|
||||
export { openLoadout, closeLoadout };
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
|
||||
// ---- export / import (干员调配 presets) -----------------------------------------------------------------------------
|
||||
//
|
||||
// The payload is the versioned envelope of ui/loadoutModel.js (exportPayload / parseImport): a downloaded file and a
|
||||
// pasted string are the SAME object, so 导出 and 导入 both funnel through applyLoadoutEntries
|
||||
// (sanitise → persist → room.loadout). The dialog is a shared Modal rendered next to the overlay, not inside it.
|
||||
|
||||
/** Save `text` as a download. Silent no-op when the browser refuses downloads — 复制 stays available. */
|
||||
function downloadText(filename, text) {
|
||||
try {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
|
||||
function readFileText(file) {
|
||||
if (typeof file?.text === 'function') return file.text();
|
||||
return new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result ?? ''));
|
||||
fr.onerror = () => reject(fr.error || new Error('read failed'));
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/** `stronghold-loadout-20261003-1245.json` */
|
||||
function exportFilename(now = new Date()) {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `stronghold-loadout-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
}
|
||||
|
||||
/** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */
|
||||
function profGlyphUrl(m, prof) {
|
||||
const k = String(prof || '').toLowerCase();
|
||||
@@ -358,7 +397,9 @@ function LoadoutScreen({ st }) {
|
||||
const nChanged = changedCount(st.entries, getChess);
|
||||
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
|
||||
const gridRef = useRef(null);
|
||||
const fileRef = useRef(null); // hidden <input type=file> of the 导入 dialog
|
||||
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
|
||||
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
|
||||
|
||||
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
|
||||
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
|
||||
@@ -369,6 +410,38 @@ function LoadoutScreen({ st }) {
|
||||
if (ok) setEntries({});
|
||||
};
|
||||
|
||||
// 导出 / 导入 the loadout as the versioned payload (a downloaded file, the clipboard, or the textarea)
|
||||
const ioText = io?.text ?? '';
|
||||
const openExport = () => setIo({ mode: 'export', text: serializeExport(loadoutStore.get().entries) });
|
||||
const openImport = () => setIo({ mode: 'import', text: '' });
|
||||
const ioCopy = async () => {
|
||||
const ok = await copyText(ioText);
|
||||
toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn');
|
||||
};
|
||||
const ioDownload = () => downloadText(exportFilename(), ioText);
|
||||
const ioPick = () => fileRef.current?.click();
|
||||
const ioFile = async (e) => {
|
||||
const f = e.currentTarget.files && e.currentTarget.files[0];
|
||||
e.currentTarget.value = ''; // picking the same file twice must fire again
|
||||
if (!f) return;
|
||||
// refuse a huge pick before reading it into memory (a real payload is a few KB)
|
||||
if (f.size > LOADOUT_IMPORT_MAX_BYTES) { toast('文件过大,请选择「导出」下载的调配文件', 'error'); return; }
|
||||
try { setIo({ mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
|
||||
};
|
||||
const ioApply = () => {
|
||||
// an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
|
||||
if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; }
|
||||
const res = parseImport(ioText);
|
||||
if (!res.ok) { toast(`导入失败:${res.error}`, 'error'); return; }
|
||||
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
|
||||
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
|
||||
if (!applied) { toast('导入失败:这份数据在当前版本没有可用的调配,未做任何改动', 'error'); return; }
|
||||
setIo(null);
|
||||
toast(dropped
|
||||
? `已导入 ${applied} 名干员(另有 ${dropped} 项未导入)`
|
||||
: `已导入 ${applied} 名干员的调配`, dropped ? 'warn' : 'success');
|
||||
};
|
||||
|
||||
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
@@ -401,7 +474,8 @@ function LoadoutScreen({ st }) {
|
||||
const [syncText, syncCls] = SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
|
||||
|
||||
return html`<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
|
||||
return html`<${Fragment}>
|
||||
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
|
||||
<div class="lo__bg" aria-hidden="true"></div>
|
||||
<header class="lo-top">
|
||||
<div class="lo-top__left">
|
||||
@@ -415,6 +489,8 @@ function LoadoutScreen({ st }) {
|
||||
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null}
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${syncText}</span>` : null}
|
||||
<span class="lo-count">已调整 <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title="导出当前调配(可复制或下载)">导出<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title="导入调配(粘贴或选择文件)">导入<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
|
||||
</div>
|
||||
</header>
|
||||
@@ -432,7 +508,25 @@ function LoadoutScreen({ st }) {
|
||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} />
|
||||
</div>
|
||||
</main>`}
|
||||
</div>`;
|
||||
</div>
|
||||
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
|
||||
title=${io.mode === 'export' ? '导出干员调配' : '导入干员调配'} micro="OPERATOR LOADOUT"
|
||||
actions=${io.mode === 'export'
|
||||
? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭<//>
|
||||
<${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制<//>
|
||||
<${Button} variant="primary" data-testid="loadout-io-download" onClick=${ioDownload}>下载文件<//>`
|
||||
: html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消<//>
|
||||
<${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件<//>
|
||||
<${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入<//>`}>
|
||||
<p class="lo-io__hint">${io.mode === 'export'
|
||||
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
|
||||
<textarea class="lo-io__text" data-testid="loadout-io-text" spellcheck=${false} readOnly=${io.mode === 'export'} value=${ioText}
|
||||
placeholder=${io.mode === 'export' ? '' : '在此粘贴导出的调配内容…'}
|
||||
onInput=${(e) => setIo({ mode: io.mode, text: e.currentTarget.value })}></textarea>
|
||||
<input type="file" accept=".json,application/json,text/plain" class="lo-io__file" ref=${fileRef} onChange=${ioFile} />
|
||||
<//>` : null}
|
||||
<//>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
html, Button, Icon, MicroLabel, PingPill, AvatarFrame, DifficultyTag, DifficultyIcon, Tooltip, confirmDialog, doctorNo,
|
||||
} from '../ui/components.js';
|
||||
import { toast, toastError } from '../ui/toasts.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { net } from '../net.js';
|
||||
@@ -68,35 +69,12 @@ export function inviteLink(code) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
|
||||
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts). Moved to ui/clipboard.js so
|
||||
* 干员调配 can use it without importing this screen (which imports loadout.js): re-exported here for existing callers.
|
||||
* @param {string} text
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function copyText(text) {
|
||||
try {
|
||||
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
// 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
|
||||
// navigator.clipboard, so this path is the one iPhones / iPads take)
|
||||
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export { copyText };
|
||||
|
||||
function SeatCard({ seat, index, room, facts, myId, busy, onAddBot, onRemoveBot }) {
|
||||
const coop = room.mode !== 'solo';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Clipboard helper shared by the room screen (invite code / link) and 干员调配 (导出 / 导入 payloads).
|
||||
//
|
||||
// Lives here rather than in a screen: `screens/room.js` imports `screens/loadout.js` (the 干员调配 button), so loadout
|
||||
// importing the helper back from room would be a cycle.
|
||||
|
||||
/**
|
||||
* Copy text to the clipboard (async API with a textarea fallback for insecure contexts).
|
||||
* @param {string} text
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function copyText(text) {
|
||||
try {
|
||||
if (globalThis.navigator?.clipboard && globalThis.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
// 16 px: iOS zooms into smaller focused fields; `readonly` keeps the keyboard away
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0;font-size:16px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
// iOS Safari ignores select() on a textarea: an explicit range is what it copies (LAN play over http has no
|
||||
// navigator.clipboard, so this path is the one iPhones / iPads take)
|
||||
try { ta.setSelectionRange(0, ta.value.length); } catch { /* ignore */ }
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export const ATTR_LABEL = Object.freeze({
|
||||
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const isInt = (v) => Number.isInteger(v);
|
||||
/** id keys a parsed payload must never inject into an entry map: `{ "__proto__": … }` would rewrite the prototype. */
|
||||
const UNSAFE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
// ---- storage -----------------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -44,7 +46,7 @@ export function parseStored(raw) {
|
||||
if (!src) return out;
|
||||
for (const [id, e] of Object.entries(src)) {
|
||||
if (Object.keys(out).length >= LOADOUT_LIMITS.entries) break;
|
||||
if (!/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
|
||||
if (UNSAFE_IDS.has(id) || !/^[A-Za-z0-9_\-.:]{1,64}$/.test(id) || !isObj(e)) continue;
|
||||
const x = {};
|
||||
if (isInt(e.skill) && e.skill >= 0 && e.skill <= LOADOUT_LIMITS.skillIndex) x.skill = e.skill;
|
||||
if (typeof e.module === 'string' && /^[A-Za-z0-9_\-.:]{1,64}$/.test(e.module)) x.module = e.module;
|
||||
@@ -56,6 +58,66 @@ export function parseStored(raw) {
|
||||
/** Serialised form for localStorage. */
|
||||
export const toStored = (entries) => ({ v: LOADOUT_VERSION, entries: entries || {} });
|
||||
|
||||
// ---- export / import ----------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `kind` of an exported loadout envelope: what a downloaded file / a copied payload carries. `entries` is exactly
|
||||
* `room.loadout.entries`, i.e. what `setEntries` + the sync already accept.
|
||||
*/
|
||||
export const LOADOUT_EXPORT_KIND = 'stronghold.loadout';
|
||||
|
||||
/** A picked file / pasted payload longer than this is refused before parsing (a real payload is a few KB). */
|
||||
export const LOADOUT_IMPORT_MAX_BYTES = 256 * 1024;
|
||||
|
||||
/**
|
||||
* Portable payload of a loadout, as downloaded / copied by 导出.
|
||||
* @param {Record<string, any>} entries `room.loadout.entries`
|
||||
* @param {{ now?: number }} [o]
|
||||
*/
|
||||
export function exportPayload(entries, { now = Date.now() } = {}) {
|
||||
const clean = {};
|
||||
for (const [id, e] of Object.entries(entries || {})) if (isObj(e)) clean[id] = { ...e };
|
||||
return {
|
||||
kind: LOADOUT_EXPORT_KIND,
|
||||
v: LOADOUT_VERSION,
|
||||
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
||||
count: Object.keys(clean).length,
|
||||
entries: clean,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pretty JSON of `exportPayload` — one preset per file / clipboard payload. */
|
||||
export function serializeExport(entries, opts) {
|
||||
return JSON.stringify(exportPayload(entries, opts), null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an imported loadout. Tolerant by design: the envelope, the stored `{ v, entries }` form and a bare
|
||||
* `{ [chessId]: { skill, module } }` map all work, as does the serialised text of any of them. Parsing is STRUCTURAL
|
||||
* only — the caller still runs `sanitizeEntries` against the loaded data, because a preset from another season may name
|
||||
* chess / skills / modules this build does not have. `__proto__` / `constructor` keys are skipped (see parseStored).
|
||||
* @param {any} input payload object or serialised text
|
||||
* @returns {{ ok: true, entries: Record<string, any> } | { ok: false, error: string }}
|
||||
*/
|
||||
export function parseImport(input) {
|
||||
let raw = input;
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length > LOADOUT_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' };
|
||||
const text = raw.trim();
|
||||
if (!text) return { ok: false, error: '没有可导入的内容' };
|
||||
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
|
||||
}
|
||||
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
|
||||
const v = isInt(raw.v) ? raw.v : null;
|
||||
// a newer envelope may reshuffle fields — refuse instead of silently reading it as something else
|
||||
if (v != null && v > LOADOUT_VERSION) return { ok: false, error: `这份调配来自更新的版本(v${v}),请先更新游戏` };
|
||||
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
||||
if (kind && kind !== LOADOUT_EXPORT_KIND) return { ok: false, error: '这不是干员调配的数据' };
|
||||
const entries = parseStored(raw);
|
||||
if (!Object.keys(entries).length) return { ok: false, error: '里面没有有效的调配条目' };
|
||||
return { ok: true, entries };
|
||||
}
|
||||
|
||||
// ---- options & choices ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,6 +40,23 @@ export function setEntries(entries) {
|
||||
loadoutStore.set({ entries: next });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a parsed entry map (an imported preset). Sanitised against the loaded data first, then persisted and synced
|
||||
* like any ordinary edit — so a preset from another build never sends the server an entry it would refuse. An import
|
||||
* that keeps nothing (every chess unknown, or every choice already the default) changes NOTHING: wiping the current
|
||||
* loadout over it would be a loss the player never asked for.
|
||||
* @param {Record<string, any>} entries `parseImport(...).entries`
|
||||
* @param {(id: string) => any} lookup chess lookup
|
||||
* @returns {{ applied: number, dropped: number }} entries kept / entries that were not imported
|
||||
*/
|
||||
export function applyLoadoutEntries(entries, lookup) {
|
||||
const asked = Object.keys(entries || {}).length;
|
||||
const clean = sanitizeEntries(entries, lookup);
|
||||
const applied = Object.keys(clean).length;
|
||||
if (applied) setEntries(clean);
|
||||
return { applied, dropped: Math.max(0, asked - applied) };
|
||||
}
|
||||
|
||||
/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */
|
||||
export function openLoadout(from = 'lobby', sel = null) {
|
||||
data.load('chess');
|
||||
|
||||
Reference in new issue
Block a user