Add graph focus mode, hover tooltips, side panel, and targeted spell practice.
Clicking a node focuses its neighbor subgraph; side panel shows word stats and curve; 「练这个词」 prepends a spell question for the selected word. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -182,6 +182,7 @@ export const api = {
|
|||||||
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
|
createWord: (data: Partial<Word>) => request.post<Word>('/words', data),
|
||||||
listWords: (status?: string) =>
|
listWords: (status?: string) =>
|
||||||
request.get<Word[]>('/words', { params: status ? { status } : {} }),
|
request.get<Word[]>('/words', { params: status ? { status } : {} }),
|
||||||
|
getWord: (id: number) => request.get<Word>(`/words/${id}`),
|
||||||
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
|
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
|
||||||
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
|
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
|
||||||
deleteWord: (id: number) => request.delete(`/words/${id}`),
|
deleteWord: (id: number) => request.delete(`/words/${id}`),
|
||||||
|
|||||||
@@ -12,8 +12,18 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const wrapRef = ref<HTMLDivElement | null>(null)
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const cursorStyle = ref('default')
|
const cursorStyle = ref('default')
|
||||||
|
const hoverNode = ref<MemoryGraphNode | null>(null)
|
||||||
|
const tooltipPos = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
new: '新词',
|
||||||
|
learning: '学习中',
|
||||||
|
mastered: '已掌握',
|
||||||
|
weak: '易错词',
|
||||||
|
}
|
||||||
|
|
||||||
interface SimNode extends MemoryGraphNode {
|
interface SimNode extends MemoryGraphNode {
|
||||||
x: number
|
x: number
|
||||||
@@ -232,6 +242,7 @@ function onPointerDown(e: PointerEvent) {
|
|||||||
const hit = hitNode(x, y)
|
const hit = hitNode(x, y)
|
||||||
if (!hit) return
|
if (!hit) return
|
||||||
|
|
||||||
|
hoverNode.value = null
|
||||||
draggingId = hit.id
|
draggingId = hit.id
|
||||||
dragOffsetX = x - hit.x
|
dragOffsetX = x - hit.x
|
||||||
dragOffsetY = y - hit.y
|
dragOffsetY = y - hit.y
|
||||||
@@ -262,7 +273,21 @@ function onPointerMove(e: PointerEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cursorStyle.value = hitNode(x, y) ? 'grab' : 'default'
|
const hit = hitNode(x, y)
|
||||||
|
cursorStyle.value = hit ? 'grab' : 'default'
|
||||||
|
if (hit) {
|
||||||
|
hoverNode.value = hit
|
||||||
|
const wrap = wrapRef.value
|
||||||
|
if (wrap) {
|
||||||
|
const rect = wrap.getBoundingClientRect()
|
||||||
|
tooltipPos.value = {
|
||||||
|
x: Math.min(e.clientX - rect.left + 12, rect.width - 160),
|
||||||
|
y: Math.max(e.clientY - rect.top - 8, 8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
hoverNode.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerUp(e: PointerEvent) {
|
function onPointerUp(e: PointerEvent) {
|
||||||
@@ -320,24 +345,55 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="graph-wrap">
|
<div ref="wrapRef" class="graph-wrap">
|
||||||
<canvas
|
<canvas
|
||||||
ref="canvasRef"
|
ref="canvasRef"
|
||||||
class="graph-canvas"
|
class="graph-canvas"
|
||||||
:style="{ cursor: cursorStyle }"
|
:style="{ cursor: cursorStyle }"
|
||||||
/>
|
/>
|
||||||
<p class="graph-hint">拖动节点调整位置(画布固定)· 点击节点查看单词</p>
|
<div
|
||||||
|
v-if="hoverNode"
|
||||||
|
class="graph-tooltip"
|
||||||
|
:style="{ left: `${tooltipPos.x}px`, top: `${tooltipPos.y}px` }"
|
||||||
|
>
|
||||||
|
<div class="tip-title">{{ hoverNode.label }} — {{ hoverNode.zh }}</div>
|
||||||
|
<div class="tip-meta">
|
||||||
|
{{ statusLabel[hoverNode.status] || hoverNode.status }} · 掌握 {{ hoverNode.mastery }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="graph-hint">悬停预览 · 点击选中 · 拖动调整节点位置</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.graph-wrap {
|
.graph-wrap {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: #fafbff;
|
background: #fafbff;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.graph-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
max-width: 200px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(30, 41, 59, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.tip-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.tip-meta {
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
.graph-canvas {
|
.graph-canvas {
|
||||||
display: block;
|
display: block;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { computed, ref, type Ref } from 'vue'
|
|||||||
import type { MemoryGraphLink, MemoryGraphNode, MemoryVisualization } from '../api/request'
|
import type { MemoryGraphLink, MemoryGraphNode, MemoryVisualization } from '../api/request'
|
||||||
|
|
||||||
export const MAX_GRAPH_NODES = 40
|
export const MAX_GRAPH_NODES = 40
|
||||||
|
export const MAX_FOCUS_NODES = 28
|
||||||
|
|
||||||
export type LinkKind = 'co_review' | 'status' | 'similar'
|
export type LinkKind = 'co_review' | 'status' | 'similar'
|
||||||
|
export type GraphViewMode = 'browse' | 'focus'
|
||||||
|
|
||||||
export const LINK_KIND_META: Record<
|
export const LINK_KIND_META: Record<
|
||||||
LinkKind,
|
LinkKind,
|
||||||
@@ -14,16 +16,79 @@ export const LINK_KIND_META: Record<
|
|||||||
similar: { label: '词形相近', color: '#f59e0b', desc: '英文拼写相近' },
|
similar: { label: '词形相近', color: '#f59e0b', desc: '英文拼写相近' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function passesNodeFilter(
|
||||||
|
n: MemoryGraphNode,
|
||||||
|
statusFilter: string,
|
||||||
|
search: string
|
||||||
|
): boolean {
|
||||||
|
if (statusFilter && n.status !== statusFilter) return false
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
if (!q) return true
|
||||||
|
return n.label.toLowerCase().includes(q) || n.zh.toLowerCase().includes(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFocusNodes(
|
||||||
|
viz: MemoryVisualization,
|
||||||
|
centerId: string,
|
||||||
|
statusFilter: string,
|
||||||
|
search: string,
|
||||||
|
linkFilters: Record<LinkKind, boolean>
|
||||||
|
): MemoryGraphNode[] {
|
||||||
|
const nodeMap = new Map(viz.graph.nodes.map((n) => [n.id, n]))
|
||||||
|
const center = nodeMap.get(centerId)
|
||||||
|
if (!center) return []
|
||||||
|
|
||||||
|
const ids = new Set<string>([centerId])
|
||||||
|
for (const l of viz.graph.links) {
|
||||||
|
const kind = l.kind as LinkKind
|
||||||
|
if (!linkFilters[kind]) continue
|
||||||
|
if (l.source === centerId) ids.add(l.target)
|
||||||
|
if (l.target === centerId) ids.add(l.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes = [...ids]
|
||||||
|
.map((id) => nodeMap.get(id)!)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((n) => passesNodeFilter(n, statusFilter, search))
|
||||||
|
|
||||||
|
if (!nodes.some((n) => n.id === centerId)) {
|
||||||
|
nodes = [center, ...nodes]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length > MAX_FOCUS_NODES) {
|
||||||
|
const centerNode = nodes.find((n) => n.id === centerId)!
|
||||||
|
const rest = nodes
|
||||||
|
.filter((n) => n.id !== centerId)
|
||||||
|
.sort((a, b) => a.mastery - b.mastery)
|
||||||
|
.slice(0, MAX_FOCUS_NODES - 1)
|
||||||
|
nodes = [centerNode, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
||||||
const graphStatusFilter = ref('')
|
const graphStatusFilter = ref('')
|
||||||
const graphSearch = ref('')
|
const graphSearch = ref('')
|
||||||
|
const graphViewMode = ref<GraphViewMode>('browse')
|
||||||
|
const focusCenterId = ref<string | null>(null)
|
||||||
const linkFilters = ref<Record<LinkKind, boolean>>({
|
const linkFilters = ref<Record<LinkKind, boolean>>({
|
||||||
co_review: true,
|
co_review: true,
|
||||||
status: true,
|
status: true,
|
||||||
similar: true,
|
similar: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const matchedBeforeLimit = computed(() => {
|
function setFocusCenter(id: string) {
|
||||||
|
focusCenterId.value = id
|
||||||
|
graphViewMode.value = 'focus'
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitFocusMode() {
|
||||||
|
focusCenterId.value = null
|
||||||
|
graphViewMode.value = 'browse'
|
||||||
|
}
|
||||||
|
|
||||||
|
const browseMatchedCount = computed(() => {
|
||||||
if (!viz.value) return 0
|
if (!viz.value) return 0
|
||||||
let nodes = viz.value.graph.nodes
|
let nodes = viz.value.graph.nodes
|
||||||
if (graphStatusFilter.value) {
|
if (graphStatusFilter.value) {
|
||||||
@@ -38,7 +103,7 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
|||||||
return nodes.length
|
return nodes.length
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredGraphNodes = computed((): MemoryGraphNode[] => {
|
const browseNodes = computed((): MemoryGraphNode[] => {
|
||||||
if (!viz.value) return []
|
if (!viz.value) return []
|
||||||
let nodes = [...viz.value.graph.nodes]
|
let nodes = [...viz.value.graph.nodes]
|
||||||
if (graphStatusFilter.value) {
|
if (graphStatusFilter.value) {
|
||||||
@@ -63,8 +128,23 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
|||||||
.slice(0, MAX_GRAPH_NODES)
|
.slice(0, MAX_GRAPH_NODES)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const filteredGraphNodes = computed((): MemoryGraphNode[] => {
|
||||||
|
if (!viz.value) return []
|
||||||
|
if (graphViewMode.value === 'focus' && focusCenterId.value) {
|
||||||
|
return buildFocusNodes(
|
||||||
|
viz.value,
|
||||||
|
focusCenterId.value,
|
||||||
|
graphStatusFilter.value,
|
||||||
|
graphSearch.value,
|
||||||
|
linkFilters.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return browseNodes.value
|
||||||
|
})
|
||||||
|
|
||||||
const graphTruncated = computed(
|
const graphTruncated = computed(
|
||||||
() => matchedBeforeLimit.value > MAX_GRAPH_NODES
|
() =>
|
||||||
|
graphViewMode.value === 'browse' && browseMatchedCount.value > MAX_GRAPH_NODES
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredGraphLinks = computed((): MemoryGraphLink[] => {
|
const filteredGraphLinks = computed((): MemoryGraphLink[] => {
|
||||||
@@ -81,7 +161,12 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
|||||||
const n = filteredGraphNodes.value.length
|
const n = filteredGraphNodes.value.length
|
||||||
const e = filteredGraphLinks.value.length
|
const e = filteredGraphLinks.value.length
|
||||||
const total = viz.value?.graph.nodes.length ?? 0
|
const total = viz.value?.graph.nodes.length ?? 0
|
||||||
let text = `显示 ${n} 个节点、${e} 条连线(全库 ${total} 词)`
|
if (graphViewMode.value === 'focus' && focusCenterId.value) {
|
||||||
|
const center = viz.value?.graph.nodes.find((x) => x.id === focusCenterId.value)
|
||||||
|
const label = center ? `${center.label}` : '当前词'
|
||||||
|
return `聚焦模式:以「${label}」为中心,显示 ${n} 个关联词、${e} 条连线`
|
||||||
|
}
|
||||||
|
let text = `浏览模式:${n} 个节点、${e} 条连线(全库 ${total} 词)`
|
||||||
if (graphTruncated.value) {
|
if (graphTruncated.value) {
|
||||||
text += ` · 已优先展示易错/薄弱词(最多 ${MAX_GRAPH_NODES} 个)`
|
text += ` · 已优先展示易错/薄弱词(最多 ${MAX_GRAPH_NODES} 个)`
|
||||||
}
|
}
|
||||||
@@ -95,12 +180,16 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
|||||||
return {
|
return {
|
||||||
graphStatusFilter,
|
graphStatusFilter,
|
||||||
graphSearch,
|
graphSearch,
|
||||||
|
graphViewMode,
|
||||||
|
focusCenterId,
|
||||||
linkFilters,
|
linkFilters,
|
||||||
filteredGraphNodes,
|
filteredGraphNodes,
|
||||||
filteredGraphLinks,
|
filteredGraphLinks,
|
||||||
graphTruncated,
|
graphTruncated,
|
||||||
graphStatsText,
|
graphStatsText,
|
||||||
isNodeVisible,
|
isNodeVisible,
|
||||||
|
setFocusCenter,
|
||||||
|
exitFocusMode,
|
||||||
LINK_KIND_META,
|
LINK_KIND_META,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type QuizSessionSnapshot,
|
type QuizSessionSnapshot,
|
||||||
} from '../composables/useQuizSession'
|
} from '../composables/useQuizSession'
|
||||||
import { useQuizTimer } from '../composables/useQuizTimer'
|
import { useQuizTimer } from '../composables/useQuizTimer'
|
||||||
|
import { consumePracticeFocusWordId } from '../utils/practiceFocus'
|
||||||
|
import { spellQuestionFromWord } from '../utils/spellQuestion'
|
||||||
|
|
||||||
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
||||||
|
|
||||||
@@ -69,7 +71,20 @@ onMounted(async () => {
|
|||||||
clearQuizSession('spell')
|
clearQuizSession('spell')
|
||||||
const { data } = await api.spellQuiz()
|
const { data } = await api.spellQuiz()
|
||||||
questions.value = data.questions
|
questions.value = data.questions
|
||||||
if (data.questions.length === 0) {
|
const focusId = consumePracticeFocusWordId()
|
||||||
|
if (focusId) {
|
||||||
|
try {
|
||||||
|
const { data: word } = await api.getWord(focusId)
|
||||||
|
const focusQ = spellQuestionFromWord(word)
|
||||||
|
if (!questions.value.some((q) => q.word_id === focusId)) {
|
||||||
|
questions.value = [focusQ, ...questions.value]
|
||||||
|
}
|
||||||
|
currentIndex.value = 0
|
||||||
|
} catch {
|
||||||
|
/* 单词不存在则忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (questions.value.length === 0) {
|
||||||
empty.value = true
|
empty.value = true
|
||||||
} else {
|
} else {
|
||||||
persist()
|
persist()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import WordCard from '../components/WordCard.vue'
|
import WordCard from '../components/WordCard.vue'
|
||||||
|
|
||||||
const MemoryCurveChart = defineAsyncComponent(
|
const MemoryCurveChart = defineAsyncComponent(
|
||||||
@@ -18,6 +19,9 @@ import {
|
|||||||
} from '../api/request'
|
} from '../api/request'
|
||||||
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
||||||
import { LINK_KIND_META, useGraphFilter, type LinkKind } from '../composables/useGraphFilter'
|
import { LINK_KIND_META, useGraphFilter, type LinkKind } from '../composables/useGraphFilter'
|
||||||
|
import { setPracticeFocusWordId } from '../utils/practiceFocus'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const viewTabs = [
|
const viewTabs = [
|
||||||
{ key: 'list', label: '单词列表' },
|
{ key: 'list', label: '单词列表' },
|
||||||
@@ -42,18 +46,20 @@ const loading = ref(true)
|
|||||||
const vizLoading = ref(false)
|
const vizLoading = ref(false)
|
||||||
const viz = ref<MemoryVisualization | null>(null)
|
const viz = ref<MemoryVisualization | null>(null)
|
||||||
const selectedWord = ref<MemoryWordSummary | null>(null)
|
const selectedWord = ref<MemoryWordSummary | null>(null)
|
||||||
const detailExpanded = ref(true)
|
|
||||||
const wordMemory = ref<WordMemoryDetail | null>(null)
|
const wordMemory = ref<WordMemoryDetail | null>(null)
|
||||||
const wordMemoryLoading = ref(false)
|
const wordMemoryLoading = ref(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
graphStatusFilter,
|
graphStatusFilter,
|
||||||
graphSearch,
|
graphSearch,
|
||||||
|
graphViewMode,
|
||||||
linkFilters,
|
linkFilters,
|
||||||
filteredGraphNodes,
|
filteredGraphNodes,
|
||||||
filteredGraphLinks,
|
filteredGraphLinks,
|
||||||
graphStatsText,
|
graphStatsText,
|
||||||
isNodeVisible,
|
isNodeVisible,
|
||||||
|
setFocusCenter,
|
||||||
|
exitFocusMode,
|
||||||
} = useGraphFilter(viz)
|
} = useGraphFilter(viz)
|
||||||
|
|
||||||
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
|
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
|
||||||
@@ -118,8 +124,10 @@ async function loadViz() {
|
|||||||
const { data } = await api.memoryViz()
|
const { data } = await api.memoryViz()
|
||||||
viz.value = data
|
viz.value = data
|
||||||
selectedWord.value = data.words[0] ?? null
|
selectedWord.value = data.words[0] ?? null
|
||||||
detailExpanded.value = true
|
if (data.words[0]) {
|
||||||
if (data.words[0]) loadWordMemory(data.words[0].id)
|
setFocusCenter(String(data.words[0].id))
|
||||||
|
loadWordMemory(data.words[0].id)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
vizLoading.value = false
|
vizLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -148,11 +156,17 @@ function onGraphSelect(id: string) {
|
|||||||
const w = viz.value?.words.find((x) => String(x.id) === id)
|
const w = viz.value?.words.find((x) => String(x.id) === id)
|
||||||
if (w) {
|
if (w) {
|
||||||
selectedWord.value = w
|
selectedWord.value = w
|
||||||
detailExpanded.value = true
|
setFocusCenter(id)
|
||||||
loadWordMemory(w.id)
|
loadWordMemory(w.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goPracticeSpell() {
|
||||||
|
if (!selectedWord.value) return
|
||||||
|
setPracticeFocusWordId(selectedWord.value.id)
|
||||||
|
router.push('/spell')
|
||||||
|
}
|
||||||
|
|
||||||
watch([graphStatusFilter, graphSearch, linkFilters], () => {
|
watch([graphStatusFilter, graphSearch, linkFilters], () => {
|
||||||
if (selectedWord.value && !isNodeVisible(String(selectedWord.value.id))) {
|
if (selectedWord.value && !isNodeVisible(String(selectedWord.value.id))) {
|
||||||
const first = filteredGraphNodes.value[0]
|
const first = filteredGraphNodes.value[0]
|
||||||
@@ -305,51 +319,70 @@ onMounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="graph-stats">{{ graphStatsText }}</p>
|
<div class="graph-mode-row">
|
||||||
|
<p class="graph-stats">{{ graphStatsText }}</p>
|
||||||
<WordGraphCanvas
|
<div class="graph-mode-actions">
|
||||||
v-if="filteredGraphNodes.length"
|
<button
|
||||||
:nodes="filteredGraphNodes"
|
v-if="graphViewMode === 'focus'"
|
||||||
:links="filteredGraphLinks"
|
type="button"
|
||||||
@select="onGraphSelect"
|
class="btn-mini"
|
||||||
/>
|
@click="exitFocusMode"
|
||||||
<p v-else class="curve-loading">当前筛选无匹配单词,请调整条件</p>
|
>
|
||||||
|
退出聚焦
|
||||||
<div class="graph-legend">
|
</button>
|
||||||
<span v-for="(meta, kind) in LINK_KIND_META" :key="kind" class="legend-item">
|
<button
|
||||||
<span class="link-swatch" :style="{ background: meta.color }" />
|
v-else-if="selectedWord"
|
||||||
{{ meta.label }}:{{ meta.desc }}
|
type="button"
|
||||||
</span>
|
class="btn-mini"
|
||||||
</div>
|
@click="setFocusCenter(String(selectedWord.id))"
|
||||||
</div>
|
>
|
||||||
|
聚焦此词
|
||||||
<div v-if="selectedWord" class="card word-detail">
|
</button>
|
||||||
<div class="detail-header">
|
|
||||||
<h3 class="detail-word-title">{{ selectedWord.en }} — {{ selectedWord.zh }}</h3>
|
|
||||||
<button type="button" class="detail-toggle" @click="detailExpanded = !detailExpanded">
|
|
||||||
{{ detailExpanded ? '收起' : '展开' }}
|
|
||||||
</button>
|
|
||||||
</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>
|
||||||
<p v-if="wordMemoryLoading" class="curve-loading">加载该词记忆曲线...</p>
|
</div>
|
||||||
<template v-else-if="wordCurvePoints.length">
|
|
||||||
<h4 class="word-curve-title">该单词记忆曲线</h4>
|
<div class="memory-graph-row">
|
||||||
<p class="section-desc">每次训练后更新 · 点与答题记录对应</p>
|
<div class="graph-panel">
|
||||||
<MemoryCurveChart
|
<WordGraphCanvas
|
||||||
:curve-points="wordCurvePoints"
|
v-if="filteredGraphNodes.length"
|
||||||
:future-risk="wordMemory?.future_risk ?? []"
|
:nodes="filteredGraphNodes"
|
||||||
|
:links="filteredGraphLinks"
|
||||||
|
@select="onGraphSelect"
|
||||||
/>
|
/>
|
||||||
</template>
|
<p v-else class="curve-loading">当前筛选无匹配单词,请调整条件</p>
|
||||||
<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>
|
||||||
|
|
||||||
|
<aside v-if="selectedWord" class="detail-panel card">
|
||||||
|
<h3 class="detail-word-title">{{ selectedWord.en }}</h3>
|
||||||
|
<p class="detail-word-zh">{{ selectedWord.zh }}</p>
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" @click="goPracticeSpell">
|
||||||
|
练这个词
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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 }}% · 保留 {{ selectedWord.retention_now }}%</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="wordMemoryLoading" class="curve-loading">加载曲线...</p>
|
||||||
|
<template v-else-if="wordCurvePoints.length">
|
||||||
|
<h4 class="word-curve-title">记忆曲线</h4>
|
||||||
|
<MemoryCurveChart
|
||||||
|
:curve-points="wordCurvePoints"
|
||||||
|
:future-risk="wordMemory?.future_risk ?? []"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<p v-else class="curve-loading">暂无训练记录</p>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -461,10 +494,72 @@ onMounted(() => {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.graph-mode-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
.graph-stats {
|
.graph-stats {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin: 0 0 10px;
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.memory-graph-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.memory-graph-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.graph-panel {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.detail-panel {
|
||||||
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.detail-panel {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.detail-word-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.detail-word-zh {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 4px 0 10px;
|
||||||
|
}
|
||||||
|
.detail-actions {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.graph-legend {
|
.graph-legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const FOCUS_WORD_KEY = 'wordloop_focus_word_id'
|
||||||
|
|
||||||
|
export function setPracticeFocusWordId(wordId: number) {
|
||||||
|
sessionStorage.setItem(FOCUS_WORD_KEY, String(wordId))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumePracticeFocusWordId(): number | null {
|
||||||
|
const raw = sessionStorage.getItem(FOCUS_WORD_KEY)
|
||||||
|
sessionStorage.removeItem(FOCUS_WORD_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const id = parseInt(raw, 10)
|
||||||
|
return Number.isFinite(id) ? id : null
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { QuizQuestion, Word } from '../api/request'
|
||||||
|
|
||||||
|
export function spellQuestionFromWord(word: Word): QuizQuestion {
|
||||||
|
const zh = word.source_lang === 'zh' ? word.source_text : word.target_text
|
||||||
|
const en = word.source_lang === 'zh' ? word.target_text : word.source_text
|
||||||
|
return {
|
||||||
|
word_id: word.id,
|
||||||
|
question_type: 'spell',
|
||||||
|
prompt: zh,
|
||||||
|
phonetic: word.phonetic,
|
||||||
|
options: [],
|
||||||
|
correct_answer: en,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user