feat: 词汇库搜索、筛选与编辑 (#12)

- GET /api/v1/terms 列表:查询键白名单、page/limit 边界(默认 20、上限 100)、
  排序 updated_at DESC;搜索同时匹配身份键、显示原文与个人释义,转小写后比较(term 为
  二进制排序规则),%/_/\ 按字面值转义;status 与 kind 可组合
- PATCH /api/v1/terms/:id 编辑:复用释义/例句与状态/等级校验,身份不可编辑;
  只有状态或等级变化才重排复习时间,历史作答记录与计数保留
- 学习端新增 /vocab 路由与导航、stores/vocabulary.ts、VocabView.vue(搜索、筛选、
  分页、空态与无结果清除、编辑对话框含 1~7 等级选择器、筛选与页码写入 URL)
- 释义/例句/状态/等级抽成 TermFormFields.vue,与阅读器面板共用,避免规则漂移
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
This commit is contained in:
ila
2026-09-14 23:46:42 +08:00
parent 329ea7336a
commit 3ebec5b11b
19 changed files with 1376 additions and 33 deletions
+259
View File
@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { createRouter, createMemoryHistory, type Router } from 'vue-router'
import { ElSelect } from 'element-plus'
import VocabView from '../views/VocabView.vue'
import { useSessionStore } from '../stores/session'
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
const user = { id: 42, username: 'fictional-vocab', role: 'learner' as const }
const word = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
id: 1, term: 'curiosity', originalForm: 'curiosity', definition: '好奇心;求知欲', examples: ['Learning begins with curiosity.'],
status: 'new', level: 0, kind: 'word', wordCount: 1, updatedAt: '2026-01-02T00:00:00Z', ...overrides,
})
const phrase = (overrides: Partial<VocabularyTerm> = {}): VocabularyTerm => ({
id: 2, term: 'a small step', originalForm: 'a small step', definition: '一小步', examples: ['Take a small step.'],
status: 'learning', level: 3, kind: 'phrase', wordCount: 3, updatedAt: '2026-01-03T00:00:00Z', ...overrides,
})
const ok = (data: unknown) => new Response(JSON.stringify({ code: 200, data }))
let wrapper: VueWrapper | undefined
function listCalls(fetchMock: MockInstance): URLSearchParams[] {
return fetchMock.mock.calls
.filter(([url]) => String(url).includes('/terms?'))
.map(([url]) => new URLSearchParams(String(url).split('?')[1]))
}
function mockApi(items: VocabularyTerm[], total = items.length, onPatch?: (body: Record<string, unknown>) => VocabularyTerm): MockInstance {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const url = String(input)
if (url.includes('/terms?') || url.endsWith('/terms')) {
const params = new URLSearchParams(url.split('?')[1] ?? '')
const query = (params.get('query') ?? '').toLowerCase()
const status = params.get('status') ?? ''
const kind = params.get('kind') ?? ''
const page = Number(params.get('page') ?? 1)
const limit = Number(params.get('limit') ?? VOCAB_PAGE_SIZE)
const filtered = items.filter(item =>
(!query || item.term.includes(query) || item.originalForm.toLowerCase().includes(query) || item.definition.includes(query))
&& (!status || item.status === status) && (!kind || item.kind === kind))
return ok({ items: filtered.slice((page - 1) * limit, page * limit), total: Math.max(total, filtered.length), page, limit })
}
if (url.includes('/terms/') && String(init?.method) === 'PATCH') {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
const base = items.find(item => url.endsWith(`/terms/${item.id}`)) ?? word()
return ok({ term: onPatch ? onPatch(body) : { ...base, ...body } })
}
return ok({})
})
}
async function viewAt(path: string): Promise<Router> {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<div>LibraryStub</div>' } }, { path: '/vocab', component: VocabView }, { path: '/review', component: { template: '<div>ReviewStub</div>' } }],
})
await router.push(path)
await router.isReady()
return router
}
async function openVocab(path = '/vocab') {
useSessionStore().user = { ...user }
const router = await viewAt(path)
wrapper = mount(VocabView, { attachTo: document.body, global: { plugins: [router] } })
// The list lands one microtask after mount; flush twice so the first assertion sees it.
await flushPromises()
await flushPromises()
return { view: wrapper, router }
}
describe('vocabulary store', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear() })
afterEach(() => { vi.restoreAllMocks() })
it('loads one page with the current filters and reports paging state', async () => {
const fetchMock = mockApi([word(), phrase()], 45)
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
const params = listCalls(fetchMock)[0]!
expect(params.get('page')).toBe('1')
expect(params.get('limit')).toBe(String(VOCAB_PAGE_SIZE))
expect(params.get('query')).toBeNull()
expect(vocabulary.items).toHaveLength(2)
expect(vocabulary.total).toBe(45)
expect(vocabulary.totalPages).toBe(3)
})
it('applies search and filters from the first page and can clear them', async () => {
const fetchMock = mockApi([word(), phrase()])
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
vocabulary.page = 3
vocabulary.query = 'small'
vocabulary.status = 'learning'
vocabulary.kind = 'phrase'
await vocabulary.apply()
expect(vocabulary.page).toBe(1)
const params = listCalls(fetchMock)[0]!
expect(params.get('query')).toBe('small')
expect(params.get('status')).toBe('learning')
expect(params.get('kind')).toBe('phrase')
expect(vocabulary.items.map(item => item.id)).toEqual([2])
await vocabulary.clearFilters()
expect(vocabulary.hasFilters).toBe(false)
expect(vocabulary.items).toHaveLength(2)
})
it('clamps paging to the available pages', async () => {
const fetchMock = mockApi([word()], 5)
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
await vocabulary.goToPage(99)
expect(vocabulary.page).toBe(1)
expect(listCalls(fetchMock)).toHaveLength(1)
})
it('keeps the list when loading fails and reports the message', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('网络暂不可用'))
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
expect(vocabulary.error).toContain('网络暂不可用')
expect(vocabulary.items).toHaveLength(0)
})
it('replaces the edited row and drops the previous account state', async () => {
mockApi([word(), phrase()], 2, body => ({ ...word(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 1) }))
useSessionStore().user = { ...user }
const vocabulary = useVocabularyStore()
await vocabulary.load()
const updated = await vocabulary.updateTerm(1, { definition: '好奇心(改)', examples: [], status: 'learning', level: 4 })
expect(updated.level).toBe(4)
expect(vocabulary.items[0]).toMatchObject({ definition: '好奇心(改)', status: 'learning', level: 4 })
useSessionStore().user = { id: 43, username: 'other-fictional', role: 'learner' }
await flushPromises()
expect(vocabulary.items).toHaveLength(0)
expect(vocabulary.total).toBe(0)
})
})
describe('vocabulary view', () => {
beforeEach(() => { setActivePinia(createPinia()); sessionStorage.clear(); vi.restoreAllMocks() })
afterEach(() => { wrapper?.unmount(); wrapper = undefined })
it('searches and filters through the toolbar and shows the server result', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view } = await openVocab()
expect(view.findAll('.vocab-row')).toHaveLength(2)
// The phrase row carries its kind and level.
expect(view.text()).toContain('短语 · 学习中 · 等级 3')
await view.get('#vocab-query').setValue('small')
await view.get('[data-testid="vocab-search"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('query')).toBe('small')
expect(view.findAll('.vocab-row')).toHaveLength(1)
expect(view.text()).toContain('一小步')
})
it('filters by status immediately and mirrors everything into the URL', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view, router } = await openVocab()
const statusSelect = view.findAllComponents(ElSelect)[0]!
statusSelect.vm.$emit('update:modelValue', 'learning')
statusSelect.vm.$emit('change', 'learning')
await flushPromises()
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('status')).toBe('learning')
expect(view.findAll('.vocab-row')).toHaveLength(1)
expect(router.currentRoute.value.query.status).toBe('learning')
})
it('restores the filters written in the URL', async () => {
const fetchMock = mockApi([word(), phrase()], 2)
const { view } = await openVocab('/vocab?query=curio&kind=word&page=1')
expect(listCalls(fetchMock)[0]!.get('query')).toBe('curio')
expect(listCalls(fetchMock)[0]!.get('kind')).toBe('word')
expect((view.get('#vocab-query').element as HTMLInputElement).value).toBe('curio')
expect(view.findAll('.vocab-row')).toHaveLength(1)
})
it('offers clearing the filters when nothing matches, and an empty state otherwise', async () => {
mockApi([word()], 1)
const { view } = await openVocab()
await view.get('#vocab-query').setValue('zzzz')
await view.get('[data-testid="vocab-search"]').trigger('click')
await flushPromises()
expect(view.get('[data-testid="vocab-none"]').text()).toContain('没有匹配的词条')
await view.get('[data-testid="vocab-clear"]').trigger('click')
await flushPromises()
expect(view.findAll('.vocab-row')).toHaveLength(1)
wrapper?.unmount()
mockApi([], 0)
const empty = await openVocab()
expect(empty.view.get('[data-testid="vocab-empty"]').text()).toContain('生词本还是空的')
})
it('edits an entry with a level selector and keeps the filters after saving', async () => {
const fetchMock = mockApi([word(), phrase()], 2, body => ({ ...phrase(), definition: String(body.definition), status: 'learning', level: Number(body.level ?? 3) }))
const { view } = await openVocab()
const statusSelect = view.findAllComponents(ElSelect)[0]!
statusSelect.vm.$emit('update:modelValue', 'learning')
statusSelect.vm.$emit('change', 'learning')
await flushPromises()
await view.get('[data-testid="vocab-edit-2"]').trigger('click')
await flushPromises()
// The dialog is prefilled and shows the level selector for a learning entry.
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('一小步')
expect(view.find('[data-testid="term-level"]').exists()).toBe(true)
await view.get('#term-definition').setValue('一小步(改)')
await view.get('[data-testid="vocab-save"]').trigger('click')
await flushPromises()
const patch = fetchMock.mock.calls.find(([, init]) => String((init as RequestInit | undefined)?.method) === 'PATCH')!
expect(JSON.parse(String((patch[1] as RequestInit).body))).toMatchObject({ definition: '一小步(改)', status: 'learning', level: 3 })
expect(view.get('[data-testid="vocab-notice"]').text()).toContain('已保存')
expect(view.get('#vocab-status')).toBeTruthy()
expect(view.findAll('.vocab-row')).toHaveLength(1)
})
it('hides the level selector for other statuses and keeps the input when saving fails', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
if (String(init?.method) === 'PATCH') return new Response(JSON.stringify({ code: 400, msg: '学习等级须为 1~7' }), { status: 400 })
return ok({ items: [word()], total: 1 })
})
const { view } = await openVocab()
await view.get('[data-testid="vocab-edit-1"]').trigger('click')
await flushPromises()
expect(view.find('[data-testid="term-level"]').exists()).toBe(false)
await view.get('#term-definition').setValue('改过的释义')
await view.get('[data-testid="vocab-save"]').trigger('click')
await flushPromises()
expect(view.text()).toContain('学习等级须为 1~7')
expect((view.get('#term-definition').element as HTMLTextAreaElement).value).toBe('改过的释义')
expect(view.find('[data-testid="vocab-dialog"]').isVisible()).toBe(true)
})
it('pages forward and back with the server totals', async () => {
// Three real pages of entries, so paging back and forth has content both ways.
const many = Array.from({ length: 45 }, (_, index) => word({ id: index + 1, term: `word ${index + 1}` }))
const fetchMock = mockApi(many, 45)
const { view } = await openVocab()
expect(view.get('.vocab-paging').text()).toContain('第 1 / 3 页')
expect(view.findAll('.vocab-row')).toHaveLength(VOCAB_PAGE_SIZE)
await view.get('[data-testid="vocab-next"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('2')
await view.get('[data-testid="vocab-prev"]').trigger('click')
await flushPromises()
expect(listCalls(fetchMock)[listCalls(fetchMock).length - 1]!.get('page')).toBe('1')
})
it('links to the due review from the list', async () => {
mockApi([word()], 1)
const { view } = await openVocab()
expect(view.get('.page-actions a[href="/review"]').text()).toContain('开始到期复习')
})
})
+21 -20
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { TERM_STATUSES, type LookupResult, type TermStatus } from '../composables/useReaderLookup'
import { ElButton } from 'element-plus'
import TermFormFields from './TermFormFields.vue'
import { type LookupResult, type TermStatus } from '../composables/useReaderLookup'
defineProps<{
word: string
@@ -22,6 +23,9 @@ defineProps<{
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
// The reader panel keeps the four statuses only, so the level field exists for the shared form
// but is never shown here and never sent: the server keeps the earned level.
const level = ref(1)
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number]; adjust: [edge: 'start' | 'end', direction: -1 | 1] }>()
const heading = ref<HTMLElement | null>(null)
const panel = ref<HTMLElement | null>(null)
@@ -74,24 +78,21 @@ onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize',
</template>
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</ElButton>
</div>
<div class="lookup-term">
<p class="lookup-term-title">学习状态</p>
<ElRadioGroup v-model="status" :disabled="saving || prefilling || !!prefillError" aria-label="学习状态">
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
</ElRadioGroup>
<p v-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
<label for="term-definition">我的释义 <span>{{ savedTermId ? '已保存' : '新词条' }}</span></label>
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="saving || prefilling || !!prefillError" placeholder="记下当前词的释义" />
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="saving || prefilling || !!prefillError" placeholder="可选,每行一条" />
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
<div class="lookup-actions">
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
</div>
<TermFormFields
v-model:definition="definition"
v-model:examples="examples"
v-model:status="status"
v-model:level="level"
:hint="savedTermId ? '已保存' : '新词条'"
:disabled="saving || prefilling || !!prefillError"
/>
<p v-if="prefilling" role="status" class="subtle">正在读取已保存的内容…</p>
<p v-else-if="prefillError" role="alert" class="lookup-message">{{ prefillError }}</p>
<p v-if="saved" role="status" class="lookup-saved">✓ {{ saved }}</p>
<p v-if="saveError" role="alert" class="lookup-message">{{ saveError }}</p>
<div class="lookup-actions">
<ElButton type="primary" data-testid="term-save" :loading="saving" :disabled="!canSave" @click="$emit('save')">保存到生词本</ElButton>
<ElButton text @click="$emit('close')">关闭,继续阅读</ElButton>
</div>
</aside>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
// The reader panel and the vocabulary dialog edit the same fields, so they share this form
// instead of keeping two copies of the same rules.
const props = defineProps<{ disabled?: boolean; showLevel?: boolean; hint?: string }>()
const definition = defineModel<string>('definition', { required: true })
const examples = defineModel<string>('examples', { required: true })
const status = defineModel<TermStatus>('status', { required: true })
const level = defineModel<number>('level', { required: true })
const levelVisible = computed(() => props.showLevel === true && status.value === 'learning')
</script>
<template>
<div class="term-fields">
<p class="lookup-term-title">学习状态</p>
<ElRadioGroup v-model="status" :disabled="disabled" aria-label="学习状态">
<ElRadio v-for="item in TERM_STATUSES" :key="item.value" :value="item.value">{{ item.label }}</ElRadio>
</ElRadioGroup>
<p v-if="levelVisible" class="term-level">
<label for="term-level">学习等级</label>
<ElSelect id="term-level" v-model="level" :disabled="disabled" class="term-level-select" data-testid="term-level">
<ElOption v-for="value in 7" :key="value" :label="`${value} 级`" :value="value" />
</ElSelect>
</p>
<p v-else-if="status === 'learning'" class="subtle">学习中按 1~7 级记录,等级由复习推进。</p>
<label for="term-definition">我的释义 <span v-if="hint">{{ hint }}</span></label>
<ElInput id="term-definition" v-model="definition" type="textarea" :rows="3" :disabled="disabled" placeholder="记下当前词的释义" />
<label for="term-examples">例句 <span>每行一条,最多 5 条</span></label>
<ElInput id="term-examples" v-model="examples" type="textarea" :rows="2" :disabled="disabled" placeholder="可选,每行一条" />
</div>
</template>
+1
View File
@@ -7,6 +7,7 @@ const router = createRouter({
{ path: '/login', name: 'login', component: () => import('../views/LoginView.vue') },
{ path: '/', name: 'library', meta: { private: true }, component: () => import('../views/LibraryView.vue') },
{ path: '/review', name: 'review', meta: { private: true }, component: () => import('../views/ReviewView.vue') },
{ path: '/vocab', name: 'vocab', meta: { private: true }, component: () => import('../views/VocabView.vue') },
{ path: '/import', name: 'import', meta: { private: true }, component: () => import('../views/ImportView.vue') },
{ path: '/books/:id', name: 'book', meta: { private: true }, component: () => import('../views/BookView.vue') },
{ path: '/chapters/:id', name: 'chapter', meta: { private: true }, component: () => import('../views/ReaderView.vue') },
+154
View File
@@ -0,0 +1,154 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import { useSessionStore } from './session'
import type { TermStatus } from '../composables/useReaderLookup'
/** One entry of the vocabulary list, exactly as the term endpoints return it. */
export interface VocabularyTerm {
id: number
term: string
originalForm: string
definition: string
examples: string[]
status: TermStatus
level: number
kind: 'word' | 'phrase'
wordCount: number
updatedAt: string
}
export interface VocabularyEdit {
definition: string
examples: string[]
status: TermStatus
level?: number
}
export const VOCAB_PAGE_SIZE = 20
/**
* The vocabulary list is one server page at a time. Filters live in the store so that opening
* and closing the edit dialog, or coming back from the reader, keeps what the learner searched
* for; the view mirrors them into the URL for reloads.
*/
export const useVocabularyStore = defineStore('vocabulary', () => {
const session = useSessionStore()
const items = ref<VocabularyTerm[]>([])
const total = ref(0)
const page = ref(1)
const query = ref('')
const status = ref<TermStatus | ''>('')
const kind = ref<'word' | 'phrase' | ''>('')
const loading = ref(false)
const error = ref('')
const saving = ref(false)
const saveError = ref('')
let sequence = 0
let generation = 0
const hasFilters = computed(() => query.value !== '' || status.value !== '' || kind.value !== '')
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / VOCAB_PAGE_SIZE)))
watch(() => session.user?.id ?? null, (next, previous) => {
if (next !== previous) reset()
}, { flush: 'sync' })
function ownerId(): number | null {
return session.user?.id ?? null
}
function isStale(version: number, owner: number | null): boolean {
return version !== generation || ownerId() !== owner
}
function reset(): void {
generation++
sequence++
items.value = []
total.value = 0
page.value = 1
query.value = ''
status.value = ''
kind.value = ''
loading.value = false
error.value = ''
saving.value = false
saveError.value = ''
}
/** Loads the current page with the current filters. */
async function load(): Promise<void> {
const version = generation
const owner = ownerId()
const seq = ++sequence
loading.value = true
error.value = ''
try {
const params = new URLSearchParams()
if (query.value.trim()) params.set('query', query.value.trim())
if (status.value) params.set('status', status.value)
if (kind.value) params.set('kind', kind.value)
params.set('page', String(page.value))
params.set('limit', String(VOCAB_PAGE_SIZE))
const result = await session.request<{ items?: VocabularyTerm[]; total?: number }>(`terms?${params.toString()}`)
if (seq !== sequence || isStale(version, owner)) return
items.value = Array.isArray(result?.items) ? result.items : []
total.value = typeof result?.total === 'number' ? result.total : items.value.length
} catch (reason) {
if (seq !== sequence || isStale(version, owner)) return
error.value = reason instanceof Error ? reason.message : '词汇库暂时无法加载,请稍后重试。'
} finally {
if (seq === sequence && !isStale(version, owner)) loading.value = false
}
}
/** Applying a filter always starts from the first page. */
async function apply(): Promise<void> {
page.value = 1
await load()
}
async function goToPage(next: number): Promise<void> {
const target = Math.min(Math.max(1, next), totalPages.value)
if (target === page.value) return
page.value = target
await load()
}
async function clearFilters(): Promise<void> {
query.value = ''
status.value = ''
kind.value = ''
await apply()
}
/**
* Saves one entry through the same endpoint the reader uses. Identity is not editable, and the
* server applies the same status/level and schedule rules as a save from the reader.
*/
async function updateTerm(id: number, input: VocabularyEdit): Promise<VocabularyTerm> {
const version = generation
const owner = ownerId()
saving.value = true
saveError.value = ''
const body: Record<string, unknown> = { definition: input.definition, examples: input.examples, status: input.status }
if (input.status === 'learning' && input.level !== undefined) body.level = input.level
try {
const result = await session.request<{ term: VocabularyTerm }>(`terms/${id}`, 'PATCH', body)
if (!isStale(version, owner)) {
items.value = items.value.map(item => (item.id === id ? result.term : item))
}
return result.term
} catch (reason) {
if (!isStale(version, owner)) saveError.value = reason instanceof Error ? reason.message : '保存失败,请稍后重试。'
throw reason instanceof Error ? reason : new Error('保存失败,请稍后重试。')
} finally {
if (!isStale(version, owner)) saving.value = false
}
}
return {
items, total, page, query, status, kind, loading, error, saving, saveError,
hasFilters, totalPages, load, apply, goToPage, clearFilters, updateTerm, reset,
}
})
+16
View File
@@ -134,6 +134,22 @@ a.chapter-name:hover { color: #315c43; text-decoration: underline; }
.lookup-saved { color: #2f6b45; font-size: 14px; margin: 12px 0 0; }
.lookup-actions { display: flex; gap: 8px; margin-top: 14px; flex-wrap: wrap; }
.review-page { max-width: 680px; }
.vocab-page { max-width: 760px; }
.vocab-search { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 22px; }
.vocab-search label { font-size: 14px; }
.vocab-search .el-input { max-width: 260px; }
.vocab-filter { width: 140px; }
.vocab-list { list-style: none; padding: 0; margin: 18px 0 0; }
.vocab-row { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 10px; border: 1px solid #dfe3d6; border-radius: 10px; background: #fffdf8; }
.vocab-info { min-width: 0; flex: 1; }
.vocab-term { margin: 0; font-family: Georgia, serif; font-size: 18px; overflow-wrap: anywhere; }
.vocab-paging { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px; }
.term-fields label { display: flex; justify-content: space-between; gap: 10px; font-size: 14px; margin-top: 14px; }
.term-fields label span { color: #8b794e; font-size: 12px; }
.term-fields .el-textarea { margin-top: 8px; }
.term-fields .el-radio-group { margin-top: 8px; flex-wrap: wrap; gap: 4px 12px; }
.term-level { display: flex; align-items: center; gap: 10px; margin: 12px 0 0; font-size: 14px; }
.term-level-select { width: 120px; }
.chapter-notice { margin: 12px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
.chapter-list .chapter-row { flex-wrap: wrap; }
.review-notice { margin: 10px 0 0; padding: 10px 14px; border: 1px solid #d9decf; border-radius: 8px; background: #fbf7ee; color: #6b5b3e; }
+1 -1
View File
@@ -38,7 +38,7 @@ onMounted(load)
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/" class="active-nav">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
+1 -1
View File
@@ -119,7 +119,7 @@ onUnmounted(() => library.closeChapter())
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
+1 -1
View File
@@ -30,7 +30,7 @@ const requireItem = (value: ReviewItem | null): ReviewItem => value as ReviewIte
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink></nav>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
+207
View File
@@ -0,0 +1,207 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElDialog, ElInput, ElOption, ElSelect } from 'element-plus'
import TermFormFields from '../components/TermFormFields.vue'
import { TERM_STATUSES, type TermStatus } from '../composables/useReaderLookup'
import { textProblem, titleProblem } from '../stores/library'
import { VOCAB_PAGE_SIZE, useVocabularyStore, type VocabularyTerm } from '../stores/vocabulary'
import { useSessionStore } from '../stores/session'
const session = useSessionStore()
const vocabulary = useVocabularyStore()
const route = useRoute()
const router = useRouter()
const search = ref('')
const editing = ref<VocabularyTerm | null>(null)
const definition = ref('')
const examples = ref('')
const status = ref<TermStatus>('new')
const level = ref(1)
const formError = ref('')
const notice = ref('')
const queryString = computed(() => ({ query: search.value.trim(), status: vocabulary.status, kind: vocabulary.kind, page: vocabulary.page }))
const levelLabel = (value: number) => (value === 0 ? '' : ` · 等级 ${value}`)
const rowLabel = (item: VocabularyTerm) => `${item.originalForm} / ${item.definition || '(未填写释义)'} · ${statusLabel(item)}`
const statusLabel = (item: VocabularyTerm) => {
const label = TERM_STATUSES.find(entry => entry.value === item.status)?.label ?? item.status
return item.kind === 'phrase' ? `短语 · ${label}${levelLabel(item.level)}` : `${label}${levelLabel(item.level)}`
}
function readFilters(): void {
const read = (value: unknown) => (Array.isArray(value) ? value[0] : value)
const query = read(route.query.query)
const statusValue = read(route.query.status)
const kindValue = read(route.query.kind)
const page = Number(read(route.query.page) ?? 1)
search.value = typeof query === 'string' ? query : ''
vocabulary.query = search.value
vocabulary.status = TERM_STATUSES.some(entry => entry.value === statusValue) ? (statusValue as TermStatus) : ''
vocabulary.kind = kindValue === 'word' || kindValue === 'phrase' ? kindValue : ''
vocabulary.page = Number.isSafeInteger(page) && page >= 1 ? page : 1
}
async function load(): Promise<void> {
await vocabulary.load()
}
async function submitSearch(): Promise<void> {
vocabulary.query = search.value
await vocabulary.apply()
}
async function clearFilters(): Promise<void> {
search.value = ''
await vocabulary.clearFilters()
}
function openEditor(item: VocabularyTerm): void {
editing.value = item
definition.value = item.definition
examples.value = item.examples.join('\n')
status.value = item.status
level.value = item.level > 0 ? item.level : 1
formError.value = ''
notice.value = ''
vocabulary.saveError = ''
}
function closeEditor(): void {
editing.value = null
formError.value = ''
}
async function save(): Promise<void> {
const item = editing.value
if (!item || vocabulary.saving) return
formError.value = textProblem(definition.value) && '' // an empty definition is allowed
if (formError.value) return
try {
const examples2 = examples.value.split('\n').map(line => line.trim()).filter(Boolean)
const updated = await vocabulary.updateTerm(item.id, {
definition: definition.value,
examples: examples2,
status: status.value,
level: status.value === 'learning' ? level.value : undefined,
})
editing.value = null
notice.value = `已保存 · ${statusLabel(updated)}`
} catch {
// vocabulary.saveError already carries the server message for the dialog.
}
}
async function logout() {
try { await session.logout() }
catch { session.notice = '已退出此设备。服务器暂时无法连接,请稍后重试。' }
finally { await router.replace('/login') }
}
onMounted(async () => {
readFilters()
await load()
})
// The URL mirrors the filters so a reload or a return keeps what the learner searched for.
watch(() => [search.value, vocabulary.status, vocabulary.kind, vocabulary.page], () => {
void router.replace({ path: '/vocab', query: Object.fromEntries(Object.entries(queryString.value).filter(([, value]) => value !== '' && value !== undefined).map(([key, value]) => [key, String(value)])) })
})
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab" class="active-nav">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink></nav>
<div class="account">
<span class="account-name">{{ session.user.username }}</span>
<ElButton text @click="logout">退出登录</ElButton>
</div>
</header>
<main class="page vocab-page">
<div class="page-title">
<div>
<h1>生词本</h1>
<p class="subtle">保存的词语与短语,共 {{ vocabulary.total }} 条。</p>
</div>
<div class="page-actions">
<RouterLink to="/review" class="link-button">开始到期复习</RouterLink>
</div>
</div>
<form class="vocab-search" novalidate @submit.prevent="submitSearch">
<label for="vocab-query">搜索词 / 释义</label>
<ElInput id="vocab-query" v-model="search" type="text" placeholder="输入词语或释义中的一部分" :disabled="vocabulary.loading" />
<ElButton type="primary" native-type="submit" :loading="vocabulary.loading" data-testid="vocab-search">搜索</ElButton>
<label for="vocab-status">状态</label>
<ElSelect id="vocab-status" v-model="vocabulary.status" class="vocab-filter" data-testid="vocab-status" @change="vocabulary.apply()">
<ElOption label="全部状态" value="" />
<ElOption v-for="item in TERM_STATUSES" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
<label for="vocab-kind">类型</label>
<ElSelect id="vocab-kind" v-model="vocabulary.kind" class="vocab-filter" data-testid="vocab-kind" @change="vocabulary.apply()">
<ElOption label="全部类型" value="" />
<ElOption label="词语" value="word" />
<ElOption label="短语" value="phrase" />
</ElSelect>
</form>
<p v-if="notice" role="status" class="chapter-notice" data-testid="vocab-notice">{{ notice }}</p>
<p v-if="vocabulary.loading" role="status" class="loading">正在加载…</p>
<div v-else-if="vocabulary.error" class="notice">
<p role="alert">{{ vocabulary.error }}</p>
<ElButton @click="load">重试</ElButton>
</div>
<template v-else-if="vocabulary.items.length">
<ul class="vocab-list" aria-label="词条列表">
<li v-for="item in vocabulary.items" :key="item.id" class="vocab-row">
<div class="vocab-info">
<p class="vocab-term">{{ item.originalForm }} <span class="subtle">/</span> {{ item.definition || '(未填写释义)' }}</p>
<p class="chapter-meta">
<span class="status-chip" :class="`status-${item.status}`">{{ statusLabel(item) }}</span>
<template v-if="item.examples.length"> · {{ item.examples[0] }}</template>
</p>
</div>
<ElButton size="small" :data-testid="`vocab-edit-${item.id}`" @click="openEditor(item)">编辑</ElButton>
</li>
</ul>
<nav v-if="vocabulary.totalPages > 1" class="vocab-paging" aria-label="分页">
<ElButton :disabled="vocabulary.page <= 1" data-testid="vocab-prev" @click="vocabulary.goToPage(vocabulary.page - 1)">上一页</ElButton>
<span class="subtle">第 {{ vocabulary.page }} / {{ vocabulary.totalPages }} 页</span>
<ElButton :disabled="vocabulary.page >= vocabulary.totalPages" data-testid="vocab-next" @click="vocabulary.goToPage(vocabulary.page + 1)">下一页</ElButton>
</nav>
</template>
<section v-else-if="vocabulary.hasFilters" class="empty-library" aria-label="搜索无结果" data-testid="vocab-none">
<h2>没有匹配的词条</h2>
<p class="subtle">试试其他关键词,或清除筛选。</p>
<ElButton data-testid="vocab-clear" @click="clearFilters">清除搜索与筛选</ElButton>
</section>
<section v-else class="empty-library" aria-label="空生词本" data-testid="vocab-empty">
<h2>生词本还是空的</h2>
<p class="subtle">阅读时点词或选择短语,保存后会出现在这里。</p>
<RouterLink to="/" class="link-button">返回书库</RouterLink>
</section>
<ElDialog :model-value="editing !== null" title="编辑词条" width="620" data-testid="vocab-dialog" @update:model-value="closeEditor">
<template v-if="editing">
<p class="subtle">{{ editing.originalForm }}{{ editing.kind === 'phrase' ? ` · 短语 · ${editing.wordCount} 个单词` : ' · 词语' }}</p>
<TermFormFields
v-model:definition="definition"
v-model:examples="examples"
v-model:status="status"
v-model:level="level"
:show-level="true"
:disabled="vocabulary.saving"
/>
<p v-if="formError" role="alert" class="field-error">{{ formError }}</p>
<p v-if="vocabulary.saveError" role="alert" class="field-error">{{ vocabulary.saveError }}</p>
</template>
<template #footer>
<ElButton data-testid="vocab-cancel" @click="closeEditor">取消</ElButton>
<ElButton type="primary" :loading="vocabulary.saving" data-testid="vocab-save" @click="save">保存修改</ElButton>
</template>
</ElDialog>
</main>
</div>
</template>