feat: PDF 转换前端(epub 上传/进度轮询/下载/用户软删,复用 zTools2 API)

便携式 Vue3 前端:上传 epub 转 PDF,显示上传与转换进度,完成后下载;
用户可软删任务(不再对自己展示,管理员仍可见)。凭 cookie 标识用户。
复用 zTools2 后端 /api/pdf/*(同源),独立 i18n(中英懒加载),
可独立运行或经 mainPage iframe 同源集成。含 vitest 单测 20 项。
This commit is contained in:
2026-07-27 11:33:26 +08:00
commit 30f5cee4a1
19 changed files with 3918 additions and 0 deletions

193
test/usePdfApi.test.js Normal file
View File

@@ -0,0 +1,193 @@
// usePdfApi 单元测试:验证状态机、校验、轮询、格式化逻辑(与 DOM 解耦)。
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
createPdfApi,
isTerminal,
fmtBytes,
validateFile,
uploadFile,
} from '../src/composables/usePdfApi.js'
describe('isTerminal', () => {
it('done 与 failed 为终态', () => {
expect(isTerminal({ status: 'done' })).toBe(true)
expect(isTerminal({ status: 'failed' })).toBe(true)
})
it('pending / converting 非终态', () => {
expect(isTerminal({ status: 'pending' })).toBe(false)
expect(isTerminal({ status: 'converting' })).toBe(false)
})
it('空值安全', () => {
expect(isTerminal(null)).toBe(false)
expect(isTerminal(undefined)).toBe(false)
})
})
describe('fmtBytes', () => {
it('字节级显示 B', () => {
expect(fmtBytes(500)).toBe('500 B')
})
it('KB/MB 进位', () => {
expect(fmtBytes(1500)).toBe('1.5 KB')
expect(fmtBytes(5 * 1024 * 1024)).toBe('5.2 MB')
})
it('空/非法值返回 -', () => {
expect(fmtBytes(null)).toBe('-')
expect(fmtBytes(NaN)).toBe('-')
})
})
describe('validateFile', () => {
const mk = (name, size) => ({ name, size })
it('epub 且未超限通过', () => {
expect(validateFile(mk('a.epub', 1000))).toBeNull()
expect(validateFile(mk('A.EPUB', 1000))).toBeNull()
})
it('非 epub 拒绝', () => {
expect(validateFile(mk('a.txt', 1000))).toBe('upload.requireEpub')
expect(validateFile(mk('a.pdf', 1000))).toBe('upload.requireEpub')
})
it('超 250MB 拒绝', () => {
expect(validateFile(mk('a.epub', 250 * 1024 * 1024 + 1))).toBe('upload.oversize')
})
it('自定义上限', () => {
expect(validateFile(mk('a.epub', 200), 100)).toBe('upload.oversize')
})
})
describe('createPdfApi', () => {
it('load 拉取列表并归一化', async () => {
const fetcher = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ total: 2, items: [{ id: 1 }, { id: 2 }] }),
})
const api = createPdfApi({ fetch: fetcher })
const r = await api.load()
expect(fetcher).toHaveBeenCalledWith('/api/pdf/jobs', { credentials: 'same-origin' })
expect(r.total).toBe(2)
expect(r.items).toHaveLength(2)
})
it('load 处理空响应', async () => {
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
const api = createPdfApi({ fetch: fetcher })
const r = await api.load()
expect(r.total).toBe(0)
expect(r.items).toEqual([])
})
it('load HTTP 错误抛异常', async () => {
const fetcher = vi.fn().mockResolvedValue({ ok: false, status: 500 })
const api = createPdfApi({ fetch: fetcher })
await expect(api.load()).rejects.toThrow('HTTP 500')
})
it('remove 发 DELETE', async () => {
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ deleted: true }) })
const api = createPdfApi({ fetch: fetcher })
const r = await api.remove(7)
expect(fetcher).toHaveBeenCalledWith('/api/pdf/jobs/7', {
method: 'DELETE',
credentials: 'same-origin',
})
expect(r.deleted).toBe(true)
})
it('downloadUrl 生成路径', () => {
const api = createPdfApi({ fetch: vi.fn() })
expect(api.downloadUrl(3)).toBe('/api/pdf/jobs/3/download')
})
it('pollPending 无未完成任务立即 onDone', () => {
const api = createPdfApi({ fetch: vi.fn() })
const onDone = vi.fn()
const onUpdate = vi.fn()
const stop = api.pollPending(
[{ id: 1, status: 'done' }],
onUpdate,
onDone
)
expect(onDone).toHaveBeenCalledTimes(1)
expect(onUpdate).not.toHaveBeenCalled()
stop()
})
it('pollPending 轮询未完成项直至终态', async () => {
vi.useFakeTimers()
// getJob 第 1 次返回 converting第 2 次返回 done
const states = [{ status: 'converting', progress: 50 }, { status: 'done', progress: 100 }]
let call = 0
const fetcher = vi.fn().mockImplementation(async () => ({
ok: true,
json: async () => ({ id: 1, ...states[Math.min(call++, states.length - 1)] }),
}))
const api = createPdfApi({ fetch: fetcher })
const updated = []
const onDone = vi.fn()
const stop = api.pollPending(
[{ id: 1, status: 'pending', progress: 0 }],
(fresh) => updated.push(fresh),
onDone
)
// 第 1 次轮询converting
await vi.advanceTimersByTimeAsync(1500)
expect(updated[0][0].status).toBe('converting')
// 第 2 次轮询done -> 触发 onDone
await vi.advanceTimersByTimeAsync(1500)
expect(onDone).toHaveBeenCalledTimes(1)
expect(updated[1][0].status).toBe('done')
stop()
vi.useRealTimers()
})
it('pollPending stop 提前停止', async () => {
vi.useFakeTimers()
const fetcher = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ id: 1, status: 'converting', progress: 30 }),
})
const api = createPdfApi({ fetch: fetcher })
const onUpdate = vi.fn()
const stop = api.pollPending([{ id: 1, status: 'pending' }], onUpdate)
stop()
await vi.advanceTimersByTimeAsync(3000)
expect(onUpdate).not.toHaveBeenCalled()
vi.useRealTimers()
})
})
describe('uploadFile', () => {
it('成功解析响应', async () => {
const fakeXhr = vi.fn().mockImplementation(() => {
const x = {
open: vi.fn(),
send: vi.fn(),
upload: {},
status: 200,
responseText: JSON.stringify({ job: { id: 9 } }),
}
// 模拟 send 后异步触发 onload
setTimeout(() => x.onload && x.onload(), 0)
return x
})
const res = await uploadFile({ name: 'a.epub', size: 1 }, () => {}, { xhr: fakeXhr })
expect(res.job.id).toBe(9)
})
it('HTTP 错误抛含 detail', async () => {
const fakeXhr = vi.fn().mockImplementation(() => {
const x = {
open: vi.fn(),
send: vi.fn(),
upload: {},
status: 413,
responseText: JSON.stringify({ detail: 'too big' }),
}
setTimeout(() => x.onload && x.onload(), 0)
return x
})
await expect(
uploadFile({ name: 'a.epub', size: 1 }, () => {}, { xhr: fakeXhr })
).rejects.toThrow('too big')
})
})