- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机, 账号切换不串、退出回默认;深色走 html[data-theme] 与 Element Plus 的 html.dark - style.css 收敛为语义调色板::root 的 53 个变量是文件内仅有的颜色字面量, 其余规则全部走 var(),暗色只覆盖变量 - 字号经 --reader-font-scale 只作用于阅读面,不做全局缩放 - 阅读位置按账号+章节保存滚动比例与该章 content_sha256,正文换版本后不恢复 - 复习页键盘:空格/Enter 显示答案、1/2/3 评分;输入控件与聚焦按钮的按键不被劫持 - 站点头部新增「显示」控件,七个学习页面共用 - Playwright 新增 390×844 hasTouch 的 mobile 项目与移动用例(无溢出、可返回、 触摸滑动不误开面板、深色与字号持久化、账号隔离);新增主题变量回归用例 - Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
210 lines
9.8 KiB
Vue
210 lines
9.8 KiB
Vue
<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 DisplaySettings from '../components/DisplaySettings.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> · <RouterLink to="/progress">进度</RouterLink></nav>
|
||
<div class="account">
|
||
<DisplaySettings />
|
||
<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>
|