Files
wordloop/frontend/src/pages/WordLibrary.vue
T
john e76fa586f1 Add word-book practice, iOS app shell, and fix embedded WebView blank screen.
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>
2026-06-06 21:09:27 +08:00

1013 lines
26 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 { 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>