Files
lexgo/learner/src/views/ImportView.vue
T
ila b18f9cc4a5 fix: 整改 #5 审核问题 R1~R4 (#5)
R1 追加契约:学习端把新建与追加拆成两个请求体,追加不再发送 language(后端严格
解码会拒绝未知字段,此前真实追加返回 400);补充断言真实请求格式的回归测试。
R2 运行期恢复:启动恢复与运行期清扫合并为一处,worker 每秒把停留超过 15 秒的
processing 任务重新入队,超过 5 次尝试的任务置为 failed(attempts_exhausted);
人工重试重置尝试次数;日志如实区分“已入队”与“等待下一次清扫”。
R3 离页作废在途请求:closeBook/closeChapter 推进请求序号并清理 loading,导入页
在卸载后的成功响应不再触发跳转。
R4 重试自愈:重试被接受后先应用返回的 pending 状态并继续轮询,静默刷新失败不再
让页面停在处理失败。

测试:Go 20 个顶层用例通过(新增 2 项);学习端 38 项通过,其中 7 项在整改前代码
上实际失败;真实联调验证追加可用、被中断任务约 0.5 秒内在运行中自动恢复、重试在
首次刷新失败后自动显示最终结果。

文档:Wiki 先写后回读(架构、业务规则、本地验证),导出核心镜像。
2026-09-11 11:02:32 +08:00

134 lines
5.5 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, 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)
const titleError = ref('')
const textError = ref('')
const bookError = ref('')
const length = computed(() => [...text.value].length)
const busy = computed(() => library.submitting)
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 = '' })
async function submit() {
if (busy.value) return
titleError.value = titleProblem(title.value)
textError.value = textProblem(text.value)
bookError.value = mode.value === 'append' && bookId.value === undefined ? '请选择要追加的书籍。' : ''
if (titleError.value || textError.value || bookError.value) return
const target: SubmitTarget = mode.value === 'append' && bookId.value !== undefined
? { mode: 'append', bookId: bookId.value }
: { mode: 'new' }
try {
const createdBookId = 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 = ''
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>
<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 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>
<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">开始处理</ElButton>
<RouterLink to="/" class="subtle">返回书库</RouterLink>
</div>
</form>
</main>
</div>
</template>