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),
|
||||
listWords: (status?: string) =>
|
||||
request.get<Word[]>('/words', { params: status ? { status } : {} }),
|
||||
getWord: (id: number) => request.get<Word>(`/words/${id}`),
|
||||
memoryViz: () => request.get<MemoryVisualization>('/words/memory-viz'),
|
||||
wordMemory: (id: number) => request.get<WordMemoryDetail>(`/words/${id}/memory`),
|
||||
deleteWord: (id: number) => request.delete(`/words/${id}`),
|
||||
|
||||
@@ -12,8 +12,18 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const wrapRef = ref<HTMLDivElement | null>(null)
|
||||
const selectedId = ref<string | null>(null)
|
||||
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 {
|
||||
x: number
|
||||
@@ -232,6 +242,7 @@ function onPointerDown(e: PointerEvent) {
|
||||
const hit = hitNode(x, y)
|
||||
if (!hit) return
|
||||
|
||||
hoverNode.value = null
|
||||
draggingId = hit.id
|
||||
dragOffsetX = x - hit.x
|
||||
dragOffsetY = y - hit.y
|
||||
@@ -262,7 +273,21 @@ function onPointerMove(e: PointerEvent) {
|
||||
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) {
|
||||
@@ -320,24 +345,55 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="graph-wrap">
|
||||
<div ref="wrapRef" class="graph-wrap">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="graph-canvas"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.graph-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fafbff;
|
||||
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 {
|
||||
display: block;
|
||||
touch-action: none;
|
||||
|
||||
@@ -2,8 +2,10 @@ import { computed, ref, type Ref } from 'vue'
|
||||
import type { MemoryGraphLink, MemoryGraphNode, MemoryVisualization } from '../api/request'
|
||||
|
||||
export const MAX_GRAPH_NODES = 40
|
||||
export const MAX_FOCUS_NODES = 28
|
||||
|
||||
export type LinkKind = 'co_review' | 'status' | 'similar'
|
||||
export type GraphViewMode = 'browse' | 'focus'
|
||||
|
||||
export const LINK_KIND_META: Record<
|
||||
LinkKind,
|
||||
@@ -14,16 +16,79 @@ export const LINK_KIND_META: Record<
|
||||
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>) {
|
||||
const graphStatusFilter = ref('')
|
||||
const graphSearch = ref('')
|
||||
const graphViewMode = ref<GraphViewMode>('browse')
|
||||
const focusCenterId = ref<string | null>(null)
|
||||
const linkFilters = ref<Record<LinkKind, boolean>>({
|
||||
co_review: true,
|
||||
status: 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
|
||||
let nodes = viz.value.graph.nodes
|
||||
if (graphStatusFilter.value) {
|
||||
@@ -38,7 +103,7 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
||||
return nodes.length
|
||||
})
|
||||
|
||||
const filteredGraphNodes = computed((): MemoryGraphNode[] => {
|
||||
const browseNodes = computed((): MemoryGraphNode[] => {
|
||||
if (!viz.value) return []
|
||||
let nodes = [...viz.value.graph.nodes]
|
||||
if (graphStatusFilter.value) {
|
||||
@@ -63,8 +128,23 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
||||
.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(
|
||||
() => matchedBeforeLimit.value > MAX_GRAPH_NODES
|
||||
() =>
|
||||
graphViewMode.value === 'browse' && browseMatchedCount.value > MAX_GRAPH_NODES
|
||||
)
|
||||
|
||||
const filteredGraphLinks = computed((): MemoryGraphLink[] => {
|
||||
@@ -81,7 +161,12 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
||||
const n = filteredGraphNodes.value.length
|
||||
const e = filteredGraphLinks.value.length
|
||||
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) {
|
||||
text += ` · 已优先展示易错/薄弱词(最多 ${MAX_GRAPH_NODES} 个)`
|
||||
}
|
||||
@@ -95,12 +180,16 @@ export function useGraphFilter(viz: Ref<MemoryVisualization | null>) {
|
||||
return {
|
||||
graphStatusFilter,
|
||||
graphSearch,
|
||||
graphViewMode,
|
||||
focusCenterId,
|
||||
linkFilters,
|
||||
filteredGraphNodes,
|
||||
filteredGraphLinks,
|
||||
graphTruncated,
|
||||
graphStatsText,
|
||||
isNodeVisible,
|
||||
setFocusCenter,
|
||||
exitFocusMode,
|
||||
LINK_KIND_META,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type QuizSessionSnapshot,
|
||||
} from '../composables/useQuizSession'
|
||||
import { useQuizTimer } from '../composables/useQuizTimer'
|
||||
import { consumePracticeFocusWordId } from '../utils/practiceFocus'
|
||||
import { spellQuestionFromWord } from '../utils/spellQuestion'
|
||||
|
||||
const { startQuestionTimer, consumeDurationSeconds } = useQuizTimer()
|
||||
|
||||
@@ -69,7 +71,20 @@ onMounted(async () => {
|
||||
clearQuizSession('spell')
|
||||
const { data } = await api.spellQuiz()
|
||||
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
|
||||
} else {
|
||||
persist()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import WordCard from '../components/WordCard.vue'
|
||||
|
||||
const MemoryCurveChart = defineAsyncComponent(
|
||||
@@ -18,6 +19,9 @@ import {
|
||||
} from '../api/request'
|
||||
import { formatTrainSeconds } from '../composables/useQuizTimer'
|
||||
import { LINK_KIND_META, useGraphFilter, type LinkKind } from '../composables/useGraphFilter'
|
||||
import { setPracticeFocusWordId } from '../utils/practiceFocus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const viewTabs = [
|
||||
{ key: 'list', label: '单词列表' },
|
||||
@@ -42,18 +46,20 @@ 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 {
|
||||
graphStatusFilter,
|
||||
graphSearch,
|
||||
graphViewMode,
|
||||
linkFilters,
|
||||
filteredGraphNodes,
|
||||
filteredGraphLinks,
|
||||
graphStatsText,
|
||||
isNodeVisible,
|
||||
setFocusCenter,
|
||||
exitFocusMode,
|
||||
} = useGraphFilter(viz)
|
||||
|
||||
const wordCurvePoints = computed((): MemoryCurvePoint[] => {
|
||||
@@ -118,8 +124,10 @@ async function loadViz() {
|
||||
const { data } = await api.memoryViz()
|
||||
viz.value = data
|
||||
selectedWord.value = data.words[0] ?? null
|
||||
detailExpanded.value = true
|
||||
if (data.words[0]) loadWordMemory(data.words[0].id)
|
||||
if (data.words[0]) {
|
||||
setFocusCenter(String(data.words[0].id))
|
||||
loadWordMemory(data.words[0].id)
|
||||
}
|
||||
} finally {
|
||||
vizLoading.value = false
|
||||
}
|
||||
@@ -148,11 +156,17 @@ function onGraphSelect(id: string) {
|
||||
const w = viz.value?.words.find((x) => String(x.id) === id)
|
||||
if (w) {
|
||||
selectedWord.value = w
|
||||
detailExpanded.value = true
|
||||
setFocusCenter(id)
|
||||
loadWordMemory(w.id)
|
||||
}
|
||||
}
|
||||
|
||||
function goPracticeSpell() {
|
||||
if (!selectedWord.value) return
|
||||
setPracticeFocusWordId(selectedWord.value.id)
|
||||
router.push('/spell')
|
||||
}
|
||||
|
||||
watch([graphStatusFilter, graphSearch, linkFilters], () => {
|
||||
if (selectedWord.value && !isNodeVisible(String(selectedWord.value.id))) {
|
||||
const first = filteredGraphNodes.value[0]
|
||||
@@ -305,51 +319,70 @@ onMounted(() => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="graph-stats">{{ graphStatsText }}</p>
|
||||
|
||||
<WordGraphCanvas
|
||||
v-if="filteredGraphNodes.length"
|
||||
:nodes="filteredGraphNodes"
|
||||
:links="filteredGraphLinks"
|
||||
@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>
|
||||
<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 class="graph-mode-row">
|
||||
<p class="graph-stats">{{ graphStatsText }}</p>
|
||||
<div class="graph-mode-actions">
|
||||
<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>
|
||||
<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 ?? []"
|
||||
</div>
|
||||
|
||||
<div class="memory-graph-row">
|
||||
<div class="graph-panel">
|
||||
<WordGraphCanvas
|
||||
v-if="filteredGraphNodes.length"
|
||||
: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>
|
||||
</template>
|
||||
@@ -461,10 +494,72 @@ onMounted(() => {
|
||||
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 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 {
|
||||
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