本阶段我的主要工作是开发小说上传中心,让用户可以把 TXT 小说提交到后端,并在前端看到处理进度。我把本阶段的重点放在三个方面:上传前校验、上传接口封装、上传后任务状态轮询。
相关代码主要在 frontend/src/views/UploadCenterView.vue 和 frontend/src/api/novel.ts。接口层中,我定义了 NovelUploadPayload 和 UploadRecord 等类型,让页面在调用接口时能明确知道需要传什么、后端会返回什么。上传小说时使用 FormData,因为文件不能像普通 JSON 一样直接提交。
export function uploadNovel(payload: NovelUploadPayload) {
const formData = new FormData()
formData.append('file', payload.file)
formData.append('title', payload.title)
if (payload.author) formData.append('author', payload.author)
if (payload.source) formData.append('source', payload.source)
if (payload.copyrightStatus) formData.append('copyrightStatus', payload.copyrightStatus)
if (payload.language) formData.append('language', payload.language)
if (payload.description) formData.append('description', payload.description)
return request.post('/api/v1/novels/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
这里我没有把 FormData 逻辑写在页面组件里,而是放在 api/novel.ts 中。这样页面只关心"我要上传一本小说",不用关心 multipart 请求如何组织。这个拆分降低了页面组件的复杂度,也让接口调用更容易维护。
页面层的状态比较多:selectedFile 保存用户选择的文件,uploading 控制提交按钮状态,pageError 显示错误,latestNovelId 和 latestUploadRecordId 记录后端返回的任务信息,uploadRecord 保存当前处理进度,pollingTimer 管理轮询定时器。这些状态分别对应用户操作、网络请求、后端任务和生命周期清理。
在上传前,我做了三类校验:是否选择文件、是否为 .txt 文件、标题是否为空。
if (!selectedFile.value) {
pageError.value = '请先选择一个 TXT 文件。'
return
}
if (!selectedFile.value.name.toLowerCase().endsWith('.txt')) {
pageError.value = '当前只支持上传 TXT 格式文件。'
return
}
if (!uploadForm.title.trim()) {
pageError.value = '请输入小说标题。'
return
}
为了减少用户输入成本,我在 onFileChange 中做了一个小优化:如果用户还没有填写标题,就自动用文件名去掉扩展名作为默认标题。
if (file && !uploadForm.title.trim()) {
uploadForm.title = file.name.replace(/\.[^/.]+$/, '')
}
上传成功后,后端并不会立即完成全部知识构建流程,而是返回 novelId 和 uploadRecordId。这时前端要根据 uploadRecordId 持续查询处理状态。我采用 setTimeout 而不是 setInterval,因为每次轮询都应该等上一次请求结束后再安排下一次,避免网络慢时多个请求堆积。
function schedulePoll(uploadRecordId: string) {
clearPolling()
pollingTimer.value = window.setTimeout(() => {
void refreshUploadRecord(uploadRecordId, true)
}, 1400)
}
refreshUploadRecord 中会请求上传记录,如果任务还没有结束,就继续安排下一轮轮询。是否结束由 isDone 计算属性判断:
const isDone = computed(() => {
const status = uploadRecord.value?.status
return status ? ['success', 'failed', 'cancelled'].includes(status) : false
})
本阶段调试时,我重点检查了三个问题。第一,后端接口返回后,前端是否能正确拿到 uploadRecordId 并启动轮询。第二,任务完成后是否停止轮询,避免请求无限持续。第三,上传失败或状态刷新失败时,页面是否能显示错误而不是静默失败。通过这些调试,我理解到异步任务页面和普通表单页面的区别:普通表单提交后只关心一次响应,而异步任务页面要关心任务的完整生命周期。
从工作量上看,本阶段我完成了小说上传页面的表单设计、文件选择、自动标题填充、前端校验、上传接口联调、处理状态轮询、状态文字映射、错误提示和生命周期清理。这个页面连接了前端用户入口和后端知识构建流程,是后续首页展示小说、选择剧情节点和开始游戏的前置条件。它的意义不只是"能上传文件",而是让用户能够看见一个长任务从提交到完成的全过程。