Files
lexgo/learner/src/views/VocabView.vue
T
ila e069894c4e feat: 补齐桌面与手机体验、显示偏好与键盘操作 (#14)
- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机,
  账号切换不串、退出回默认;深色走 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 与需求更新
2026-09-15 15:11:30 +08:00

210 lines
9.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>