Files
lexgo/learner/src/components/LookupPanel.vue
T
ila 8c0946af9f feat: 保存个人词义与状态,并在其他章节同步显示 (#7)
- 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
2026-09-11 16:32:04 +08:00

86 lines
4.7 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 { 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>