Compare commits

..

2 Commits

Author SHA1 Message Date
25bc00ac51 refactor: 清理死代码 + 提前失败 + 错误记录控制台
- downloadUrl 提升为顶层纯函数导出, JobList 不再为此实例化整个 api 对象
- createPdfApi 返回对象移除内部用的 getJob / downloadUrl (高内聚)
- useLocale 移除未用的 isZh/pick 返回值
- 移除 4 个死 i18n key (upload.uploading/submitted/failed/networkError)
- App.vue: import {watch} 提升至顶部; onDelete 补 try/catch + console.error
- usePdfApi: pollPending 单任务失败补 console.warn; uploadFile 响应解析失败补 console.error
- docs: 新增 error-handling.md / integration.md (嵌入细节从 README 移入)
- README: 修正 dev 端口 5173->5175; 补 apache2 部署说明
2026-07-28 11:12:37 +08:00
cfbf4ad2b4 feat: PDF 页签移动端兼容
窄屏(≤768px,与宿主 zMainPage 断点一致)优化:
- JobList 表格转卡片式:文件名为标题,大小/时间降为次要行(带列名前缀),
  状态与操作独占一行,避免横向滚动;表头隐藏
- Uploader 拖拽区缩小(padding 40->24、min-height 160->120),移动端无拖拽语义
- 按钮触控目标加大(padding 5->8、font 0.85->0.9),符合移动端可点击区建议
- 统一断点为 768px(原 JobList 用 640px,与宿主不一致)
2026-07-27 17:37:35 +08:00
11 changed files with 103 additions and 40 deletions

View File

