Files
lexgo/learner/src/views/ImportView.vue
T
ila e069894c4e feat: 补齐桌面与手机体验、显示偏好与键盘操作 (#14)
- 主题(浅色/深色/跟随系统)与正文字号(标准/大/特大)按账号存在本机,
  账号切换不串、退出回默认;深色走 html[data-theme] 与 Element Plus 的 html.dark
- style.css 收敛为语义调色板::root 的 53 个变量是文件内仅有的颜色字面量,
  其余规则全部走 var(),暗色只覆盖变量
- 字号经 --reader-font-scale 只作用于阅读面,不做全局缩放
- 阅读位置按账号+章节保存滚动比例与该章 content_sha256,正文换版本后不恢复
- 复习页键盘:空格/Enter 显示答案、1/2/3 评分;输入控件与聚焦按钮的按键不被劫持
- 站点头部新增「显示」控件,七个学习页面共用
- Playwright 新增 390×844 hasTouch 的 mobile 项目与移动用例(无溢出、可返回、
  触摸滑动不误开面板、深色与字号持久化、账号隔离);新增主题变量回归用例
- Wiki 记录 Architecture、Business-Rules、Local-Development 与需求更新
2026-09-15 15:11:30 +08:00

199 lines
8.6 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, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { ElButton, ElInput, ElOption, ElRadio, ElRadioGroup, ElSelect } from 'element-plus'
import { LANGUAGE_LABEL, TEXT_MAX_CODE_POINTS, fileProblem, fileSizeLabel, textProblem, titleProblem, useLibraryStore, type SubmitTarget } from '../stores/library'
import { useSessionStore } from '../stores/session'
import DisplaySettings from '../components/DisplaySettings.vue'
const session = useSessionStore()
const library = useLibraryStore()
const route = useRoute()
const router = useRouter()
const title = ref('')
const text = ref('')
const mode = ref<'new' | 'append'>('new')
const bookId = ref<number | undefined>(undefined)
// The accepted prototype offers both sources on one screen; the server treats them the same.
const source = ref<'paste' | 'txt'>('paste')
const file = ref<File | null>(null)
const fileInfo = ref<{ name: string; encoding: string; size: string } | null>(null)
const titleError = ref('')
const textError = ref('')
const fileError = ref('')
const bookError = ref('')
const length = computed(() => [...text.value].length)
const busy = computed(() => library.submitting)
/** The browser pre-check only replaces the server, it never replaces its verdict. */
async function readFile(selected: File): Promise<void> {
fileError.value = ''
fileInfo.value = null
const problem = fileProblem(selected)
if (problem) {
file.value = null
fileError.value = problem
return
}
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
decoder.decode(await selected.arrayBuffer())
} catch {
file.value = null
fileError.value = '文件不是 UTF-8 编码,请另存为 UTF-8 后重试。'
return
}
file.value = selected
fileInfo.value = { name: selected.name, encoding: 'UTF-8', size: fileSizeLabel(selected.size) }
// A file usually defines the title; the learner can still change it before submitting.
if (!title.value.trim()) title.value = selected.name.replace(/\.txt$/i, '').slice(0, 120)
}
function onFile(event: Event): void {
const selected = (event.target as HTMLInputElement).files?.[0]
file.value = null
fileInfo.value = null
fileError.value = ''
if (selected) void readFile(selected)
}
function requestedBookId(): number | undefined {
const raw = Array.isArray(route.query.book) ? route.query.book[0] : route.query.book
if (typeof raw !== 'string' || !/^\d+$/.test(raw)) return undefined
const value = Number(raw)
return Number.isSafeInteger(value) && value > 0 ? value : undefined
}
onMounted(() => {
// `?book=<id>` preselects "append to an existing book".
const preselect = requestedBookId()
if (preselect === undefined) return
mode.value = 'append'
bookId.value = preselect
})
watch(mode, value => {
// The select can only list the caller's own books.
if (value === 'append') void library.loadBooks()
})
watch(title, () => { titleError.value = '' })
watch(text, () => { textError.value = '' })
// Switching source clears the other source's complaints and its result.
watch(source, () => {
titleError.value = ''
textError.value = ''
fileError.value = ''
library.submitError = ''
})
async function submit() {
if (busy.value) return
titleError.value = titleProblem(title.value)
textError.value = source.value === 'paste' ? textProblem(text.value) : ''
fileError.value = source.value === 'txt' && !file.value ? (fileError.value || '请选择要导入的 TXT 文件。') : fileError.value
bookError.value = mode.value === 'append' && bookId.value === undefined ? '请选择要追加的书籍。' : ''
if (titleError.value || textError.value || fileError.value || bookError.value) return
const target: SubmitTarget = mode.value === 'append' && bookId.value !== undefined
? { mode: 'append', bookId: bookId.value }
: { mode: 'new' }
try {
const createdBookId = source.value === 'txt' && file.value
? await library.upload({ title: title.value, target, file: file.value })
: await library.submit({ title: title.value, text: text.value, target })
// A response that arrives after the user left this page must not navigate them back.
if (disposed) return
// The requestId was consumed by this submission, so the form starts clean.
title.value = ''
text.value = ''
file.value = null
fileInfo.value = null
await router.replace(`/books/${createdBookId}`)
} catch {
// library.submitError already carries the server message for the template.
}
}
// The form may still be awaiting its submit when the user navigates away.
let disposed = false
onUnmounted(() => {
disposed = true
library.submitError = ''
})
</script>
<template>
<div v-if="session.user">
<header class="site-header">
<RouterLink to="/" class="brand">LexGo<span class="brand-dot">.</span></RouterLink>
<nav aria-label="学习导航"><RouterLink to="/">我的书库</RouterLink> · <RouterLink to="/vocab">生词本</RouterLink> · <RouterLink to="/review">到期复习</RouterLink> · <RouterLink to="/progress">进度</RouterLink></nav>
<div class="account">
<DisplaySettings />
<span class="account-name">{{ session.user.username }}</span>
</div>
</header>
<main class="page">
<p class="breadcrumb"><RouterLink to="/">我的书库</RouterLink><span aria-hidden="true">/</span><span>导入内容</span></p>
<div class="page-title">
<div>
<h1>导入英文内容</h1>
<p class="subtle">粘贴英文正文,提交后系统会自动切分并处理章节。</p>
</div>
</div>
<form class="import-form" novalidate @submit.prevent="submit">
<div class="field">
<span class="field-label">导入方式</span>
<ElRadioGroup v-model="source" :disabled="busy" aria-label="导入方式">
<ElRadio value="paste">粘贴文本</ElRadio>
<ElRadio value="txt">TXT 文件</ElRadio>
</ElRadioGroup>
</div>
<div class="field">
<span class="field-label">语言</span>
<p class="fixed-value">{{ LANGUAGE_LABEL }}</p>
</div>
<div class="field">
<label for="title">标题</label>
<ElInput id="title" v-model="title" type="text" maxlength="200" placeholder="例如:虚构样例第一章" :disabled="busy" />
<p v-if="titleError" role="alert" class="field-error">{{ titleError }}</p>
</div>
<div class="field">
<span class="field-label">导入到</span>
<ElRadioGroup v-model="mode" :disabled="busy" aria-label="导入目标">
<ElRadio value="new">新建书籍</ElRadio>
<ElRadio value="append">追加到已有书籍</ElRadio>
</ElRadioGroup>
</div>
<div v-if="mode === 'append'" class="field">
<label for="book">选择书籍</label>
<ElSelect id="book" v-model="bookId" placeholder="请选择要追加的书籍" :disabled="busy" class="book-select">
<ElOption v-for="item in library.books" :key="item.id" :label="item.title" :value="item.id" />
</ElSelect>
<p v-if="library.booksError" role="alert" class="field-error">{{ library.booksError }}</p>
<p v-if="bookError" role="alert" class="field-error">{{ bookError }}</p>
</div>
<div v-if="source === 'paste'" class="field">
<label for="text">正文</label>
<ElInput id="text" v-model="text" type="textarea" :rows="12" placeholder="在此粘贴英文正文…" :disabled="busy" />
<p class="counter">{{ length }} / {{ TEXT_MAX_CODE_POINTS }} 字符</p>
<p v-if="textError" role="alert" class="field-error">{{ textError }}</p>
</div>
<div v-else class="field">
<label for="file">TXT 文件</label>
<input id="file" data-testid="file-input" type="file" accept=".txt,text/plain" :disabled="busy" @change="onFile" />
<p v-if="fileInfo" class="counter" data-testid="file-info">{{ fileInfo.name }} · {{ fileInfo.encoding }} · {{ fileInfo.size }}</p>
<p v-else class="subtle">仅支持 UTF-8 的 .txt 文件,最多 2 MiB;文件不会保存在服务器上。</p>
<p v-if="fileError" role="alert" class="field-error">{{ fileError }}</p>
</div>
<p v-if="library.submitError" role="alert" class="notice">{{ library.submitError }}</p>
<div class="form-actions">
<ElButton type="primary" native-type="submit" :loading="busy" :disabled="busy">{{ source === 'txt' ? '上传并处理' : '开始处理' }}</ElButton>
<RouterLink to="/" class="subtle">返回书库</RouterLink>
</div>
</form>
</main>
</div>
</template>