- 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 与需求更新
99 lines
5.3 KiB
Vue
99 lines
5.3 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, onUnmounted, ref } from 'vue'
|
||
import { ElButton } from 'element-plus'
|
||
import TermFormFields from './TermFormFields.vue'
|
||
import { type LookupResult, type TermStatus } from '../composables/useReaderLookup'
|
||
|
||
defineProps<{
|
||
word: string
|
||
// Set when the selection is a phrase: the panel edits that phrase instead of a word.
|
||
phrase: { wordCount: number; stored: boolean } | null
|
||
adjust: { startLeft: boolean; startRight: boolean; endLeft: boolean; endRight: boolean } | null
|
||
result: LookupResult | null
|
||
loading: boolean
|
||
error: string
|
||
saving: boolean
|
||
saveError: string
|
||
saved: string
|
||
savedTermId: number | null
|
||
prefilling: boolean
|
||
prefillError: string
|
||
canSave: boolean
|
||
}>()
|
||
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)
|
||
let observer: ResizeObserver | undefined
|
||
function resized() {
|
||
if (window.innerWidth <= 760 && panel.value) emit('resize', panel.value.getBoundingClientRect().top)
|
||
}
|
||
onMounted(() => {
|
||
heading.value?.focus({ preventScroll: true })
|
||
resized()
|
||
if (typeof ResizeObserver !== 'undefined' && panel.value) {
|
||
observer = new ResizeObserver(resized)
|
||
observer.observe(panel.value)
|
||
}
|
||
window.addEventListener('resize', resized)
|
||
})
|
||
onUnmounted(() => { observer?.disconnect(); window.removeEventListener('resize', resized) })
|
||
</script>
|
||
|
||
<template>
|
||
<aside ref="panel" class="lookup-panel" aria-labelledby="lookup-heading" @keydown.esc.stop.prevent="$emit('close')">
|
||
<header class="lookup-heading">
|
||
<h2 id="lookup-heading" ref="heading" tabindex="-1">{{ word }}</h2>
|
||
<ElButton text aria-label="关闭释义" @click="$emit('close')">关闭</ElButton>
|
||
</header>
|
||
<div v-if="phrase" class="lookup-range" data-testid="phrase-range">
|
||
<p class="subtle">短语 · {{ phrase.wordCount }} 个单词{{ phrase.stored ? ' · 已保存' : '' }}</p>
|
||
<div class="lookup-range-actions">
|
||
<ElButton size="small" data-testid="range-start-left" :disabled="!adjust?.startLeft" @click="$emit('adjust', 'start', -1)">起点 ←</ElButton>
|
||
<ElButton size="small" data-testid="range-start-right" :disabled="!adjust?.startRight" @click="$emit('adjust', 'start', 1)">起点 →</ElButton>
|
||
<ElButton size="small" data-testid="range-end-left" :disabled="!adjust?.endLeft" @click="$emit('adjust', 'end', -1)">终点 ←</ElButton>
|
||
<ElButton size="small" data-testid="range-end-right" :disabled="!adjust?.endRight" @click="$emit('adjust', 'end', 1)">终点 →</ElButton>
|
||
</div>
|
||
</div>
|
||
<div v-if="!phrase" class="lookup-content" aria-live="polite" :aria-busy="loading">
|
||
<p v-if="loading" role="status" class="subtle">正在查询…</p>
|
||
<p v-else-if="error" role="alert" class="lookup-message">{{ error }}</p>
|
||
<template v-else-if="result">
|
||
<p v-if="result.status === 'resource_missing'" class="lookup-message">词典资源暂不可用。</p>
|
||
<p v-else-if="result.status === 'not_found'" class="lookup-message">未找到释义。</p>
|
||
<p v-if="result.status === 'lemma'" class="subtle">词形匹配:{{ result.matchedForm }}(规则候选)</p>
|
||
<ol v-if="result.entries.length" class="lookup-senses">
|
||
<li v-for="(entry, index) in result.entries" :key="index">
|
||
<p class="sense-heading"><strong>{{ entry.lemma }}</strong> <span>{{ entry.pos }}</span></p>
|
||
<p lang="en">{{ entry.definition }}</p>
|
||
<blockquote v-for="(example, exampleIndex) in entry.examples" :key="exampleIndex" lang="en">{{ example }}</blockquote>
|
||
</li>
|
||
</ol>
|
||
<p v-if="result.resource" class="subtle">{{ result.resource.name }} · {{ result.resource.version }}</p>
|
||
</template>
|
||
<ElButton v-if="!loading && (error || result?.status === 'resource_missing' || result?.status === 'not_found')" data-testid="lookup-retry" @click="$emit('retry')">重试查询</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>
|