feat: PDF 转换前端(epub 上传/进度轮询/下载/用户软删,复用 zTools2 API)
便携式 Vue3 前端:上传 epub 转 PDF,显示上传与转换进度,完成后下载; 用户可软删任务(不再对自己展示,管理员仍可见)。凭 cookie 标识用户。 复用 zTools2 后端 /api/pdf/*(同源),独立 i18n(中英懒加载), 可独立运行或经 mainPage iframe 同源集成。含 vitest 单测 20 项。
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
88
README.md
Normal file
88
README.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# zPDF_package - PDF 转换前端
|
||||||
|
|
||||||
|
上传 epub 文件转为 PDF,显示转换进度,完成后下载。复用 [zTools2](https://git.zikai.wang/zikai/zTools2) 后端 API(`/api/pdf/*`)。便携式组件,可独立运行,亦可作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的子模块经 iframe 集成展示。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 上传 epub(拖拽/选择,≤250MB,客户端预校验 + 服务端校验)
|
||||||
|
- 上传进度显示
|
||||||
|
- 转换进度轮询(pending/converting -> done/failed)
|
||||||
|
- 完成后下载 PDF
|
||||||
|
- 用户软删(不再对自己展示;管理员仍可见,需管理员硬删才真正删除)
|
||||||
|
- 凭浏览器 cookie(`zk_pdf`,httpOnly)标识用户、记住任务
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
zPDF_package/
|
||||||
|
├── index.html Vite 入口
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js base:'./' 便携式构建
|
||||||
|
├── vitest.config.js
|
||||||
|
├── .npmrc npmmirror 镜像加速
|
||||||
|
├── src/
|
||||||
|
│ ├── main.js 应用挂载(i18n + 全局样式)
|
||||||
|
│ ├── App.vue 便携式根组件(嵌入与独立两用)
|
||||||
|
│ ├── i18n/ 独立 vue-i18n 实例 + 中英 locale
|
||||||
|
│ ├── composables/
|
||||||
|
│ │ ├── usePdfApi.js API 客户端 + 进度轮询状态机(可单测)
|
||||||
|
│ │ └── useLocale.js 语言辅助(直读自身 i18n 实例)
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── Uploader.vue 拖拽/选择 + 上传进度
|
||||||
|
│ │ └── JobList.vue 任务列表(进度/状态/下载/删除)
|
||||||
|
│ └── styles/global.css 全局重置 + 设计令牌
|
||||||
|
└── test/ Vitest 单元测试(20 项)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 外部依赖
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| Node.js | ≥ 18 |
|
||||||
|
| zTools2 | 后端 `/api/pdf/*`(同源调用,cookie 自动管理) |
|
||||||
|
|
||||||
|
> 前端纯静态,无运行期后端依赖;API 由 zTools2 提供。
|
||||||
|
|
||||||
|
## 如何使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆并安装
|
||||||
|
git clone https://git.zikai.wang/zikai/zPDF_package.git
|
||||||
|
cd zPDF_package
|
||||||
|
npm install # 已配 npmmirror 镜像加速
|
||||||
|
|
||||||
|
npm run dev # 开发服务器 http://localhost:5173(需 zTools2 在同源或配置代理)
|
||||||
|
npm run build # 构建到 dist/
|
||||||
|
npm test # 单元测试(20 项)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 开发时 `/api/pdf/*` 需指向 zTools2。可配 Vite 代理,或直接由 zTools2 托管 `dist/`。
|
||||||
|
|
||||||
|
## 部署到 zTools2
|
||||||
|
|
||||||
|
zTools2 的 `/pdf` 页面(`static/pdf.html` + `pdf.js`)为内置同款实现。如需用本项目的 Vue 构建产物替换:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd zPDF_package && npm run build
|
||||||
|
# 把 dist/ 部署到 zTools2 静态目录(如 static/pdf_app/),改 pdf.html 引用之
|
||||||
|
```
|
||||||
|
|
||||||
|
## 嵌入 mainPage
|
||||||
|
|
||||||
|
作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的 git submodule(`third_party/zPDF_package`),mainPage 用 iframe 嵌入 zTools2 托管的 `/pdf` 页面(同源,cookie 第一方生效,无需 CORS)。详见 mainPage 的 `docs/submodule-zPDF_package.md`。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 项 | 选择 |
|
||||||
|
|----|------|
|
||||||
|
| 框架 | Vue 3.5(SFC,`<script setup>`) |
|
||||||
|
| 构建 | Vite 6 |
|
||||||
|
| 国际化 | vue-i18n 9(中文同步,英文懒加载) |
|
||||||
|
| 状态管理 | composable 单例 |
|
||||||
|
| 测试 | Vitest |
|
||||||
|
| 包源 | npmmirror.com(中国镜像加速) |
|
||||||
|
|
||||||
|
## 了解更多
|
||||||
|
|
||||||
|
- [嵌入 mainPage(iframe 同源集成)](../zMainPage/docs/submodule-zPDF_package.md)
|
||||||
|
- 后端 API 与转换实现见 [zTools2](https://git.zikai.wang/zikai/zTools2) README
|
||||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PDF 转换</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2791
package-lock.json
generated
Normal file
2791
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "zpdf-package",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "PDF 转换前端 -- 上传 epub 转 PDF、显示进度、下载;复用 zTools2 后端 API。便携式组件,可独立运行或经 mainPage iframe 集成。",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-i18n": "^9.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/App.vue
Normal file
131
src/App.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onUnmounted } from 'vue'
|
||||||
|
import { usePdfApi, validateFile, uploadFile } from './composables/usePdfApi.js'
|
||||||
|
import { useLocale } from './composables/useLocale.js'
|
||||||
|
import { loadLocaleAsync } from './i18n/index.js'
|
||||||
|
import Uploader from './components/Uploader.vue'
|
||||||
|
import JobList from './components/JobList.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
locale: { type: String, default: 'zh-CN' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useLocale()
|
||||||
|
const api = usePdfApi()
|
||||||
|
|
||||||
|
// 跟随父项目语言开关切换本子项目 i18n 实例的 locale
|
||||||
|
import { watch } from 'vue'
|
||||||
|
watch(
|
||||||
|
() => props.locale,
|
||||||
|
(val) => {
|
||||||
|
if (val) loadLocaleAsync(val)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const items = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref('')
|
||||||
|
let stopPoll = null
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const r = await api.load()
|
||||||
|
items.value = r.items
|
||||||
|
total.value = r.total
|
||||||
|
startPoll()
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = String(e.message || e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPoll() {
|
||||||
|
if (stopPoll) stopPoll()
|
||||||
|
stopPoll = api.pollPending(
|
||||||
|
items.value,
|
||||||
|
(fresh) => {
|
||||||
|
items.value = fresh
|
||||||
|
},
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpload(file, onProgress) {
|
||||||
|
const err = validateFile(file)
|
||||||
|
if (err) {
|
||||||
|
throw new Error(t(err))
|
||||||
|
}
|
||||||
|
const res = await uploadFile(file, onProgress)
|
||||||
|
// 上传成功后刷新列表以纳入新任务并开始轮询
|
||||||
|
await refresh()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(job) {
|
||||||
|
await api.remove(job.id)
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (stopPoll) stopPoll()
|
||||||
|
})
|
||||||
|
|
||||||
|
refresh()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="zpdf-app">
|
||||||
|
<header class="zpdf-header">
|
||||||
|
<h1>{{ t('app.title') }}</h1>
|
||||||
|
</header>
|
||||||
|
<main class="zpdf-main">
|
||||||
|
<Uploader @upload="onUpload" />
|
||||||
|
<JobList
|
||||||
|
:items="items"
|
||||||
|
:total="total"
|
||||||
|
:loading="loading"
|
||||||
|
:load-error="loadError"
|
||||||
|
@refresh="refresh"
|
||||||
|
@delete="onDelete"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* scoped:样式仅作用于本组件,作为组件被嵌入时不污染宿主。
|
||||||
|
全局重置与设计令牌在 src/styles/global.css(独立 app)/ 宿主 :root(嵌入时)。 */
|
||||||
|
.zpdf-app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.zpdf-header {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 12px 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.zpdf-header h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.zpdf-main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 20px 32px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.zpdf-header { padding: 10px 12px; }
|
||||||
|
.zpdf-main { padding: 12px 12px 24px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
165
src/components/JobList.vue
Normal file
165
src/components/JobList.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useLocale } from '../composables/useLocale.js'
|
||||||
|
import { usePdfApi, fmtBytes } from '../composables/usePdfApi.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array, default: () => [] },
|
||||||
|
total: { type: Number, default: 0 },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
loadError: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh', 'delete'])
|
||||||
|
const { t } = useLocale()
|
||||||
|
const api = usePdfApi()
|
||||||
|
|
||||||
|
function statusText(job) {
|
||||||
|
if (job.status === 'done') return t('job.statusDone')
|
||||||
|
if (job.status === 'failed') return t('job.statusFailed')
|
||||||
|
if (job.status === 'converting') return t('job.statusConverting')
|
||||||
|
return t('job.statusPending')
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClass(job) {
|
||||||
|
if (job.status === 'done') return 'zpdf-tag zpdf-tag--ok'
|
||||||
|
if (job.status === 'failed') return 'zpdf-tag zpdf-tag--err'
|
||||||
|
return 'zpdf-tag zpdf-tag--warn'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(s) {
|
||||||
|
if (!s) return '-'
|
||||||
|
const d = new Date(s)
|
||||||
|
if (isNaN(d.getTime())) return s
|
||||||
|
const p = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(job) {
|
||||||
|
if (!confirm(t('job.deleteConfirm', { name: job.source_filename }))) return
|
||||||
|
emit('delete', job)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="zpdf-list">
|
||||||
|
<div class="zpdf-toolbar">
|
||||||
|
<button class="zpdf-btn zpdf-btn--primary zpdf-btn--sm" @click="emit('refresh')">
|
||||||
|
{{ t('refresh') }}
|
||||||
|
</button>
|
||||||
|
<span class="zpdf-muted">{{ t('list.count', { total }) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="zpdf-skel">{{ t('list.loading') }}</div>
|
||||||
|
<div v-else-if="loadError" class="zpdf-empty">
|
||||||
|
{{ t('list.loadFailed', { msg: loadError }) }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!items.length" class="zpdf-empty">{{ t('list.empty') }}</div>
|
||||||
|
<table v-else class="zpdf-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-name">{{ t('job.name') }}</th>
|
||||||
|
<th class="col-size">{{ t('job.size') }}</th>
|
||||||
|
<th class="col-status">{{ t('job.status') }}</th>
|
||||||
|
<th class="col-time">{{ t('job.created') }}</th>
|
||||||
|
<th class="col-act">{{ t('job.actions') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="j in items" :key="j.id">
|
||||||
|
<td class="col-name">{{ j.source_filename }}</td>
|
||||||
|
<td class="col-size zpdf-mono">{{ fmtBytes(j.source_size) }}</td>
|
||||||
|
<td class="col-status">
|
||||||
|
<span v-if="j.status === 'done' || j.status === 'failed'" :class="statusClass(j)">
|
||||||
|
{{ statusText(j) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="zpdf-prog">
|
||||||
|
<span class="zpdf-prog__label">{{ statusText(j) }}</span>
|
||||||
|
<span class="zpdf-bar zpdf-bar--sm">
|
||||||
|
<span class="zpdf-bar__fill" :style="{ width: (j.progress || 0) + '%' }"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="col-time zpdf-muted">{{ fmtTime(j.created_at) }}</td>
|
||||||
|
<td class="col-act">
|
||||||
|
<a
|
||||||
|
v-if="j.status === 'done'"
|
||||||
|
class="zpdf-btn zpdf-btn--primary zpdf-btn--sm"
|
||||||
|
:href="api.downloadUrl(j.id)"
|
||||||
|
download
|
||||||
|
>{{ t('job.download') }}</a>
|
||||||
|
<button class="zpdf-btn zpdf-btn--danger zpdf-btn--sm" @click="onDelete(j)">
|
||||||
|
{{ t('job.delete') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.zpdf-toolbar {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.zpdf-muted { color: var(--text-secondary); font-size: 0.82em; }
|
||||||
|
.zpdf-mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.85em; }
|
||||||
|
.zpdf-skel, .zpdf-empty {
|
||||||
|
padding: 28px; text-align: center; color: var(--text-secondary); font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zpdf-table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
|
||||||
|
.zpdf-table th, .zpdf-table td {
|
||||||
|
text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.zpdf-table th {
|
||||||
|
color: var(--text-secondary); font-weight: 600; font-size: 0.82em;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.zpdf-table tbody tr:hover td { background: var(--surface); }
|
||||||
|
.zpdf-table .col-size { width: 8em; }
|
||||||
|
.zpdf-table .col-status { width: 12em; }
|
||||||
|
.zpdf-table .col-time { width: 11em; }
|
||||||
|
.zpdf-table .col-act { width: 10em; text-align: right; white-space: nowrap; }
|
||||||
|
.zpdf-table .col-name { font-weight: 600; word-break: break-all; }
|
||||||
|
.zpdf-table .col-act .zpdf-btn { margin-left: 6px; }
|
||||||
|
|
||||||
|
.zpdf-tag {
|
||||||
|
display: inline-block; padding: 2px 10px; border-radius: 10px;
|
||||||
|
font-size: 0.78em; background: var(--surface-2); color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.zpdf-tag--ok { background: var(--success-weak); color: var(--success); }
|
||||||
|
.zpdf-tag--err { background: var(--danger-weak); color: var(--danger); }
|
||||||
|
.zpdf-tag--warn { background: var(--warn-weak); color: var(--warn); }
|
||||||
|
|
||||||
|
.zpdf-prog { display: inline-flex; align-items: center; gap: 8px; font-size: 0.82em; }
|
||||||
|
.zpdf-prog__label { color: var(--text-secondary); white-space: nowrap; }
|
||||||
|
|
||||||
|
.zpdf-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 18px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong); background: var(--surface);
|
||||||
|
color: var(--text-primary); cursor: pointer; font-size: 0.92em;
|
||||||
|
text-decoration: none; transition: background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
.zpdf-btn:hover { background: var(--surface-2); }
|
||||||
|
.zpdf-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.zpdf-btn--primary:hover { background: var(--accent-hover); }
|
||||||
|
.zpdf-btn--danger { color: var(--danger); border-color: var(--danger-weak); }
|
||||||
|
.zpdf-btn--danger:hover { background: var(--danger-weak); }
|
||||||
|
.zpdf-btn--sm { padding: 5px 12px; font-size: 0.85em; }
|
||||||
|
|
||||||
|
.zpdf-bar {
|
||||||
|
height: 8px; background: var(--surface-2); border-radius: 6px; overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.zpdf-bar__fill {
|
||||||
|
height: 100%; width: 0; background: var(--accent); border-radius: 6px;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
.zpdf-bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.zpdf-table th, .zpdf-table td { padding: 8px 6px; }
|
||||||
|
.zpdf-table .col-time { font-size: 0.78em; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
131
src/components/Uploader.vue
Normal file
131
src/components/Uploader.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useLocale } from '../composables/useLocale.js'
|
||||||
|
|
||||||
|
const emit = defineEmits(['upload'])
|
||||||
|
const { t } = useLocale()
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const isDrag = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadName = ref('')
|
||||||
|
const uploadPercent = ref(0)
|
||||||
|
const errMsg = ref('')
|
||||||
|
|
||||||
|
function pick() {
|
||||||
|
fileInput.value && fileInput.value.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
isDrag.value = true
|
||||||
|
}
|
||||||
|
function onDragLeave() {
|
||||||
|
isDrag.value = false
|
||||||
|
}
|
||||||
|
function onDrop(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
isDrag.value = false
|
||||||
|
if (e.dataTransfer.files.length) handle(e.dataTransfer.files[0])
|
||||||
|
}
|
||||||
|
function onChange(e) {
|
||||||
|
if (e.target.files.length) handle(e.target.files[0])
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(file) {
|
||||||
|
errMsg.value = ''
|
||||||
|
uploading.value = true
|
||||||
|
uploadName.value = file.name
|
||||||
|
uploadPercent.value = 0
|
||||||
|
try {
|
||||||
|
await emit('upload', file, (p) => {
|
||||||
|
uploadPercent.value = p
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
errMsg.value = e.message || String(e)
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
uploadPercent.value = 0
|
||||||
|
uploadName.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="zpdf-uploader">
|
||||||
|
<div
|
||||||
|
class="zpdf-dropzone"
|
||||||
|
:class="{ 'is-drag': isDrag }"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
|
<div class="zpdf-dropzone__hint">{{ t('upload.hint') }}</div>
|
||||||
|
<button type="button" class="zpdf-btn zpdf-btn--primary" @click="pick">
|
||||||
|
{{ t('upload.pick') }}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".epub,application/epub+zip"
|
||||||
|
hidden
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
<div class="zpdf-dropzone__limit">{{ t('upload.limit') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="zpdf-upload-progress">
|
||||||
|
<div class="zpdf-upload-progress__name">{{ uploadName }}</div>
|
||||||
|
<div class="zpdf-bar"><div class="zpdf-bar__fill" :style="{ width: uploadPercent + '%' }"></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errMsg" class="zpdf-err">{{ errMsg }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.zpdf-uploader { margin-bottom: 24px; }
|
||||||
|
.zpdf-dropzone {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 10px; padding: 40px 20px; min-height: 160px;
|
||||||
|
border: 2px dashed var(--border-strong); border-radius: var(--radius);
|
||||||
|
background: var(--surface); text-align: center;
|
||||||
|
transition: border-color var(--transition), background var(--transition);
|
||||||
|
}
|
||||||
|
.zpdf-dropzone.is-drag { border-color: var(--accent); background: var(--accent-weak); }
|
||||||
|
.zpdf-dropzone__hint { color: var(--text-secondary); font-size: 0.95em; }
|
||||||
|
.zpdf-dropzone__limit { color: var(--text-tertiary); font-size: 0.8em; }
|
||||||
|
|
||||||
|
.zpdf-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 18px; border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border-strong); background: var(--surface);
|
||||||
|
color: var(--text-primary); cursor: pointer; font-size: 0.92em;
|
||||||
|
transition: background var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
.zpdf-btn:hover { background: var(--surface-2); }
|
||||||
|
.zpdf-btn--primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.zpdf-btn--primary:hover { background: var(--accent-hover); }
|
||||||
|
.zpdf-btn--danger { color: var(--danger); border-color: var(--danger-weak); }
|
||||||
|
.zpdf-btn--danger:hover { background: var(--danger-weak); }
|
||||||
|
.zpdf-btn--sm { padding: 5px 12px; font-size: 0.85em; }
|
||||||
|
|
||||||
|
.zpdf-upload-progress { margin-top: 12px; }
|
||||||
|
.zpdf-upload-progress__name { font-size: 0.88em; margin-bottom: 6px; word-break: break-all; }
|
||||||
|
|
||||||
|
.zpdf-bar {
|
||||||
|
height: 8px; background: var(--surface-2); border-radius: 6px; overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.zpdf-bar__fill {
|
||||||
|
height: 100%; width: 0; background: var(--accent); border-radius: 6px;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
.zpdf-bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; }
|
||||||
|
|
||||||
|
.zpdf-err {
|
||||||
|
margin-top: 10px; padding: 8px 12px; border-radius: var(--radius-sm);
|
||||||
|
background: var(--danger-weak); color: var(--danger); font-size: 0.88em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
14
src/composables/useLocale.js
Normal file
14
src/composables/useLocale.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import i18n from '../i18n/index.js'
|
||||||
|
|
||||||
|
// ★ 子项目自有的语言辅助 composable。
|
||||||
|
// 关键:直接读模块级 i18n 实例的 global composer,而非 useI18n() 注入。
|
||||||
|
// 原因:useI18n({ useScope: 'global' }) 解析的是「当前运行 app 已安装的全局 i18n」,
|
||||||
|
// 嵌入 mainPage 时那是父项目的实例(不含本子项目文案)-> 文案回退成 key。
|
||||||
|
// 改为直接引用本子项目实例的 .global,无论是否 app.use() 都拿到本子项目的 t。
|
||||||
|
export function useLocale() {
|
||||||
|
const { t, locale } = i18n.global
|
||||||
|
const isZh = computed(() => locale.value === 'zh-CN')
|
||||||
|
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
|
||||||
|
return { t, locale, isZh, pick }
|
||||||
|
}
|
||||||
174
src/composables/usePdfApi.js
Normal file
174
src/composables/usePdfApi.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// ★ PDF 转换 API 客户端 + 进度轮询状态机(与 DOM 解耦,便于单元测试)。
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - upload(file): XMLHttpRequest 上传,报告上传进度
|
||||||
|
// - load(): 拉取当前用户任务列表
|
||||||
|
// - pollPending(items, onUpdate, onDone): 轮询未完成任务直至终态
|
||||||
|
// - remove(id): 用户软删
|
||||||
|
// - downloadUrl(id): 产物下载 URL
|
||||||
|
//
|
||||||
|
// 同源调用 zTools2 的 /api/pdf/*;cookie(zk_pdf)由浏览器自动管理,无需手动处理。
|
||||||
|
// fetch 默认 same-origin,带上同源 cookie。
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 1500
|
||||||
|
|
||||||
|
/** 终态判定:done/failed 不再轮询。 */
|
||||||
|
export function isTerminal(job) {
|
||||||
|
return !!job && (job.status === 'done' || job.status === 'failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化字节为人类可读(供组件与测试复用)。 */
|
||||||
|
export function fmtBytes(n) {
|
||||||
|
const x = Number(n)
|
||||||
|
if (n == null || !isFinite(x)) return '-'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let i = 0
|
||||||
|
let v = x
|
||||||
|
while (Math.abs(v) >= 1000 && i < units.length - 1) {
|
||||||
|
v /= 1000
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i === 0 ? `${Math.round(v)} ${units[i]}` : `${v.toFixed(1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传前客户端校验:扩展名 + 大小。返回 null 表示通过,否则返回错误 i18n key。 */
|
||||||
|
export function validateFile(file, maxBytes = 250 * 1024 * 1024) {
|
||||||
|
const name = (file && file.name) || ''
|
||||||
|
if (!name.toLowerCase().endsWith('.epub')) return 'upload.requireEpub'
|
||||||
|
if (file.size > maxBytes) return 'upload.oversize'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 PdfApi 实例。fetcher 注入便于测试(默认用全局 fetch)。
|
||||||
|
* @param {{ fetch?: typeof fetch }} [opts]
|
||||||
|
*/
|
||||||
|
export function createPdfApi(opts = {}) {
|
||||||
|
const fetcher = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null)
|
||||||
|
async function load() {
|
||||||
|
if (!fetcher) throw new Error('fetch unavailable')
|
||||||
|
const res = await fetcher('/api/pdf/jobs', { credentials: 'same-origin' })
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
const body = await res.json()
|
||||||
|
return { total: body.total || 0, items: body.items || [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJob(id) {
|
||||||
|
if (!fetcher) throw new Error('fetch unavailable')
|
||||||
|
const res = await fetcher(`/api/pdf/jobs/${id}`, { credentials: 'same-origin' })
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
if (!fetcher) throw new Error('fetch unavailable')
|
||||||
|
const res = await fetcher(`/api/pdf/jobs/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadUrl(id) {
|
||||||
|
return `/api/pdf/jobs/${id}/download`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询未完成任务直至全部终态。每次拉取后调 onUpdate(freshItems),全部终态后调 onDone()。
|
||||||
|
* 返回 stop 函数,可提前停止(如组件卸载)。
|
||||||
|
* @param {Array} items 任务列表快照
|
||||||
|
* @param {(freshItems: Array) => void} onUpdate
|
||||||
|
* @param {() => void} [onDone]
|
||||||
|
*/
|
||||||
|
function pollPending(items, onUpdate, onDone) {
|
||||||
|
const pendingIds = items.filter((j) => !isTerminal(j)).map((j) => j.id)
|
||||||
|
if (pendingIds.length === 0) {
|
||||||
|
if (onDone) onDone()
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
let stopped = false
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (stopped) return
|
||||||
|
let stillPending = false
|
||||||
|
const fresh = []
|
||||||
|
for (const id of pendingIds) {
|
||||||
|
try {
|
||||||
|
fresh.push(await getJob(id))
|
||||||
|
} catch {
|
||||||
|
// 单个查询失败不中断整体轮询
|
||||||
|
fresh.push(items.find((j) => j.id === id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 合并:用 fresh 更新对应项,保留已完成项原值
|
||||||
|
const byId = new Map(fresh.map((j) => [j.id, j]))
|
||||||
|
const merged = items.map((j) => byId.get(j.id) || j)
|
||||||
|
for (const j of merged) {
|
||||||
|
if (!isTerminal(j)) stillPending = true
|
||||||
|
}
|
||||||
|
onUpdate(merged)
|
||||||
|
if (!stillPending) {
|
||||||
|
stop()
|
||||||
|
if (onDone) onDone()
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
stopped = true
|
||||||
|
clearInterval(timer)
|
||||||
|
}
|
||||||
|
return stop
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, getJob, remove, downloadUrl, pollPending }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组件用便捷工厂:返回带默认 fetch 的 PdfApi 实例(composable 风格命名)。
|
||||||
|
* 测试用 createPdfApi({ fetch }) 注入 mock。
|
||||||
|
*/
|
||||||
|
export function usePdfApi() {
|
||||||
|
return createPdfApi()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传文件(XMLHttpRequest 以支持上传进度回调)。返回 Promise<{job}>。
|
||||||
|
* @param {File} file
|
||||||
|
* @param {(percent: number) => void} [onProgress] 上传百分比 0-100
|
||||||
|
* @param {{ xhr?: typeof XMLHttpRequest }} [opts] 注入便于测试
|
||||||
|
*/
|
||||||
|
export function uploadFile(file, onProgress, opts = {}) {
|
||||||
|
const Xhr = opts.xhr || (typeof XMLHttpRequest !== 'undefined' ? XMLHttpRequest : null)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!Xhr) {
|
||||||
|
reject(new Error('XMLHttpRequest unavailable'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const xhr = new Xhr()
|
||||||
|
xhr.open('POST', '/api/pdf/jobs')
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable && onProgress) {
|
||||||
|
onProgress(Math.round((e.loaded / e.total) * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(xhr.responseText))
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('bad response'))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let detail = ''
|
||||||
|
try {
|
||||||
|
detail = JSON.parse(xhr.responseText).detail || ''
|
||||||
|
} catch {}
|
||||||
|
reject(new Error(detail || 'HTTP ' + xhr.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onerror = () => reject(new Error('network error'))
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
xhr.send(fd)
|
||||||
|
})
|
||||||
|
}
|
||||||
46
src/i18n/index.js
Normal file
46
src/i18n/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN.js'
|
||||||
|
|
||||||
|
// ★ zPDF_package 独立 i18n 实例。中文同步注入(默认语言);英文动态 import() 懒加载。
|
||||||
|
// 嵌入 mainPage 时,父项目把当前语言经 :locale prop 传入 App.vue,watch 同步到本实例,
|
||||||
|
// 从而跟随父项目语言开关切换(不共享父的 messages,仅同步 locale 值,文案来自本子项目自有 locale)。
|
||||||
|
// 注:组件内不用 useI18n() 注入(会解析到宿主实例),改用 ./composables/useLocale.js 直接读本实例。
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadedLocales = new Set(['zh-CN'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 懒加载并切换到目标语言。首次切换某语言时动态 import 其语言包并 mergeLocaleMessage。
|
||||||
|
* @param {string} locale
|
||||||
|
*/
|
||||||
|
export async function loadLocaleAsync(locale) {
|
||||||
|
if (i18n.global.locale.value === locale) return
|
||||||
|
if (loadedLocales.has(locale)) {
|
||||||
|
i18n.global.locale.value = locale
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages
|
||||||
|
if (locale === 'en') {
|
||||||
|
messages = (await import('./locales/en.js')).default
|
||||||
|
} else {
|
||||||
|
i18n.global.locale.value = 'zh-CN'
|
||||||
|
document.documentElement.setAttribute('lang', 'zh-CN')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.global.mergeLocaleMessage(locale, messages)
|
||||||
|
loadedLocales.add(locale)
|
||||||
|
i18n.global.locale.value = locale
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n
|
||||||
41
src/i18n/locales/en.js
Normal file
41
src/i18n/locales/en.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// 英文语言包 -- 通过动态 import() 加载,被打成独立 chunk,
|
||||||
|
// 只看中文的访客不会下载此文件。
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
title: 'PDF Converter'
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
hint: 'Drop an .epub file, or',
|
||||||
|
pick: 'click to choose',
|
||||||
|
limit: 'Only epub, up to 250MB',
|
||||||
|
requireEpub: 'Only epub files are supported',
|
||||||
|
oversize: 'File exceeds the 250MB limit',
|
||||||
|
uploading: 'Uploading…',
|
||||||
|
submitted: 'Submitted, converting…',
|
||||||
|
failed: 'Upload failed',
|
||||||
|
networkError: 'Network error'
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
count: '{total} task(s)',
|
||||||
|
empty: 'No tasks yet. Upload an epub to start.',
|
||||||
|
loading: 'Loading…',
|
||||||
|
loadFailed: 'Load failed: {msg}'
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
name: 'Filename',
|
||||||
|
size: 'Size',
|
||||||
|
status: 'Status',
|
||||||
|
created: 'Created',
|
||||||
|
actions: 'Actions',
|
||||||
|
statusDone: 'Done',
|
||||||
|
statusFailed: 'Failed',
|
||||||
|
statusConverting: 'Converting',
|
||||||
|
statusPending: 'Queued',
|
||||||
|
download: 'Download',
|
||||||
|
delete: 'Delete',
|
||||||
|
deleteConfirm: 'Delete "{name}"?\nIt will no longer be shown to you (admin can still see it; only admin hard-delete truly removes it).',
|
||||||
|
deleted: 'Deleted',
|
||||||
|
deleteFailed: 'Delete failed: {msg}'
|
||||||
|
},
|
||||||
|
refresh: 'Refresh'
|
||||||
|
}
|
||||||
40
src/i18n/locales/zh-CN.js
Normal file
40
src/i18n/locales/zh-CN.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// 中文语言包(默认语言,同步注入主 chunk)
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
title: 'PDF 转换'
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
hint: '拖入 .epub 文件,或',
|
||||||
|
pick: '点击选择文件',
|
||||||
|
limit: '仅支持 epub,最大 250MB',
|
||||||
|
requireEpub: '仅支持 epub 文件',
|
||||||
|
oversize: '文件超过 250MB 上限',
|
||||||
|
uploading: '上传中…',
|
||||||
|
submitted: '已提交,转换中…',
|
||||||
|
failed: '上传失败',
|
||||||
|
networkError: '网络错误'
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
count: '共 {total} 个任务',
|
||||||
|
empty: '还没有任务。上传一个 epub 试试吧。',
|
||||||
|
loading: '加载中…',
|
||||||
|
loadFailed: '加载失败:{msg}'
|
||||||
|
},
|
||||||
|
job: {
|
||||||
|
name: '文件名',
|
||||||
|
size: '大小',
|
||||||
|
status: '状态',
|
||||||
|
created: '创建时间',
|
||||||
|
actions: '操作',
|
||||||
|
statusDone: '完成',
|
||||||
|
statusFailed: '失败',
|
||||||
|
statusConverting: '转换中',
|
||||||
|
statusPending: '排队中',
|
||||||
|
download: '下载',
|
||||||
|
delete: '删除',
|
||||||
|
deleteConfirm: '确定删除「{name}」?\n删除后将不再显示(管理员仍可见,需管理员彻底删除才会清除)。',
|
||||||
|
deleted: '已删除',
|
||||||
|
deleteFailed: '删除失败:{msg}'
|
||||||
|
},
|
||||||
|
refresh: '刷新'
|
||||||
|
}
|
||||||
6
src/main.js
Normal file
6
src/main.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import i18n from './i18n/index.js'
|
||||||
|
import './styles/global.css'
|
||||||
|
|
||||||
|
createApp(App).use(i18n).mount('#app')
|
||||||
36
src/styles/global.css
Normal file
36
src/styles/global.css
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/* 全局重置 + 设计令牌 -- 仅独立 app(main.js)加载。
|
||||||
|
作为组件被嵌入时由宿主(mainPage)提供 :root 令牌,本文件的重置仍安全(与宿主一致)。
|
||||||
|
令牌值与 mainPage src/styles/variables.css 对齐,保证独立与嵌入视觉一致。 */
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--bg, #ffffff);
|
||||||
|
color: var(--text-primary, #1f2329);
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--surface: #f7f8fa;
|
||||||
|
--surface-2: #f0f2f5;
|
||||||
|
--text-primary: #1f2329;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--text-tertiary: #9ca3af;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-weak: #eef2ff;
|
||||||
|
--accent-hover: #4338ca;
|
||||||
|
--border: #eceef1;
|
||||||
|
--border-strong: #e0e2e6;
|
||||||
|
--success: #2e7d32;
|
||||||
|
--success-weak: #e8f5e9;
|
||||||
|
--danger: #c62828;
|
||||||
|
--danger-weak: #fbe9e7;
|
||||||
|
--warn: #e65100;
|
||||||
|
--warn-weak: #fff3e0;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||||
|
--transition: 0.18s ease;
|
||||||
|
}
|
||||||
193
test/usePdfApi.test.js
Normal file
193
test/usePdfApi.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
12
vite.config.js
Normal file
12
vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// 便携式构建:base './' 使产物可被任意路径托管(含 zTools2 /static 与 mainPage iframe)。
|
||||||
|
// preview 开放 f.zikai.wang 便于线上预览校验。
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: './',
|
||||||
|
preview: {
|
||||||
|
allowedHosts: ['f.zikai.wang'],
|
||||||
|
},
|
||||||
|
})
|
||||||
9
vitest.config.js
Normal file
9
vitest.config.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user