diff --git a/css/main.css b/css/main.css index 61a69d4a..d446c41e 100644 --- a/css/main.css +++ b/css/main.css @@ -7157,6 +7157,229 @@ body.theme-blue ::-webkit-scrollbar-thumb { min-width: 96px; } +.vocab-list-modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(92, 70, 46, 0.35); + backdrop-filter: blur(6px); + z-index: 1200; + padding: 24px; +} + +.vocab-list-modal[hidden] { + display: none; +} + +.vocab-list-modal__backdrop { + position: absolute; + inset: 0; + z-index: 0; +} + +.vocab-list-modal__dialog { + width: min(1120px, 100%); + max-height: min(82vh, 760px); + display: flex; + flex-direction: column; + background: #fffaf2; + border-radius: 18px; + border: 1px solid var(--vocab-line); + box-shadow: var(--vocab-shadow-strong); + color: var(--vocab-ink); + position: relative; + z-index: 1; + overflow: hidden; +} + +.vocab-list-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 22px 14px; + border-bottom: 1px solid rgba(120, 90, 60, 0.16); +} + +.vocab-list-modal__header h3 { + margin: 0; + font-size: 1.25rem; + font-family: var(--vocab-font-title); +} + +.vocab-list-modal__subtitle { + margin: 6px 0 0; + color: var(--vocab-muted); + font-size: 0.9rem; +} + +.vocab-list-modal__toolbar { + display: grid; + grid-template-columns: minmax(220px, 1fr) auto auto; + align-items: center; + gap: 12px; + padding: 14px 22px; + border-bottom: 1px solid rgba(120, 90, 60, 0.12); +} + +.vocab-list-modal__toolbar input[type="search"] { + width: 100%; + min-width: 0; + border: 1px solid var(--vocab-line); + border-radius: 10px; + background: var(--vocab-paper-bright); + color: var(--vocab-ink); + padding: 10px 12px; + font-size: 0.95rem; +} + +.vocab-list-modal__toolbar input[type="search"]:focus { + outline: none; + border-color: var(--vocab-accent); + box-shadow: 0 0 0 3px rgba(196, 104, 75, 0.18); +} + +.vocab-list-modal__filter { + display: inline-flex; + align-items: center; + gap: 8px; + white-space: nowrap; + color: var(--vocab-ink); + font-size: 0.9rem; +} + +.vocab-list-modal__filter input { + width: 17px; + height: 17px; +} + +.vocab-list-modal__stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px 22px 0; +} + +.vocab-list-modal__stats span { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 4px 10px; + border-radius: 999px; + background: rgba(196, 104, 75, 0.1); + color: var(--vocab-accent-strong); + font-size: 0.86rem; + font-weight: 600; +} + +.vocab-list-modal__body { + flex: 1 1 220px; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 14px 22px 22px; +} + +.vocab-list-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: 180px; + color: var(--vocab-muted); + border: 1px dashed var(--vocab-line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.5); +} + +.vocab-list-table-wrap { + flex: 1; + min-height: 0; + overflow: auto; + border: 1px solid rgba(120, 90, 60, 0.16); + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); +} + +.vocab-list-pagination { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 40px; + padding-top: 8px; + color: var(--vocab-muted); + font-size: 0.86rem; +} + +.vocab-list-pagination .btn { + width: 32px; + height: 32px; + min-height: 32px; +} + +.vocab-list-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; + font-size: 0.9rem; +} + +.vocab-list-table th, +.vocab-list-table td { + padding: 10px 12px; + border-bottom: 1px solid rgba(120, 90, 60, 0.12); + text-align: left; + vertical-align: top; +} + +.vocab-list-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f7ebdc; + color: var(--vocab-muted); + font-weight: 700; +} + +.vocab-list-table tbody tr:hover { + background: rgba(196, 104, 75, 0.08); +} + +.vocab-list-status { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + white-space: nowrap; +} + +.vocab-list-status--mastered { + background: rgba(44, 143, 98, 0.14); + color: #257a55; +} + +.vocab-list-status--due { + background: rgba(191, 82, 72, 0.14); + color: #a54841; +} + +.vocab-list-status--reviewing { + background: rgba(196, 104, 75, 0.14); + color: var(--vocab-accent-strong); +} + +.vocab-list-status--new { + background: rgba(92, 70, 46, 0.09); + color: var(--vocab-muted); +} + @media (max-width: 768px) { .vocab-settings-modal { padding: 16px; @@ -7166,6 +7389,30 @@ body.theme-blue ::-webkit-scrollbar-thumb { width: 100%; padding: 20px; } + + .vocab-list-modal { + padding: 12px; + align-items: stretch; + } + + .vocab-list-modal__dialog { + max-height: none; + height: 100%; + border-radius: 14px; + } + + .vocab-list-modal__toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .vocab-list-modal__toolbar .btn { + width: 100%; + } + + .vocab-list-modal__body { + padding: 12px; + } } /* Developer Easter Egg - Connected Heart */ @@ -8068,4 +8315,3 @@ body.blue-dark-mode .practice-settings-block__head .hero-panel__muted { } - diff --git a/developer/tests/js/integration/vocabSessionView.test.js b/developer/tests/js/integration/vocabSessionView.test.js index b927a1cf..e91d162e 100644 --- a/developer/tests/js/integration/vocabSessionView.test.js +++ b/developer/tests/js/integration/vocabSessionView.test.js @@ -12,6 +12,8 @@ const repoRoot = path.resolve(__dirname, '../../../..'); const originalConsoleLog = (console && typeof console.log === 'function') ? console.log.bind(console) : null; +let activeDocumentStub = null; +const modalFocusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; function emitResult(payload) { const text = JSON.stringify(payload, null, 2); @@ -65,6 +67,12 @@ function patchVocabSessionView(source) { closeMenu, bindEvents, toggleMenu, + getWordStatus, + analyzeListWords, + openListModal, + closeListModal, + exportCurrentList, + renderListBrowser, setElements: (elements) => { state.elements = elements || {}; }, setStore: (store) => { state.store = store; }, setScheduler: (scheduler) => { state.scheduler = scheduler; }, @@ -134,6 +142,7 @@ function createElementStub(tag = 'div', overrides = {}) { textContent: '', value: '', hidden: false, + disabled: false, appendChild(child) { if (!child) { return child; @@ -214,6 +223,9 @@ function createElementStub(tag = 'div', overrides = {}) { }, focus() { this._focused = true; + if (activeDocumentStub) { + activeDocumentStub.activeElement = this; + } }, click() { this._clicked = true; @@ -231,7 +243,7 @@ function createDocumentStub() { const listeners = new Map(); const body = createElementStub('body'); - return { + const documentStub = { body, activeElement: null, addEventListener(type, handler) { @@ -271,6 +283,8 @@ function createDocumentStub() { return element; } }; + activeDocumentStub = documentStub; + return documentStub; } function createWindowStub(documentStub) { @@ -471,6 +485,7 @@ function createSessionElements() { const settingsModal = createElementStub('div'); const settingsDialog = createElementStub('div'); + const settingsClose = createElementStub('button'); const settingsError = createElementStub('div'); const settingsForm = createElementStub('form'); @@ -482,6 +497,28 @@ function createSessionElements() { const listSwitcher = createElementStub('div'); const menuButton = createElementStub('button'); const menu = createElementStub('div'); + const listModal = createElementStub('div'); + const listDialog = createElementStub('div'); + const listSubtitle = createElementStub('p'); + const listSearch = createElementStub('input'); + const listLearnedOnly = createElementStub('input'); + const listStats = createElementStub('div'); + const listBody = createElementStub('div'); + const listClose = createElementStub('button'); + + settingsDialog.appendChild(dailyField); + settingsDialog.appendChild(settingsClose); + settingsDialog.__queryMap = { + 'input, button, select, textarea': dailyField + }; + settingsDialog.__queryListMap = { + [modalFocusableSelector]: [dailyField, settingsClose] + }; + listDialog.appendChild(listSearch); + listDialog.appendChild(listClose); + listDialog.__queryListMap = { + [modalFocusableSelector]: [listSearch, listClose] + }; return { root: createElementStub('div'), @@ -509,7 +546,16 @@ function createSessionElements() { }, listSwitcher, menuButton, - menu + menu, + listModal, + listDialog, + listSubtitle, + listSearch, + listLearnedOnly, + listStats, + listBody, + settingsClose, + listClose }; } @@ -605,6 +651,10 @@ async function run() { 'data-action="toggle-side-panel"', 'data-action="save-note"', 'data-vocab-role="import-input"', + 'data-action="menu-view-list"', + 'data-vocab-role="list-modal"', + 'data-vocab-role="list-dialog"', + 'data-action="export-current-list"', 'data-vocab-role="settings-modal"' ]; markers.forEach((marker) => { @@ -612,6 +662,22 @@ async function run() { }); }); + await record('list modal body owns the table scroll', () => { + const css = readSource('css/main.css'); + const bodyRule = css.match(/\.vocab-list-modal__body\s*\{([^}]*)\}/); + const tableWrapRule = css.match(/\.vocab-list-table-wrap\s*\{([^}]*)\}/); + assert.ok(bodyRule, 'Missing list modal body rule'); + assert.ok(tableWrapRule, 'Missing table wrapper rule'); + assert.match(bodyRule[1], /flex:\s*1 1 220px/); + assert.match(bodyRule[1], /min-height:\s*0/); + assert.match(bodyRule[1], /overflow:\s*hidden/); + assert.match(tableWrapRule[1], /flex:\s*1/); + assert.match(tableWrapRule[1], /min-height:\s*0/); + assert.match(tableWrapRule[1], /overflow:\s*auto/); + assert.doesNotMatch(css, /@media\s*\(max-height:\s*480px\)/); + assert.doesNotMatch(css, /@media\s*\(max-height:\s*300px\)/); + }); + const vocabContext = createVocabContext(); const hooks = vocabContext.hooks; const windowStub = vocabContext.window; @@ -986,6 +1052,7 @@ async function run() { }; hooks.state.session.batchSize = 1; + hooks.openSettingsModal(elements.menuButton); await hooks.handleSettingsSubmit({ preventDefault() {}, currentTarget: elements.settingsForm @@ -1008,6 +1075,287 @@ async function run() { assert.ok(elements.menu.hidden); }); + await record('settings modal restores focus to visible menu trigger', () => { + const hiddenMenuItem = createElementStub('button'); + const trigger = createElementStub('button'); + trigger.dataset.action = 'menu-settings'; + documentStub.activeElement = hiddenMenuItem; + elements.menuButton._focused = false; + + elements.menu.dispatchEvent({ + type: 'click', + target: { + closest(selector) { + return selector === 'button[data-action]' ? trigger : null; + } + } + }); + hooks.closeSettingsModal(); + + assert.strictEqual(elements.menuButton._focused, true); + assert.ok(!hiddenMenuItem._focused); + }); + + await record('list status keeps due mastered words in the review queue', () => { + const config = { masteryCount: 4 }; + const dueMastered = hooks.getWordStatus({ + correctCount: 4, + nextReview: new Date(Date.now() - 60_000).toISOString() + }, config); + const futureMastered = hooks.getWordStatus({ + correctCount: 4, + nextReview: new Date(Date.now() + 60_000).toISOString() + }, config); + + assert.strictEqual(dueMastered.tone, 'due'); + assert.strictEqual(futureMastered.tone, 'mastered'); + + hooks.setStore(createMockStore([ + { word: 'alpha', meaning: 'A', correctCount: 4, nextReview: new Date(Date.now() - 60_000).toISOString() } + ], config)); + const analysis = hooks.analyzeListWords(); + assert.strictEqual(analysis.masteredCount, 1); + assert.strictEqual(analysis.dueCount, 1); + }); + + await record('list modal restores focus to visible menu trigger', async () => { + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + const hiddenMenuItem = createElementStub('button'); + const trigger = createElementStub('button'); + trigger.dataset.action = 'menu-view-list'; + hooks.setStore(store); + documentStub.activeElement = hiddenMenuItem; + elements.menuButton._focused = false; + + elements.menu.dispatchEvent({ + type: 'click', + target: { + closest(selector) { + return selector === 'button[data-action]' ? trigger : null; + } + } + }); + await flushPromises(); + hooks.closeListModal(); + + assert.strictEqual(elements.menuButton._focused, true); + assert.ok(!hiddenMenuItem._focused); + }); + + await record('Escape cancels a pending list modal open', async () => { + let resolveInit; + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + store.init = () => new Promise((resolve) => { + resolveInit = resolve; + }); + hooks.setStore(store); + const opening = hooks.openListModal(elements.menuButton); + let prevented = false; + + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Escape', + preventDefault() { + prevented = true; + } + }); + resolveInit(true); + await opening; + + assert.strictEqual(prevented, true); + assert.notStrictEqual(elements.listModal.dataset.open, 'true'); + assert.ok(elements.listModal.hidden); + }); + + await record('list and settings modals remain mutually exclusive', async () => { + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + hooks.setStore(store); + + await hooks.openListModal(elements.menuButton); + hooks.openSettingsModal(elements.menuButton); + assert.strictEqual(elements.settingsModal.dataset.open, 'true'); + assert.strictEqual(elements.listModal.dataset.open, 'false'); + + await hooks.openListModal(elements.menuButton); + assert.strictEqual(elements.listModal.dataset.open, 'true'); + assert.strictEqual(elements.settingsModal.dataset.open, 'false'); + hooks.closeListModal(); + }); + + await record('settings writes keep the latest submitted values', async () => { + const pendingSaves = []; + const store = createMockStore(); + store.setConfig = (config) => new Promise((resolve) => { + pendingSaves.push(() => { + store.config = { ...store.config, ...config }; + resolve(true); + }); + }); + hooks.setStore(store); + elements.settingsForm.__fields = { + dailyNew: '10', + reviewLimit: '50', + masteryCount: '3' + }; + hooks.openSettingsModal(elements.menuButton); + const pendingSave = hooks.handleSettingsSubmit({ + preventDefault() {}, + currentTarget: elements.settingsForm + }); + await flushPromises(); + + hooks.closeSettingsModal(); + hooks.openSettingsModal(elements.menuButton); + elements.settingsForm.__fields = { + dailyNew: '30', + reviewLimit: '80', + masteryCount: '5', + notify: '1' + }; + const latestSave = hooks.handleSettingsSubmit({ + preventDefault() {}, + currentTarget: elements.settingsForm + }); + assert.strictEqual(pendingSaves.length, 1); + + pendingSaves.shift()(); + await pendingSave; + await flushPromises(); + assert.strictEqual(pendingSaves.length, 1); + pendingSaves.shift()(); + await latestSave; + + assert.strictEqual(store.config.dailyNew, 30); + assert.strictEqual(store.config.reviewLimit, 80); + assert.strictEqual(store.config.masteryCount, 5); + assert.strictEqual(store.config.notify, true); + assert.strictEqual(elements.settingsModal.dataset.open, 'false'); + }); + + await record('Tab stays inside the active modal', async () => { + let prevented = false; + hooks.openSettingsModal(elements.menuButton); + elements.settingsClose.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: false, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(prevented, true); + assert.strictEqual(documentStub.activeElement, elements.settingsFields.dailyNew); + + prevented = false; + elements.settingsFields.dailyNew.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: true, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(prevented, true); + assert.strictEqual(documentStub.activeElement, elements.settingsClose); + hooks.closeSettingsModal(); + + const store = createMockStore([{ word: 'alpha', meaning: 'A' }]); + hooks.setStore(store); + await hooks.openListModal(elements.menuButton); + elements.listClose.focus(); + documentStub.dispatchEvent({ + type: 'keydown', + code: 'Tab', + key: 'Tab', + shiftKey: false, + preventDefault() {} + }); + assert.strictEqual(documentStub.activeElement, elements.listSearch); + hooks.closeListModal(); + }); + + await record('list rendering is paged and resets after search', async () => { + const words = Array.from({ length: 401 }, (_, index) => ({ + word: `word-${String(index + 1).padStart(3, '0')}`, + meaning: `Meaning ${index + 1}` + })); + const store = createMockStore(words); + let getWordsCalls = 0; + const originalGetWords = store.getWords.bind(store); + store.getWords = () => { + getWordsCalls += 1; + return originalGetWords(); + }; + hooks.setStore(store); + hooks.state.ui.listBrowserQuery = ''; + hooks.state.ui.listBrowserLearnedOnly = false; + hooks.state.ui.listBrowserPage = 1; + + hooks.renderListBrowser(); + const firstBody = elements.listBody.innerHTML.match(/([\s\S]*?)<\/tbody>/)[1]; + assert.strictEqual((firstBody.match(//g) || []).length, 200); + assert.strictEqual(getWordsCalls, 1); + + hooks.state.ui.listBrowserPage = 2; + hooks.renderListBrowser(); + assert.match(elements.listBody.innerHTML, /201<\/td>/); + elements.listSearch.dispatchEvent({ type: 'input', target: { value: 'word-401' } }); + assert.strictEqual(hooks.state.ui.listBrowserPage, 1); + await new Promise((resolve) => setTimeout(resolve, 220)); + assert.match(elements.listBody.innerHTML, /word-401/); + }); + + await record('filtered list export remains a complete mergeable word list', async () => { + const store = createMockStore([ + { word: 'alpha', meaning: 'A', example: 'First', freq: 0.8, correctCount: 2 }, + { word: 'beta', meaning: 'B', note: 'private note', questionId: 'internal-id' } + ]); + hooks.setStore(store); + hooks.state.ui.listBrowserQuery = 'alpha'; + hooks.state.ui.listBrowserLearnedOnly = true; + windowStub.URL.created.length = 0; + const anchor = createElementStub('a'); + vocabContext.document.createElement = () => anchor; + + hooks.exportCurrentList(); + + const download = windowStub.URL.created.at(-1); + assert.ok(download, 'Expected an exported blob'); + const payload = JSON.parse(await download.blob.text()); + assert.strictEqual(payload.type, 'wordlist'); + assert.strictEqual(payload.category, 'external'); + assert.strictEqual(payload.entries.length, 2); + assert.deepStrictEqual(Array.from(payload.entries, (entry) => entry.word), ['alpha', 'beta']); + assert.ok(!Object.prototype.hasOwnProperty.call(payload, 'version')); + assert.ok(!Object.prototype.hasOwnProperty.call(payload, 'words')); + assert.ok(!Object.prototype.hasOwnProperty.call(payload.entries[0], 'correctCount')); + assert.ok(!Object.prototype.hasOwnProperty.call(payload.entries[1], 'questionId')); + assert.match(windowStub.messages.at(-1).text, /可分享词表/); + }); + + await record('card actions are ignored while list modal is open', () => { + hooks.state.session.stage = 'recognition'; + elements.listModal.dataset.open = 'true'; + let prevented = false; + hooks.handleCardAction({ + target: { + closest() { + return { dataset: { action: 'reveal-meaning' } }; + } + }, + preventDefault() { + prevented = true; + } + }); + assert.strictEqual(hooks.state.session.stage, 'recognition'); + assert.strictEqual(prevented, false); + elements.listModal.dataset.open = 'false'; + }); + await record('import request triggers input', () => { const store = createMockStore(); hooks.setStore(store); diff --git a/developer/tests/js/vocabDataIO.test.js b/developer/tests/js/vocabDataIO.test.js index b3369973..888fedd9 100644 --- a/developer/tests/js/vocabDataIO.test.js +++ b/developer/tests/js/vocabDataIO.test.js @@ -11,12 +11,8 @@ global.AppData = { async getConfig() { return { activeListId: 'spelling-errors-p1', dailyNew: 8 }; }, - async readList(listId) { - assert.strictEqual(listId, 'spelling-errors-p1'); - return { - id: listId, - words: [{ id: 'word-1', word: 'garden', meaning: '花园' }] - }; + async readList() { + throw new Error('进度导出不应读取原始 collection 词条'); } } }; @@ -26,11 +22,23 @@ const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '../../..'); eval(fs.readFileSync(path.join(repoRoot, 'js/utils/vocabDataIO.js'), 'utf8')); +function makeJsonFile(payload, name = 'data.json') { + const blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); + Object.defineProperty(blob, 'name', { value: name }); + return blob; +} + +const vendorWords = await window.VocabDataIO.importWordList(makeJsonFile({ + version: 'vendor-1', + words: [{ id: 'vendor-1', word: 'alpha', meaning: 'A', correctCount: 2 }] +}, 'vendor.json')); +assert.strictEqual(vendorWords.type, 'wordlist'); +assert.strictEqual(vendorWords.entries.length, 1); + const importBlob = new Blob([JSON.stringify({ version: '2.0', listId: 'spelling-errors-p1', config: { activeListId: 'spelling-errors-p1', dailyNew: 8 }, - reviewQueue: ['legacy-derived-id'], words: [{ id: 'word-1', word: 'garden', meaning: '花园', nextReview: '2026-07-25T00:00:00.000Z' }] })], { type: 'application/json' }); Object.defineProperty(importBlob, 'name', { value: 'progress.json' }); @@ -38,13 +46,48 @@ Object.defineProperty(importBlob, 'name', { value: 'progress.json' }); const imported = await window.VocabDataIO.importWordList(importBlob); assert.strictEqual(imported.type, 'progress'); assert.strictEqual(imported.meta.listId, 'spelling-errors-p1'); -assert.strictEqual(imported.meta.reviewQueue, undefined, 'derived review queue must not cross the import boundary'); assert.strictEqual(imported.entries[0].nextReview, '2026-07-25T00:00:00.000Z'); -const exported = JSON.parse(await (await window.VocabDataIO.exportProgress()).text()); +await assert.rejects( + window.VocabDataIO.importWordList(makeJsonFile({ + type: 'progress', + version: '2.0', + listId: 'spelling-errors-p1', + config: { activeListId: 'spelling-errors-p1' }, + words: [{ word: 123, meaning: { value: 'bad' } }] + }, 'bad-progress.json')), + /invalid|无效/i +); + +await assert.rejects( + window.VocabDataIO.importWordList(makeJsonFile({ + type: 'progress', + words: [{ word: 'alpha', meaning: 'A' }] + }, 'incomplete-progress.json')), + /v2|配置|词表/i +); + +await assert.rejects( + window.VocabDataIO.importWordList(makeJsonFile({ + version: '0.6.2-fix', + config: { activeListId: 'default' }, + words: [{ word: 'alpha', meaning: 'A', correctCount: 2 }], + reviewQueue: ['alpha'] + }, 'v1-progress.json')), + /不支持 v1 进度备份/ +); + +const exportBlob = await window.VocabDataIO.exportProgress([ + { id: 'word-1', word: 'garden', meaning: '花园', userInput: 'gardon' } +]); +const exported = JSON.parse(await exportBlob.text()); assert.strictEqual(exported.listId, 'spelling-errors-p1'); +assert.strictEqual(exported.type, 'progress'); assert.strictEqual(exported.words[0].word, 'garden'); assert.strictEqual(Object.prototype.hasOwnProperty.call(exported, 'reviewQueue'), false); +const roundTrip = await window.VocabDataIO.importWordList(makeJsonFile(exported, 'round-trip.json')); +assert.strictEqual(roundTrip.type, 'progress'); +assert.strictEqual(roundTrip.entries[0].meaning, '花园'); console.log(JSON.stringify({ status: 'pass', diff --git a/developer/tests/js/vocabStore.test.js b/developer/tests/js/vocabStore.test.js index a32f5b3d..1a82c308 100644 --- a/developer/tests/js/vocabStore.test.js +++ b/developer/tests/js/vocabStore.test.js @@ -293,6 +293,67 @@ async function testDefaultLexiconWriteFailureRejectsInitialization() { assert.strictEqual(vocabStore.state.ready, false, '持久化失败时不得把词汇域标记为 ready'); } +async function testConfigUsesCentralBoundsAndTypes() { + const vocabStore = loadVocabStore({ + embeddedWords: [], + dataSeed: { + words: [{ id: 'word-1', word: 'alpha', meaning: 'A' }] + } + }); + await vocabStore.init(); + + await vocabStore.setConfig({ + dailyNew: -10, + reviewLimit: 999, + masteryCount: 2.9, + notify: 'yes', + theme: 'neon' + }); + let config = vocabStore.getConfig(); + assert.strictEqual(config.dailyNew, 0); + assert.strictEqual(config.reviewLimit, 300); + assert.strictEqual(config.masteryCount, 2); + assert.strictEqual(config.notify, true); + assert.strictEqual(config.theme, 'auto'); + + await vocabStore.setConfig({ + dailyNew: '10', + reviewLimit: Number.NaN, + masteryCount: Number.POSITIVE_INFINITY, + notify: false, + theme: 'dark' + }); + config = vocabStore.getConfig(); + assert.strictEqual(config.dailyNew, 20); + assert.strictEqual(config.reviewLimit, 100); + assert.strictEqual(config.masteryCount, 4); + assert.strictEqual(config.notify, false); + assert.strictEqual(config.theme, 'dark'); +} + +async function testProgressRestoreRequiresCompleteV2Identity() { + const vocabStore = loadVocabStore({ + embeddedWords: [], + dataSeed: { + words: [{ id: 'word-1', word: 'alpha', meaning: 'A' }] + } + }); + await vocabStore.init(); + + await assert.rejects( + vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], { dailyNew: 10 }, null), + /未知词表/ + ); + await assert.rejects( + vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], null, 'custom'), + /有效配置/ + ); + await assert.rejects( + vocabStore.replaceProgress([{ word: 'beta', meaning: 'B' }], { dailyNew: 10 }, 'other-list'), + /未知词表/ + ); +} + async function main() { const results = []; try { @@ -306,6 +367,10 @@ async function main() { results.push({ name: '背诵更新保留错词业务元数据', status: 'pass' }); await testDefaultLexiconWriteFailureRejectsInitialization(); results.push({ name: '默认词库持久化失败会阻断 ready', status: 'pass' }); + await testConfigUsesCentralBoundsAndTypes(); + results.push({ name: '配置写入遵守统一范围和类型', status: 'pass' }); + await testProgressRestoreRequiresCompleteV2Identity(); + results.push({ name: '进度恢复要求完整 v2 词表身份', status: 'pass' }); console.log(JSON.stringify({ status: 'pass', detail: `${results.length}/${results.length} 测试通过`, diff --git a/js/bundles/more.bundle.js b/js/bundles/more.bundle.js index 8617f4a4..aec1939a 100644 --- a/js/bundles/more.bundle.js +++ b/js/bundles/more.bundle.js @@ -24,6 +24,10 @@ const DEFAULT_EXPORT_VERSION = '0.6.2-fix'; + function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); + } + function normalizeFrequency(value) { if (value == null || value === '') { return null; @@ -96,26 +100,21 @@ } function cloneProgressEntry(raw) { - if (!raw || typeof raw !== 'object') { + if (!isPlainObject(raw)) { return null; } - if (!raw.word || !raw.meaning) { + const word = typeof raw.word === 'string' ? raw.word.trim() : ''; + const meaning = typeof raw.meaning === 'string' ? raw.meaning.trim() : ''; + if (!word || !meaning) { return null; } - const clone = {}; - Object.keys(raw).forEach((key) => { - clone[key] = raw[key]; - }); - return clone; + return { ...raw, word, meaning }; } function buildImportResult(type, entries, meta = {}) { const safeEntries = Array.isArray(entries) ? entries.filter(Boolean) : []; const normalizedMeta = { ...meta }; normalizedMeta.category = normalizeCategory(normalizedMeta.category, type === 'progress' ? 'user' : 'external'); - if (Array.isArray(normalizedMeta.reviewQueue)) { - normalizedMeta.reviewQueue = normalizedMeta.reviewQueue.map((item) => String(item)); - } return { type, entries: safeEntries, @@ -239,12 +238,30 @@ } if (payload && typeof payload === 'object' && Array.isArray(payload.words)) { const metaCategory = extractCategory(payload.meta, null); - const category = extractCategory(payload, metaCategory || 'external'); - const looksProgress = typeof payload.version === 'string' - || Array.isArray(payload.reviewQueue) - || payload.words.some((item) => item && (item.id || item.box || item.correctCount || item.lastReviewed || item.nextReview)); + const declaredType = typeof payload.type === 'string' ? payload.type.trim().toLowerCase() : ''; + const explicitProgress = declaredType === 'progress' || declaredType === 'progress-backup'; + const hasListId = typeof payload.listId === 'string' && payload.listId.trim(); + const hasV2ProgressEnvelope = typeof payload.version === 'string' + && isPlainObject(payload.config) + && hasListId; + const legacyProgressEnvelope = !declaredType + && typeof payload.version === 'string' + && isPlainObject(payload.config) + && Array.isArray(payload.reviewQueue) + && !hasListId; + if (legacyProgressEnvelope) { + throw new Error('不支持 v1 进度备份,请使用 v2 格式重新导出'); + } + if (explicitProgress && !hasV2ProgressEnvelope) { + throw new Error('进度备份缺少 v2 词表或配置数据'); + } + const looksProgress = (explicitProgress || !declaredType) && hasV2ProgressEnvelope; + const category = extractCategory(payload, metaCategory || (looksProgress ? 'user' : 'external')); if (looksProgress) { - const entries = payload.words.map(cloneProgressEntry).filter(Boolean); + const entries = payload.words.map(cloneProgressEntry); + if (entries.some((entry) => !entry)) { + throw new Error('进度备份包含无效词汇数据'); + } return buildImportResult('progress', entries, { format: 'json', originalLength: payload.words.length, @@ -253,7 +270,7 @@ : undefined, category: category || 'user', version: typeof payload.version === 'string' ? payload.version : undefined, - config: payload.config && typeof payload.config === 'object' ? { ...payload.config } : undefined, + config: isPlainObject(payload.config) ? { ...payload.config } : undefined, name: typeof payload.name === 'string' ? payload.name : undefined, source: typeof payload.source === 'string' ? payload.source : undefined, exportedAt: typeof payload.exportedAt === 'string' ? payload.exportedAt : undefined @@ -323,18 +340,23 @@ return normalizedResult; } - async function exportProgress() { + async function exportProgress(words) { if (!window.AppData || !window.AppData.vocab) throw new Error('AppData.vocab 未加载'); + if (!Array.isArray(words)) throw new Error('当前词表尚未加载'); await window.AppData.ready; const config = await window.AppData.vocab.getConfig(); const listId = config.activeListId || 'default'; - const list = await window.AppData.vocab.readList(listId); + const entries = words.map(cloneProgressEntry); + if (entries.some((entry) => !entry)) { + throw new Error('当前词表包含无效词汇数据'); + } const payload = { + type: 'progress', version: DEFAULT_EXPORT_VERSION, exportedAt: new Date().toISOString(), listId, config, - words: Array.isArray(list) ? list : (list && Array.isArray(list.words) ? list.words : []) + words: entries }; return new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); } @@ -770,6 +792,12 @@ const DEFAULT_LIST_ID = 'default'; const DEFAULT_LEXICON_URL = 'assets/wordlists/ielts_core.json'; const SPELLING_ERROR_LIST_IDS = new Set(['spelling-errors-p1', 'spelling-errors-p4', 'spelling-errors-master']); + const CONFIG_LIMITS = Object.freeze({ + dailyNew: { min: 0, max: 200 }, + reviewLimit: { min: 1, max: 300 }, + masteryCount: { min: 1, max: 10 } + }); + const VALID_THEMES = new Set(['auto', 'light', 'dark']); const state = { words: [], @@ -1018,12 +1046,22 @@ function mergeConfig(config) { const base = { ...DEFAULT_CONFIG }; - if (config && typeof config === 'object') { - Object.keys(DEFAULT_CONFIG).forEach((key) => { - if (typeof config[key] !== 'undefined') { - base[key] = config[key]; - } - }); + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return base; + } + Object.keys(CONFIG_LIMITS).forEach((key) => { + const value = config[key]; + const limits = CONFIG_LIMITS[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return; + } + base[key] = Math.min(limits.max, Math.max(limits.min, Math.floor(value))); + }); + if (typeof config.theme === 'string' && VALID_THEMES.has(config.theme)) { + base.theme = config.theme; + } + if (typeof config.notify === 'boolean') { + base.notify = config.notify; } return base; } @@ -1274,15 +1312,17 @@ return getConfig(); } - async function replaceProgress(words, config = {}, listId = null) { + async function replaceProgress(words, config, listId) { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error('进度备份缺少有效配置'); + } + const requestedListId = typeof listId === 'string' ? listId.trim() : ''; + if (!requestedListId || !VOCAB_LISTS[requestedListId]) { + throw new Error('进度备份包含未知词表'); + } const normalized = Array.isArray(words) ? words.map((word) => normalizeWordRecord(word)).filter(Boolean) : []; - const requestedListId = typeof listId === 'string' && listId.trim() - ? listId.trim() - : (typeof config.activeListId === 'string' && config.activeListId.trim() - ? config.activeListId.trim() - : state.activeListId); const nextConfig = mergeConfig({ ...config, activeListId: requestedListId }); const vocab = await requireVocabData(); await vocab.replaceProgress({ listId: requestedListId, words: normalized, config: nextConfig }); @@ -2281,6 +2321,9 @@ reviewLimit: { min: 1, max: 300 }, masteryCount: { min: 1, max: 10 } }); + const LIST_PAGE_SIZE = 200; + const LIST_SEARCH_DEBOUNCE_MS = 180; + const MODAL_FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; const state = { container: null, @@ -2295,9 +2338,18 @@ menuOpen: false, ui: { sidePanelManual: null, - lastFocus: null, importing: false, exporting: false, + listBrowserQuery: '', + listBrowserLearnedOnly: false, + listBrowserPage: 1, + listSearchTimer: null, + modalEpoch: 0, + modalOwner: null, + settingsRestoreFocus: null, + listRestoreFocus: null, + settingsSaveToken: 0, + settingsSaveTail: Promise.resolve(), listSwitcher: null, listSwitcherListenerAttached: false }, @@ -2356,6 +2408,52 @@ return state.elements.settingsModal?.dataset.open === 'true'; } + function isListModalOpen() { + return state.elements.listModal?.dataset.open === 'true'; + } + + function isListModalPending() { + return state.ui.modalOwner === 'list-pending'; + } + + function focusElement(target) { + const fallback = state.elements.menuButton; + const focusTarget = target && typeof target.focus === 'function' ? target : fallback; + if (focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus(); + } + } + + function trapModalFocus(event, dialog) { + if (!dialog) { + return; + } + const focusable = Array.from(dialog.querySelectorAll(MODAL_FOCUSABLE_SELECTOR)) + .filter((element) => !element.hidden && !element.disabled); + if (!focusable.length) { + event.preventDefault(); + focusElement(dialog); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (event.shiftKey && (active === first || !dialog.contains(active))) { + event.preventDefault(); + focusElement(last); + } else if (!event.shiftKey && (active === last || !dialog.contains(active))) { + event.preventDefault(); + focusElement(first); + } + } + + function clearListSearchTimer() { + if (state.ui.listSearchTimer) { + clearTimeout(state.ui.listSearchTimer); + state.ui.listSearchTimer = null; + } + } + function clampNumber(value, min, max) { if (typeof value !== 'number' || Number.isNaN(value)) { return null; @@ -2463,6 +2561,7 @@