- schema v5 新增 lexgo_terms:身份为学习者+语言+规范化词形,唯一键保证
重复保存只更新同一条记录,不产生冲突副本
- POST /api/v1/terms 幂等保存并返回 created,GET /api/v1/terms/:id 仅本人可读,
章节 tokens 为 word 片段附带 term:{id,status,level}
- 状态与等级边界:新词/学习中/已知/忽略,只有学习中带 1~7 级,其余必须为 0,
并由数据库检查约束守住
- 学习端面板可编辑释义、例句与学习状态,正文按状态高亮;打开已保存词先读取原内容,
读取失败时禁用保存,切换账号或退出后清理表单、状态与高亮
- 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、
Local-Development-and-Verification、Product-Requirements-Overview 与 Home
86 lines
4.7 KiB
Vue
86 lines
4.7 KiB
Vue
<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'
|
||
|
||
defineProps<{
|
||
word: string
|
||
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 })
|
||
const emit = defineEmits<{ close: []; retry: []; save: []; resize: [top: number] }>()
|
||
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 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>
|
||
<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>
|
||
</div>
|
||
</aside>
|
||
</template>
|