- POST /api/v1/books/upload 与 /api/v1/books/:id/chapters/upload:multipart 上传, 字段白名单、未知或重复字段拒绝、非 multipart 拒绝、单槽并发门忙时 429 - 只接受 UTF-8(可选 BOM 剥离且不进入原文),UTF-16 按 BOM 识别并给出针对性提示, 非法字节整体拒绝、不使用替换字符;2 MiB 字节上限之后仍套用单章 100000 码点上限 - 文件只在内存中解码,不创建临时文件;客户端文件名不参与任何路径也不入库 - 解码后交给现有 PasteBook/PasteChapter,分章、任务幂等与崩溃恢复与粘贴完全一致 - 学习端导入页新增「粘贴文本 / TXT 文件」来源切换与客户端预检,session.request 支持 FormData - gofmt 整理 #8 引入的 import 顺序与空行 - 同步 Architecture-and-Code-Map、Business-Rules-and-Glossary、 Local-Development-and-Verification、Product-Requirements-Overview 与 Home
197 lines
8.4 KiB
Vue
197 lines
8.4 KiB
Vue
<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'
|
||
|
||
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></nav>
|
||
<div class="account">
|
||
<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>
|