@@ -41,7 +41,7 @@ zPDF_package/
| Node.js | ≥ 18 | | Node.js | ≥ 18 |
| zTools2 | 后端 `/api/pdf/*`同源调用cookie 自动管理) | | zTools2 | 后端 `/api/pdf/*`同源调用cookie 自动管理) |
> 前端纯静态无运行期后端依赖API 由 zTools2 提供。 > 前端纯静态无运行期后端依赖API 由 zTools2 提供。生产部署经 [zMainPage](https://git.zikai.wang/zikai/zMainPage) 作为子模块集成,由其 apache2 静态托管 + `/api` 反代到 zTools2无独立 apache2 配置)。
## 如何使用 ## 如何使用
@@ -51,16 +51,16 @@ git clone https://git.zikai.wang/zikai/zPDF_package.git
cd zPDF_package cd zPDF_package
npm install # 已配 npmmirror 镜像加速 npm install # 已配 npmmirror 镜像加速
npm run dev # 开发服务器 http://localhost:5173需 zTools2 在同源或配置代理 npm run dev # 开发服务器 http://localhost:5175vite.config.js 已配 /api 代理到 zTools2:6867
npm run build # 构建到 dist/ npm run build # 构建到 dist/
npm test # 单元测试20 项) npm test # 单元测试20 项)
``` ```
> 开发时 `/api/pdf/*` 需指向 zTools2。可配 Vite 代理;生产环境由 mainPage 同源反代到 zTools2cookie 第一方生效。 > 开发时 `/api/pdf/*` 由 vite 代理到本地 zTools2127.0.0.1:6867;生产环境由 mainPage 同源反代到 zTools2cookie 第一方生效。
## 嵌入 mainPage ## 嵌入 mainPage
作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的 git submodule`third_party/zPDF_package`mainPage 在构建期直接 `import` 本项目的 `src/App.vue` 作为组件挂载(非 iframe与 [timeTableFix](https://git.zikai.wang/zikai/timeTableFix) 同范式mainPage 侧 `src/modules/pdf/Pdf.vue``PageShell` 并透传 `:locale`;组件内 `usePdfApi.js` 同源 fetch `/api/pdf/*`httpOnly cookie`zk_pdf`)经反代第一方自动携带,无需 CORS。组件自身保持「无 host 依赖」:不持 `defineOptions({ name })`、不引 mainPage 的 i18n仅靠 `:locale` prop 驱动自有 i18n 实例。详见 mainPage 的 `docs/submodule-zPDF_package.md` 作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的 git submodule,经构建期组件 import 集成(非 iframe。详见 [`docs/integration.md`](./docs/integration.md)
## 技术栈 ## 技术栈
@@ -75,5 +75,6 @@ npm test # 单元测试20 项)
## 了解更多 ## 了解更多
- [嵌入 mainPage构建期组件 import](../zMainPage/docs/submodule-zPDF_package.md) - [嵌入 mainPage构建期组件 import](./docs/integration.md)
- [错误处理与日志约定](./docs/error-handling.md)
- 后端 API 与转换实现见 [zTools2](https://git.zikai.wang/zikai/zTools2) README - 后端 API 与转换实现见 [zTools2](https://git.zikai.wang/zikai/zTools2) README

21
docs/error-handling.md Normal file
View File

@@ -0,0 +1,21 @@
# 错误处理与日志约定
zPDF_package 的错误处理分两层API 客户端 fail-fast抛错UI 层捕获后既写控制台又给用户反馈。
## API 客户端src/composables/usePdfApi.js
- `load` / `getJob` / `remove`fetch 不可用抛 `fetch unavailable`;非 2xx 抛 `HTTP <status>`
- `uploadFile`XHR 不可用抛 `XMLHttpRequest unavailable`;网络错误 reject `network error`;响应非 200 时解析 `detail` 字段,无则 `HTTP <status>`
- `pollPending`:单个任务查询失败不中断整体轮询,但 `console.warn` 记录(避免持久故障完全不可见)。
- `downloadUrl`:纯函数,无副作用。
## UI 层src/App.vue, src/components/
- `refresh()` 失败:捕获写入 `loadError`JobList 显示「加载失败」。
- `onDelete()` 失败:`console.error` 记录 + 写入 `loadError` 提示用户。
- `Uploader.handle()` 失败:捕获写入 `errMsg` 显示。
- `validateFile` 返回 i18n key非抛错`onUpload` 翻译后抛 `Error(t(key))` 交给 Uploader 显示。
## 控制台前缀
所有 `[zPDF]` 前缀的日志来自本子项目,便于嵌入宿主时与宿主日志区分。

19
docs/integration.md Normal file
View File

@@ -0,0 +1,19 @@
# 嵌入 mainPage
zPDF_package 作为 [mainPage](https://git.zikai.wang/zikai/zMainPage) 的 git submodule`third_party/zPDF_package`),经构建期组件 import 集成(非 iframe与 timeTableFix 同范式)。
## 集成方式
- mainPage 侧 `src/modules/pdf/Pdf.vue``PageShell` 并透传 `:locale`
- 构建期直接 `import` 本项目 `src/App.vue`,共享 mainPage 的构建与 KeepAlive 缓存。
- 组件内 `usePdfApi.js` 同源 fetch `/api/pdf/*`httpOnly cookie`zk_pdf`)经反代第一方自动携带,无需 CORS。
## 便携式设计(无 host 依赖)
组件自身保持「无 host 依赖」:
- 不持 `defineOptions({ name })`(避免与宿主组件名冲突)。
- 不引 mainPage 的 i18n仅靠 `:locale` prop 驱动自有 i18n 实例。
- `useLocale` 直接读本子项目模块级 `i18n.global`,而非 `useI18n()`(后者嵌入时会解析到宿主实例,文案回退成 key
详见 mainPage 的 `docs/submodule-zPDF_package.md`

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onUnmounted } from 'vue' import { ref, watch, onUnmounted } from 'vue'
import { usePdfApi, validateFile, uploadFile } from './composables/usePdfApi.js' import { usePdfApi, validateFile, uploadFile } from './composables/usePdfApi.js'
import { useLocale } from './composables/useLocale.js' import { useLocale } from './composables/useLocale.js'
import { loadLocaleAsync } from './i18n/index.js' import { loadLocaleAsync } from './i18n/index.js'
@@ -14,7 +14,6 @@ const { t } = useLocale()
const api = usePdfApi() const api = usePdfApi()
// 跟随父项目语言开关切换本子项目 i18n 实例的 locale // 跟随父项目语言开关切换本子项目 i18n 实例的 locale
import { watch } from 'vue'
watch( watch(
() => props.locale, () => props.locale,
(val) => { (val) => {
@@ -67,8 +66,13 @@ async function onUpload(file, onProgress) {
} }
async function onDelete(job) { async function onDelete(job) {
try {
await api.remove(job.id) await api.remove(job.id)
await refresh() await refresh()
} catch (e) {
console.error('[zPDF] 删除任务失败', e)
loadError.value = String(e.message || e)
}
} }
onUnmounted(() => { onUnmounted(() => {

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { useLocale } from '../composables/useLocale.js' import { useLocale } from '../composables/useLocale.js'
import { usePdfApi, fmtBytes } from '../composables/usePdfApi.js' import { downloadUrl, fmtBytes } from '../composables/usePdfApi.js'
const props = defineProps({ const props = defineProps({
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
@@ -11,7 +11,6 @@ const props = defineProps({
const emit = defineEmits(['refresh', 'delete']) const emit = defineEmits(['refresh', 'delete'])
const { t } = useLocale() const { t } = useLocale()
const api = usePdfApi()
function statusText(job) { function statusText(job) {
if (job.status === 'done') return t('job.statusDone') if (job.status === 'done') return t('job.statusDone')
@@ -66,7 +65,7 @@ function onDelete(job) {
<tbody> <tbody>
<tr v-for="j in items" :key="j.id"> <tr v-for="j in items" :key="j.id">
<td class="col-name">{{ j.source_filename }}</td> <td class="col-name">{{ j.source_filename }}</td>
<td class="col-size zpdf-mono">{{ fmtBytes(j.source_size) }}</td> <td class="col-size zpdf-mono" :data-label="t('job.size')">{{ fmtBytes(j.source_size) }}</td>
<td class="col-status"> <td class="col-status">
<span v-if="j.status === 'done' || j.status === 'failed'" :class="statusClass(j)"> <span v-if="j.status === 'done' || j.status === 'failed'" :class="statusClass(j)">
{{ statusText(j) }} {{ statusText(j) }}
@@ -78,12 +77,12 @@ function onDelete(job) {
</span> </span>
</span> </span>
</td> </td>
<td class="col-time zpdf-muted">{{ fmtTime(j.created_at) }}</td> <td class="col-time zpdf-muted" :data-label="t('job.created')">{{ fmtTime(j.created_at) }}</td>
<td class="col-act"> <td class="col-act">
<a <a
v-if="j.status === 'done'" v-if="j.status === 'done'"
class="zpdf-btn zpdf-btn--primary zpdf-btn--sm" class="zpdf-btn zpdf-btn--primary zpdf-btn--sm"
:href="api.downloadUrl(j.id)" :href="downloadUrl(j.id)"
download download
>{{ t('job.download') }}</a> >{{ t('job.download') }}</a>
<button class="zpdf-btn zpdf-btn--danger zpdf-btn--sm" @click="onDelete(j)"> <button class="zpdf-btn zpdf-btn--danger zpdf-btn--sm" @click="onDelete(j)">
@@ -157,8 +156,27 @@ function onDelete(job) {
} }
.zpdf-bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; } .zpdf-bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; }
@media (max-width: 640px) { @media (max-width: 768px) {
.zpdf-table th, .zpdf-table td { padding: 8px 6px; } /* 窄屏:表格转卡片式,避免横向滚动。每行展示文件名(主)+ 状态(次)+ 操作 */
.zpdf-table .col-time { font-size: 0.78em; } .zpdf-table, .zpdf-table thead, .zpdf-table tbody, .zpdf-table tr, .zpdf-table th, .zpdf-table td {
display: block; width: 100%;
}
.zpdf-table thead { display: none; } /* 卡片模式无需表头 */
.zpdf-table tr {
padding: 12px; border-bottom: 1px solid var(--border);
}
.zpdf-table tbody tr:hover td { background: transparent; }
.zpdf-table td {
padding: 4px 0; border-bottom: none; text-align: left;
}
/* 文件名为卡片标题,其余为次要行 */
.zpdf-table .col-name { font-size: 1em; margin-bottom: 4px; }
.zpdf-table .col-size::before { content: attr(data-label) " "; color: var(--text-tertiary); font-size: 0.82em; }
.zpdf-table .col-time::before { content: attr(data-label) " "; color: var(--text-tertiary); font-size: 0.82em; }
.zpdf-table .col-status { margin: 6px 0; }
.zpdf-table .col-act { margin-top: 8px; text-align: left; }
.zpdf-table .col-act .zpdf-btn { margin-left: 0; margin-right: 8px; }
/* 移动端触控目标加大 */
.zpdf-btn--sm { padding: 8px 16px; font-size: 0.9em; }
} }
</style> </style>

View File

@@ -128,4 +128,12 @@ async function handle(file) {
margin-top: 10px; padding: 8px 12px; border-radius: var(--radius-sm); margin-top: 10px; padding: 8px 12px; border-radius: var(--radius-sm);
background: var(--danger-weak); color: var(--danger); font-size: 0.88em; background: var(--danger-weak); color: var(--danger); font-size: 0.88em;
} }
@media (max-width: 768px) {
/* 移动端无拖拽语义,缩小拖拽区节省首屏;触控目标加大 */
.zpdf-dropzone { padding: 24px 16px; min-height: 120px; gap: 8px; }
.zpdf-dropzone__hint { font-size: 0.9em; }
.zpdf-btn { padding: 10px 22px; font-size: 1em; }
.zpdf-upload-progress__name { font-size: 0.85em; }
}
</style> </style>

View File

@@ -1,4 +1,3 @@
import { computed } from 'vue'
import i18n from '../i18n/index.js' import i18n from '../i18n/index.js'
// ★ 子项目自有的语言辅助 composable。 // ★ 子项目自有的语言辅助 composable。
@@ -8,7 +7,5 @@ import i18n from '../i18n/index.js'
// 改为直接引用本子项目实例的 .global无论是否 app.use() 都拿到本子项目的 t。 // 改为直接引用本子项目实例的 .global无论是否 app.use() 都拿到本子项目的 t。
export function useLocale() { export function useLocale() {
const { t, locale } = i18n.global const { t, locale } = i18n.global
const isZh = computed(() => locale.value === 'zh-CN') return { t, locale }
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
return { t, locale, isZh, pick }
} }

View File

@@ -17,6 +17,11 @@ export function isTerminal(job) {
return !!job && (job.status === 'done' || job.status === 'failed') return !!job && (job.status === 'done' || job.status === 'failed')
} }
/** 产物下载 URL纯函数供组件直接 import。 */
export function downloadUrl(id) {
return `/api/pdf/jobs/${id}/download`
}
/** 格式化字节为人类可读(供组件与测试复用)。 */ /** 格式化字节为人类可读(供组件与测试复用)。 */
export function fmtBytes(n) { export function fmtBytes(n) {
const x = Number(n) const x = Number(n)
@@ -70,10 +75,6 @@ export function createPdfApi(opts = {}) {
return res.json() return res.json()
} }
function downloadUrl(id) {
return `/api/pdf/jobs/${id}/download`
}
/** /**
* 轮询未完成任务直至全部终态。每次拉取后调 onUpdate(freshItems),全部终态后调 onDone()。 * 轮询未完成任务直至全部终态。每次拉取后调 onUpdate(freshItems),全部终态后调 onDone()。
* 返回 stop 函数,可提前停止(如组件卸载)。 * 返回 stop 函数,可提前停止(如组件卸载)。
@@ -95,8 +96,9 @@ export function createPdfApi(opts = {}) {
for (const id of pendingIds) { for (const id of pendingIds) {
try { try {
fresh.push(await getJob(id)) fresh.push(await getJob(id))
} catch { } catch (e) {
// 单个查询失败不中断整体轮询 // 单个查询失败不中断整体轮询, 但记录便于排查持久故障
console.warn('[zPDF] 轮询单任务失败', id, e)
fresh.push(items.find((j) => j.id === id)) fresh.push(items.find((j) => j.id === id))
} }
} }
@@ -120,7 +122,7 @@ export function createPdfApi(opts = {}) {
return stop return stop
} }
return { load, getJob, remove, downloadUrl, pollPending } return { load, remove, pollPending }
} }
/** /**
@@ -156,6 +158,7 @@ export function uploadFile(file, onProgress, opts = {}) {
try { try {
resolve(JSON.parse(xhr.responseText)) resolve(JSON.parse(xhr.responseText))
} catch (e) { } catch (e) {
console.error('[zPDF] 解析上传响应失败', e)
reject(new Error('bad response')) reject(new Error('bad response'))
} }
} else { } else {

View File

@@ -9,11 +9,7 @@ export default {
pick: 'click to choose', pick: 'click to choose',
limit: 'Only epub, up to 250MB', limit: 'Only epub, up to 250MB',
requireEpub: 'Only epub files are supported', requireEpub: 'Only epub files are supported',
oversize: 'File exceeds the 250MB limit', oversize: 'File exceeds the 250MB limit'
uploading: 'Uploading…',
submitted: 'Submitted, converting…',
failed: 'Upload failed',
networkError: 'Network error'
}, },
list: { list: {
count: '{total} task(s)', count: '{total} task(s)',

View File

@@ -8,11 +8,7 @@ export default {
pick: '点击选择文件', pick: '点击选择文件',
limit: '仅支持 epub最大 250MB', limit: '仅支持 epub最大 250MB',
requireEpub: '仅支持 epub 文件', requireEpub: '仅支持 epub 文件',
oversize: '文件超过 250MB 上限', oversize: '文件超过 250MB 上限'
uploading: '上传中…',
submitted: '已提交,转换中…',
failed: '上传失败',
networkError: '网络错误'
}, },
list: { list: {
count: '共 {total} 个任务', count: '共 {total} 个任务',

View File

@@ -6,6 +6,7 @@ import {
fmtBytes, fmtBytes,
validateFile, validateFile,
uploadFile, uploadFile,
downloadUrl,
} from '../src/composables/usePdfApi.js' } from '../src/composables/usePdfApi.js'
describe('isTerminal', () => { describe('isTerminal', () => {
@@ -94,8 +95,7 @@ describe('createPdfApi', () => {
}) })
it('downloadUrl 生成路径', () => { it('downloadUrl 生成路径', () => {
const api = createPdfApi({ fetch: vi.fn() }) expect(downloadUrl(3)).toBe('/api/pdf/jobs/3/download')
expect(api.downloadUrl(3)).toBe('/api/pdf/jobs/3/download')
}) })
it('pollPending 无未完成任务立即 onDone', () => { it('pollPending 无未完成任务立即 onDone', () => {