e76fa586f1
Ship dual-track learning (daily accumulation vs textbook),沪教/商务词书 APIs and UI, native iOS wrapper with bundled H5, and production book import on deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
1013 lines
26 KiB
Vue
1013 lines
26 KiB
Vue
<script setup lang="ts">
|
||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import WordCard from '../components/WordCard.vue'
|
||
|
||
const MemoryCurveChart = defineAsyncComponent(
|
||
() => import('../components/MemoryCurveChart.vue')
|
||
)
|
||
const WordGraphCanvas = defineAsyncComponent(
|
||
() => import('../components/WordGraphCanvas.vue')
|
||
)
|
||
import {
|
||
api,
|
||
type MemoryCurvePoint,
|
||
type MemoryVisualization,
|
||
type MemoryWordSummary,
|
||
type Word,
|
||
type WordMemoryDetail,
|
||
type MemoryTransformerPredict,
|
||
} from '../api/request'
|
||
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
||
import {
|
||
buildFocusNodes,
|
||
LINK_KIND_META,
|
||
useGraphFilter,
|
||
type LinkKind,
|
||
} from '../composables/useGraphFilter'
|
||
import {
|
||
getGraphPracticeWordIds,
|
||
isPracticeSessionComplete,
|
||
loadPracticeResults,
|
||
startGraphPractice,
|
||
type PracticeResult,
|
||
} from '../utils/practiceGraphSession'
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const libraryBookId = computed(() => {
|
||
const raw = route.query.book_id
|
||
if (raw) {
|
||
const id = Number(raw)
|
||
return Number.isFinite(id) && id > 0 ? id : 0
|
||
}
|
||
return 0
|
||
})
|
||
const libraryTitle = computed(() =>
|
||
libraryBookId.value > 0 ? '词书记忆曲线' : '积累记忆曲线'
|
||
)
|
||
const graphPracticeResults = ref<Record<string, PracticeResult>>(loadPracticeResults())
|
||
|
||
const graphSessionComplete = computed(() => {
|
||
const ids = getGraphPracticeWordIds()
|
||
if (!ids.length) return false
|
||
return isPracticeSessionComplete(ids, graphPracticeResults.value)
|
||
})
|
||
|
||
function refreshGraphPracticeResults() {
|
||
graphPracticeResults.value = loadPracticeResults()
|
||
}
|
||
|
||
const viewTabs = [
|
||
{ key: 'memory', label: '记忆曲线' },
|
||
{ key: 'list', label: '单词列表' },
|
||
]
|
||
|
||
const tabs = [
|
||
{ key: '', label: '全部' },
|
||
{ key: 'new', label: '新词' },
|
||
{ key: 'learning', label: '学习中' },
|
||
{ key: 'mastered', label: '已掌握' },
|
||
{ key: 'weak', label: '易错词' },
|
||
]
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
const activeView = ref('memory')
|
||
const activeTab = ref('')
|
||
const words = ref<Word[]>([])
|
||
const currentPage = ref(1)
|
||
const loading = ref(true)
|
||
const vizLoading = ref(false)
|
||
const viz = ref<MemoryVisualization | null>(null)
|
||
const selectedWord = ref<MemoryWordSummary | null>(null)
|
||
const detailExpanded = ref(true)
|
||
const wordMemory = ref<WordMemoryDetail | null>(null)
|
||
const wordMemoryLoading = ref(false)
|
||
const memoryModel = ref<MemoryTransformerPredict | null>(null)
|
||
const memoryModelLoading = ref(false)
|
||
const listEditMode = ref(false)
|
||
const selectedIds = ref<Set<number>>(new Set())
|
||
const batchDeleting = ref(false)
|
||
const pageSelectRef = ref<HTMLInputElement | null>(null)
|
||
|
||
const {
|
||
graphStatusFilter,
|
||
graphSearch,
|
||
graphViewMode,
|
||
focusCenterId,
|
||
linkFilters,
|
||
filteredGraphNodes,
|
||
filteredGraphLinks,
|
||
graphStatsText,
|
||
isNodeVisible,
|
||
setFocusCenter,
|
||
exitFocusMode,
|
||
} = useGraphFilter(viz)
|
||
|
||
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
|
||
if (!wordMemory.value?.curve_points.length) return []
|
||
return wordMemory.value.curve_points.map((p, i) => ({
|
||
day_index: i,
|
||
date: p.date,
|
||
forgetting: p.forgetting,
|
||
mastery: p.mastery,
|
||
risk: p.risk,
|
||
}))
|
||
})
|
||
|
||
const totalPages = computed(() =>
|
||
Math.max(1, Math.ceil(words.value.length / PAGE_SIZE))
|
||
)
|
||
|
||
const paginatedWords = computed(() => {
|
||
const start = (currentPage.value - 1) * PAGE_SIZE
|
||
return words.value.slice(start, start + PAGE_SIZE)
|
||
})
|
||
|
||
const pageSummary = computed(() => {
|
||
if (!words.value.length) return ''
|
||
const start = (currentPage.value - 1) * PAGE_SIZE + 1
|
||
const end = Math.min(currentPage.value * PAGE_SIZE, words.value.length)
|
||
return `第 ${currentPage.value} / ${totalPages.value} 页,显示 ${start}–${end},共 ${words.value.length} 个`
|
||
})
|
||
|
||
const selectedCount = computed(() => selectedIds.value.size)
|
||
|
||
const allPageSelected = computed(() => {
|
||
if (!paginatedWords.value.length) return false
|
||
return paginatedWords.value.every((w) => selectedIds.value.has(w.id))
|
||
})
|
||
|
||
const pageSelectIndeterminate = computed(() => {
|
||
const pageIds = paginatedWords.value.map((w) => w.id)
|
||
const selectedOnPage = pageIds.filter((id) => selectedIds.value.has(id)).length
|
||
return selectedOnPage > 0 && selectedOnPage < pageIds.length
|
||
})
|
||
|
||
function clearSelection() {
|
||
selectedIds.value = new Set()
|
||
}
|
||
|
||
function enterListEdit() {
|
||
listEditMode.value = true
|
||
}
|
||
|
||
function exitListEdit() {
|
||
listEditMode.value = false
|
||
clearSelection()
|
||
}
|
||
|
||
function toggleWordSelect(id: number) {
|
||
const next = new Set(selectedIds.value)
|
||
if (next.has(id)) next.delete(id)
|
||
else next.add(id)
|
||
selectedIds.value = next
|
||
}
|
||
|
||
function togglePageSelect() {
|
||
const next = new Set(selectedIds.value)
|
||
if (allPageSelected.value) {
|
||
paginatedWords.value.forEach((w) => next.delete(w.id))
|
||
} else {
|
||
paginatedWords.value.forEach((w) => next.add(w.id))
|
||
}
|
||
selectedIds.value = next
|
||
}
|
||
|
||
function selectAllWords() {
|
||
selectedIds.value = new Set(words.value.map((w) => w.id))
|
||
}
|
||
|
||
function clampPage() {
|
||
if (currentPage.value > totalPages.value) {
|
||
currentPage.value = totalPages.value
|
||
}
|
||
if (currentPage.value < 1) {
|
||
currentPage.value = 1
|
||
}
|
||
}
|
||
|
||
function goToPage(page: number) {
|
||
currentPage.value = Math.min(Math.max(1, page), totalPages.value)
|
||
}
|
||
|
||
function goToPageAndScroll(page: number) {
|
||
goToPage(page)
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
|
||
async function loadWords() {
|
||
loading.value = true
|
||
try {
|
||
const { data } = await api.listWords(
|
||
activeTab.value || undefined,
|
||
libraryBookId.value > 0 ? libraryBookId.value : 0
|
||
)
|
||
words.value = data
|
||
clampPage()
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadViz() {
|
||
vizLoading.value = true
|
||
try {
|
||
const { data } = await api.memoryViz(libraryBookId.value)
|
||
viz.value = data
|
||
selectedWord.value = data.words[0] ?? null
|
||
if (data.words[0]) {
|
||
loadWordMemory(data.words[0].id)
|
||
}
|
||
} finally {
|
||
vizLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: number) {
|
||
if (!confirm('确定删除这个单词?')) return
|
||
await api.deleteWord(id)
|
||
if (selectedIds.value.has(id)) {
|
||
const next = new Set(selectedIds.value)
|
||
next.delete(id)
|
||
selectedIds.value = next
|
||
}
|
||
await loadWords()
|
||
if (activeView.value === 'memory') await loadViz()
|
||
}
|
||
|
||
async function handleBatchDelete() {
|
||
const ids = [...selectedIds.value]
|
||
if (!ids.length) return
|
||
if (!confirm(`确定删除选中的 ${ids.length} 个单词?`)) return
|
||
batchDeleting.value = true
|
||
try {
|
||
const results = await Promise.allSettled(ids.map((id) => api.deleteWord(id)))
|
||
const failed = results.filter((r) => r.status === 'rejected').length
|
||
clearSelection()
|
||
await loadWords()
|
||
if (failed > 0) {
|
||
alert(`${failed} 个单词删除失败,请重试`)
|
||
}
|
||
} finally {
|
||
batchDeleting.value = false
|
||
}
|
||
}
|
||
|
||
async function loadWordMemory(wordId: number) {
|
||
wordMemoryLoading.value = true
|
||
memoryModelLoading.value = true
|
||
try {
|
||
const [memRes, modelRes] = await Promise.all([
|
||
api.wordMemory(wordId),
|
||
api.wordMemoryModel(wordId),
|
||
])
|
||
wordMemory.value = memRes.data
|
||
memoryModel.value = modelRes.data
|
||
} catch {
|
||
wordMemory.value = null
|
||
memoryModel.value = null
|
||
} finally {
|
||
wordMemoryLoading.value = false
|
||
memoryModelLoading.value = false
|
||
}
|
||
}
|
||
|
||
function onGraphSelect(id: string) {
|
||
const w = viz.value?.words.find((x) => String(x.id) === id)
|
||
if (w) {
|
||
selectedWord.value = w
|
||
loadWordMemory(w.id)
|
||
}
|
||
}
|
||
|
||
function goGraphPractice(wordIds?: string[]) {
|
||
const ids = wordIds ?? filteredGraphNodes.value.map((n) => n.id)
|
||
if (!ids.length) return
|
||
const total = viz.value?.words.length ?? words.value.length
|
||
if (total < 4) {
|
||
alert('词库至少 4 个单词才能进行选择题练习')
|
||
return
|
||
}
|
||
startGraphPractice(ids, ids)
|
||
router.push('/graph-practice')
|
||
}
|
||
|
||
function graphIdsForPracticeContext(centerId: string): string[] {
|
||
if (!viz.value) return [centerId]
|
||
if (graphViewMode.value === 'focus' && focusCenterId.value) {
|
||
return filteredGraphNodes.value.map((n) => n.id)
|
||
}
|
||
return buildFocusNodes(
|
||
viz.value,
|
||
centerId,
|
||
graphStatusFilter.value,
|
||
graphSearch.value,
|
||
linkFilters.value
|
||
).map((n) => n.id)
|
||
}
|
||
|
||
function goPracticeSelectedWord() {
|
||
if (!selectedWord.value) return
|
||
const centerId = String(selectedWord.value.id)
|
||
const graphIds = graphIdsForPracticeContext(centerId)
|
||
const total = viz.value?.words.length ?? words.value.length
|
||
if (total < 4) {
|
||
alert('词库至少 4 个单词才能进行选择题练习')
|
||
return
|
||
}
|
||
startGraphPractice([centerId], graphIds)
|
||
router.push('/graph-practice')
|
||
}
|
||
|
||
watch([graphStatusFilter, graphSearch, linkFilters], () => {
|
||
if (selectedWord.value && !isNodeVisible(String(selectedWord.value.id))) {
|
||
const first = filteredGraphNodes.value[0]
|
||
if (first) {
|
||
const w = viz.value?.words.find((x) => String(x.id) === first.id)
|
||
if (w) {
|
||
selectedWord.value = w
|
||
loadWordMemory(w.id)
|
||
}
|
||
}
|
||
}
|
||
}, { deep: true })
|
||
|
||
watch(selectedWord, (w) => {
|
||
if (w && activeView.value === 'memory') loadWordMemory(w.id)
|
||
})
|
||
|
||
watch(activeTab, () => {
|
||
currentPage.value = 1
|
||
exitListEdit()
|
||
if (activeView.value === 'list') loadWords()
|
||
})
|
||
|
||
watch([allPageSelected, pageSelectIndeterminate], () => {
|
||
if (pageSelectRef.value) {
|
||
pageSelectRef.value.indeterminate = pageSelectIndeterminate.value
|
||
}
|
||
})
|
||
|
||
watch(activeView, (v) => {
|
||
if (v === 'list') {
|
||
loadWords()
|
||
} else {
|
||
exitListEdit()
|
||
refreshGraphPracticeResults()
|
||
loadViz()
|
||
}
|
||
})
|
||
|
||
watch(
|
||
() => route.query.view,
|
||
(v) => {
|
||
if (v === 'memory') {
|
||
activeView.value = 'memory'
|
||
refreshGraphPracticeResults()
|
||
if (!viz.value && !vizLoading.value) loadViz()
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
watch(libraryBookId, () => {
|
||
if (activeView.value === 'memory') {
|
||
viz.value = null
|
||
loadViz()
|
||
} else {
|
||
loadWords()
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
if (activeView.value === 'memory') {
|
||
refreshGraphPracticeResults()
|
||
loadViz()
|
||
} else {
|
||
loadWords()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<h1 class="page-title">{{ libraryTitle }}</h1>
|
||
|
||
<div class="view-tabs">
|
||
<button
|
||
v-for="v in viewTabs"
|
||
:key="v.key"
|
||
:class="['view-tab', { active: activeView === v.key }]"
|
||
@click="activeView = v.key"
|
||
>
|
||
{{ v.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<template v-if="activeView === 'list'">
|
||
<div class="tabs">
|
||
<button
|
||
v-for="t in tabs"
|
||
:key="t.key"
|
||
:class="['tab', { active: activeTab === t.key }]"
|
||
@click="activeTab = t.key"
|
||
>
|
||
{{ t.label }}
|
||
</button>
|
||
</div>
|
||
<p v-if="loading" style="color: var(--muted)">加载中...</p>
|
||
<p v-else-if="words.length === 0" style="color: var(--muted); text-align: center">
|
||
暂无单词,去翻译页添加吧
|
||
</p>
|
||
<template v-else>
|
||
<Teleport to="body">
|
||
<button
|
||
v-if="totalPages > 1"
|
||
type="button"
|
||
class="float-page-arrow prev"
|
||
:disabled="currentPage <= 1"
|
||
aria-label="上一页"
|
||
@click="goToPageAndScroll(currentPage - 1)"
|
||
>
|
||
‹
|
||
</button>
|
||
<button
|
||
v-if="totalPages > 1"
|
||
type="button"
|
||
class="float-page-arrow next"
|
||
:disabled="currentPage >= totalPages"
|
||
aria-label="下一页"
|
||
@click="goToPageAndScroll(currentPage + 1)"
|
||
>
|
||
›
|
||
</button>
|
||
</Teleport>
|
||
<div class="bulk-bar" :class="{ editing: listEditMode }">
|
||
<button
|
||
v-if="!listEditMode"
|
||
type="button"
|
||
class="btn btn-outline bulk-edit"
|
||
@click="enterListEdit"
|
||
>
|
||
编辑
|
||
</button>
|
||
<template v-else>
|
||
<label class="bulk-select">
|
||
<input
|
||
ref="pageSelectRef"
|
||
type="checkbox"
|
||
:checked="allPageSelected"
|
||
@change="togglePageSelect"
|
||
/>
|
||
全选本页
|
||
</label>
|
||
<button
|
||
v-if="words.length > paginatedWords.length"
|
||
type="button"
|
||
class="bulk-link"
|
||
@click="selectAllWords"
|
||
>
|
||
全选全部 {{ words.length }} 个
|
||
</button>
|
||
<span v-if="selectedCount" class="bulk-count">已选 {{ selectedCount }} 个</span>
|
||
<button
|
||
type="button"
|
||
class="btn btn-danger bulk-delete"
|
||
:disabled="!selectedCount || batchDeleting"
|
||
@click="handleBatchDelete"
|
||
>
|
||
{{ batchDeleting ? '删除中...' : '批量删除' }}
|
||
</button>
|
||
<button type="button" class="bulk-done" @click="exitListEdit">完成</button>
|
||
</template>
|
||
</div>
|
||
<p class="page-summary">{{ pageSummary }}</p>
|
||
<WordCard
|
||
v-for="w in paginatedWords"
|
||
:key="w.id"
|
||
:word="w"
|
||
:selectable="listEditMode"
|
||
:selected="selectedIds.has(w.id)"
|
||
@delete="handleDelete"
|
||
@toggle-select="toggleWordSelect"
|
||
/>
|
||
<div v-if="totalPages > 1" class="pagination">
|
||
<button
|
||
type="button"
|
||
class="page-btn"
|
||
:disabled="currentPage <= 1"
|
||
@click="goToPageAndScroll(currentPage - 1)"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span class="page-indicator">{{ currentPage }} / {{ totalPages }}</span>
|
||
<button
|
||
type="button"
|
||
class="page-btn"
|
||
:disabled="currentPage >= totalPages"
|
||
@click="goToPageAndScroll(currentPage + 1)"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<template v-else>
|
||
<p v-if="vizLoading" style="color: var(--muted)">加载记忆数据...</p>
|
||
<template v-else-if="viz && viz.graph.nodes.length">
|
||
<div class="card chart-card">
|
||
<h3 class="section-title">记忆曲线</h3>
|
||
<p class="section-desc">
|
||
红线:遗忘曲线 · 绿线:熟练曲线 · 橙虚线:可能遗忘(历史) · 紫点线:未来预测
|
||
</p>
|
||
<MemoryCurveChart :curve-points="viz.curve_points" :future-risk="viz.future_risk" />
|
||
</div>
|
||
|
||
<div class="card graph-card">
|
||
<h3 class="section-title">单词关系图</h3>
|
||
<p class="section-desc">筛选后展示子图,减轻卡顿并突出记忆关联</p>
|
||
|
||
<div class="graph-toolbar">
|
||
<input
|
||
v-model="graphSearch"
|
||
class="graph-search input"
|
||
type="search"
|
||
placeholder="搜索英文或中文"
|
||
/>
|
||
<select v-model="graphStatusFilter" class="graph-select">
|
||
<option value="">全部状态</option>
|
||
<option value="new">新词</option>
|
||
<option value="learning">学习中</option>
|
||
<option value="mastered">已掌握</option>
|
||
<option value="weak">易错词</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="link-filters">
|
||
<label
|
||
v-for="(meta, kind) in LINK_KIND_META"
|
||
:key="kind"
|
||
class="link-filter-item"
|
||
>
|
||
<input v-model="linkFilters[kind as LinkKind]" type="checkbox" />
|
||
<span class="link-swatch" :style="{ background: meta.color }" />
|
||
{{ meta.label }}
|
||
</label>
|
||
</div>
|
||
|
||
<div class="graph-mode-row">
|
||
<p class="graph-stats">{{ graphStatsText }}</p>
|
||
<div class="graph-mode-actions">
|
||
<button
|
||
v-if="filteredGraphNodes.length"
|
||
type="button"
|
||
class="btn-mini primary"
|
||
@click="goGraphPractice()"
|
||
>
|
||
开始练习
|
||
</button>
|
||
<button
|
||
v-if="graphViewMode === 'focus'"
|
||
type="button"
|
||
class="btn-mini"
|
||
@click="exitFocusMode"
|
||
>
|
||
退出聚焦
|
||
</button>
|
||
<button
|
||
v-else-if="selectedWord"
|
||
type="button"
|
||
class="btn-mini"
|
||
@click="setFocusCenter(String(selectedWord.id))"
|
||
>
|
||
聚焦此词
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="Object.keys(graphPracticeResults).length" class="practice-color-hint">
|
||
{{ graphSessionComplete ? '上次练习已全部完成 · 节点与连线按对错着色' : '图中保留上次练习进度 · 答对绿、答错红' }}
|
||
</p>
|
||
|
||
<WordGraphCanvas
|
||
v-if="filteredGraphNodes.length"
|
||
:nodes="filteredGraphNodes"
|
||
:links="filteredGraphLinks"
|
||
:practice-results="graphPracticeResults"
|
||
:session-complete="graphSessionComplete"
|
||
@select="onGraphSelect"
|
||
/>
|
||
<p v-else class="curve-loading">当前筛选无匹配单词,请调整条件</p>
|
||
|
||
<div class="graph-legend">
|
||
<span v-for="(meta, kind) in LINK_KIND_META" :key="kind" class="legend-item">
|
||
<span class="link-swatch" :style="{ background: meta.color }" />
|
||
{{ meta.label }}:{{ meta.desc }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="selectedWord" class="card word-detail">
|
||
<div class="detail-header">
|
||
<h3 class="detail-word-title">{{ selectedWord.en }} — {{ selectedWord.zh }}</h3>
|
||
<div class="detail-header-actions">
|
||
<button type="button" class="btn-mini primary" @click="goPracticeSelectedWord">
|
||
练这个词
|
||
</button>
|
||
<button type="button" class="detail-toggle" @click="detailExpanded = !detailExpanded">
|
||
{{ detailExpanded ? '收起' : '展开' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-show="detailExpanded" class="detail-expanded">
|
||
<div class="detail-meta">
|
||
<span>进入词库:{{ selectedWord.entered_at.replace('T', ' ').slice(0, 16) }}</span>
|
||
<span>训练 {{ selectedWord.train_count }} 次 · 累计 {{ formatTrainSeconds(selectedWord.total_train_seconds) }}</span>
|
||
<span>答对 {{ selectedWord.correct_count }} · 答错 {{ selectedWord.wrong_count }}</span>
|
||
<span>掌握率 {{ selectedWord.mastery_score }}%</span>
|
||
<span>当前记忆保留 {{ selectedWord.retention_now }}%</span>
|
||
<span>7 日后预测 {{ selectedWord.risk_7d }}%</span>
|
||
</div>
|
||
<div v-if="memoryModel && !memoryModelLoading" class="model-panel">
|
||
<h4 class="word-curve-title">Transformer 记忆预测</h4>
|
||
<p class="section-desc">
|
||
模型回忆率 {{ memoryModel.model_recall_percent }}% · 公式
|
||
{{ memoryModel.formula_recall_percent }}% · 融合
|
||
{{ memoryModel.recall_now_percent }}% · 建议
|
||
{{ memoryModel.recommended_review_days }} 天后复习
|
||
</p>
|
||
<div class="attn-chips">
|
||
<span
|
||
v-for="a in memoryModel.attention_hint"
|
||
:key="a.index"
|
||
class="attn-chip"
|
||
>
|
||
{{ a.label }} {{ (a.weight * 100).toFixed(0) }}%
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<p v-if="wordMemoryLoading" class="curve-loading">加载该词记忆曲线...</p>
|
||
<template v-else-if="wordCurvePoints.length">
|
||
<h4 class="word-curve-title">该单词记忆曲线</h4>
|
||
<p class="section-desc">每次训练后更新 · 点与答题记录对应</p>
|
||
<MemoryCurveChart
|
||
:curve-points="wordCurvePoints"
|
||
:future-risk="wordMemory?.future_risk ?? []"
|
||
/>
|
||
</template>
|
||
<p v-else class="curve-loading">暂无训练记录,完成每日训练或拼写练习后生成曲线</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<p v-else style="color: var(--muted); text-align: center">暂无单词,无法生成记忆曲线</p>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.view-tabs {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.view-tab {
|
||
flex: 1;
|
||
padding: 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: #fff;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
.view-tab.active {
|
||
border-color: var(--primary);
|
||
background: rgba(79, 110, 247, 0.1);
|
||
color: var(--primary);
|
||
font-weight: 600;
|
||
}
|
||
.model-panel {
|
||
margin-bottom: 14px;
|
||
padding: 12px;
|
||
background: rgba(79, 110, 247, 0.06);
|
||
border-radius: var(--radius);
|
||
border: 1px solid rgba(79, 110, 247, 0.15);
|
||
}
|
||
.attn-chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
.attn-chip {
|
||
font-size: 11px;
|
||
padding: 4px 8px;
|
||
background: #fff;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
.bulk-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 10px 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.bulk-bar.editing {
|
||
padding: 10px 12px;
|
||
background: var(--card);
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.bulk-edit {
|
||
width: auto;
|
||
padding: 8px 16px;
|
||
font-size: 14px;
|
||
margin-left: auto;
|
||
}
|
||
.bulk-done {
|
||
margin-left: auto;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--primary);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 8px 4px;
|
||
}
|
||
.bulk-select {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.bulk-select input {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: var(--primary);
|
||
cursor: pointer;
|
||
}
|
||
.bulk-link {
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--primary);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.bulk-count {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.bulk-delete {
|
||
margin-left: auto;
|
||
width: auto;
|
||
padding: 8px 14px;
|
||
font-size: 13px;
|
||
}
|
||
.page-summary {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
margin: 0 0 12px;
|
||
}
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 16px;
|
||
margin-top: 16px;
|
||
padding-top: 12px;
|
||
}
|
||
.page-btn {
|
||
padding: 8px 16px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: #fff;
|
||
font-size: 14px;
|
||
color: var(--primary);
|
||
cursor: pointer;
|
||
}
|
||
.page-btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
color: var(--muted);
|
||
}
|
||
.page-indicator {
|
||
font-size: 14px;
|
||
color: var(--muted);
|
||
min-width: 72px;
|
||
text-align: center;
|
||
}
|
||
.section-title {
|
||
font-size: 16px;
|
||
margin: 0 0 6px;
|
||
}
|
||
.section-desc {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin: 0 0 12px;
|
||
}
|
||
.chart-card,
|
||
.graph-card {
|
||
margin-bottom: 16px;
|
||
}
|
||
.graph-toolbar {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.graph-search {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.graph-select {
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
background: #fff;
|
||
}
|
||
.link-filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px 14px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.link-filter-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
}
|
||
.link-swatch {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
.graph-mode-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.graph-stats {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin: 0;
|
||
flex: 1;
|
||
}
|
||
.practice-color-hint {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin: 0 0 8px;
|
||
}
|
||
.graph-mode-actions {
|
||
flex-shrink: 0;
|
||
}
|
||
.btn-mini {
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: #fff;
|
||
font-size: 12px;
|
||
color: var(--primary);
|
||
cursor: pointer;
|
||
}
|
||
.btn-mini.primary {
|
||
border-color: var(--primary);
|
||
background: rgba(79, 110, 247, 0.1);
|
||
}
|
||
.detail-header-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-shrink: 0;
|
||
}
|
||
.word-detail {
|
||
margin-top: 12px;
|
||
}
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.detail-word-title {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
margin: 0;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.detail-toggle {
|
||
flex-shrink: 0;
|
||
padding: 6px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: #fff;
|
||
font-size: 13px;
|
||
color: var(--primary);
|
||
cursor: pointer;
|
||
}
|
||
.detail-toggle:hover {
|
||
background: rgba(79, 110, 247, 0.08);
|
||
}
|
||
.detail-expanded {
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.detail-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
}
|
||
.word-curve-title {
|
||
font-size: 14px;
|
||
margin: 14px 0 4px;
|
||
color: #1e293b;
|
||
}
|
||
.graph-legend {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 10px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.legend-item {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.curve-loading {
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
margin: 12px 0;
|
||
}
|
||
</style>
|
||
|
||
<style>
|
||
/* 悬浮翻页箭头挂到 body,避免被页面容器裁剪 */
|
||
.float-page-arrow {
|
||
position: fixed;
|
||
top: calc(50% - 28px);
|
||
z-index: 100;
|
||
width: 44px;
|
||
height: 52px;
|
||
border: 1px solid var(--border, #e2e8f0);
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.12);
|
||
font-size: 28px;
|
||
line-height: 1;
|
||
color: var(--primary, #4f6ef7);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
backdrop-filter: blur(6px);
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.float-page-arrow.prev {
|
||
left: max(6px, env(safe-area-inset-left));
|
||
}
|
||
.float-page-arrow.next {
|
||
right: max(6px, env(safe-area-inset-right));
|
||
}
|
||
.float-page-arrow:hover:not(:disabled) {
|
||
background: #fff;
|
||
border-color: var(--primary, #4f6ef7);
|
||
}
|
||
.float-page-arrow:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
box-shadow: none;
|
||
}
|
||
@media (min-width: 480px) {
|
||
.float-page-arrow.prev {
|
||
left: 12px;
|
||
}
|
||
.float-page-arrow.next {
|
||
right: 12px;
|
||
}
|
||
}
|
||
</style>
|