diff --git a/docs/specs/manual-management/implementation.md b/docs/specs/manual-management/implementation.md index 4462804..59b617a 100644 --- a/docs/specs/manual-management/implementation.md +++ b/docs/specs/manual-management/implementation.md @@ -22,7 +22,7 @@ | --- | --- | --- | | 后端 | 三表、多分类、字面名称检索、草稿/发布、混合资料、封面、幂等及 revision | Go 构建、完整主测试、DAL 和跨实例配额重复测试通过 | | 权限与文件 | 精确 session 事务复核、应用 scope、各内容入口共用权限、格式/尺寸/配额、私有存储 | HTTP 权限矩阵、撤销/重复请求/删除竞态、EXIF/PDF 实际处理通过;故障注入边界见验证记录 | -| 前端 | 列表/编辑/阅读、移动布局、多次选图队列、局部重试、分类分页、保护路由 | Node 92/92、说明书浏览器 8/8、账号浏览器 6/6、生产构建通过;系统相册待实机验收 | +| 前端 | 列表/编辑/阅读、移动布局、多次选图队列、上传及排序缩略图、内嵌 PDF 阅读、局部重试、分类分页、保护路由 | Node 92/92、说明书浏览器 9/9、账号浏览器 6/6、生产构建通过;系统相册待实机验收 | | Skill | 9 个说明书命令、批量输入、dry-run、写后回读、逐项稳定 ID 恢复 | 新旧 Python/HTTPS 33/33 与独立前向验证通过 | | 集成与审查 | 导航/scopes、CI、双入口代理、部署及回滚说明、完整产物校验 | 独立静态审查无未处理 P1/P2;候选网关实际 Nginx 校验通过 | | 发布 | 提交并推送、创建 PR、GitHub CI、按精确 SHA 更新 pi 与本地稳定 skill | 以 PR 和发布记录中的最终提交、CI 与现场验收为准;PR 保持打开由用户合并 | @@ -34,3 +34,9 @@ 验证结果单独记录于 `verification.md`,必须区分已运行、通过、失败和未覆盖。禁止把 Go 编译替代权限集成测试、把静态源码匹配替代浏览器行为、把 `/ping` 替代新功能验证。 部署记录只包含版本号、哈希、路径和状态,不包含私有域名凭据、DSN、AK/SK 或 Cookie。原仓库未跟踪的 `agents.md` 不纳入提交。 + +## 阅读与排序交互 + +编辑器对待上传图片使用页面生命周期内的本地缩略图,对 MIME 为 `application/pdf` 的待上传文件使用惰性首页预览;只带 `.pdf` 文件名但 MIME 不符的文件显示类型占位,不进入同源 iframe。移除资料、上传完成或离开页面时释放本地 object URL。已保存图片和 PDF 直接使用受权限保护的服务端缩略图,排序只移动资料对象,缩略图始终随对应资料移动。 + +详情页使用现有 `content_url` 嵌入浏览器原生 PDF 阅读器,并保留新窗口与下载入口作为兼容降级。资料序号、类型和原始文件名调整为辅助层级;自定义标题继续作为普通标题展示。该变化不增加依赖,也不修改 API、数据表、权限模型或文件响应规则。 diff --git a/docs/specs/manual-management/interaction-optimization.md b/docs/specs/manual-management/interaction-optimization.md new file mode 100644 index 0000000..ebe5e15 --- /dev/null +++ b/docs/specs/manual-management/interaction-optimization.md @@ -0,0 +1,38 @@ +# 说明书阅读与排序交互优化 + +日期:2026-09-19。设计基线:`master` `1db4129`。 + +## 目标 + +降低用户上传、排序和阅读说明书时的识别成本。调整只涉及说明书前端页面,沿用现有资料 API、权限检查、原件与缩略图接口,不改变存储、数据库或访问控制。 + +| 场景 | 调整后行为 | +| --- | --- | +| 上传队列 | 图片显示本地缩略图;PDF 显示惰性加载的首页内嵌预览;文本和网址保留清晰的类型占位 | +| 已保存资料排序 | 图片和 PDF 使用服务端缩略图;缩略图不可用时显示类型占位,不影响排序与删除 | +| 说明书详情 | PDF 在页面内使用浏览器原生阅读器快速浏览,同时保留新窗口打开和下载入口 | +| 资料标题区域 | 序号、类型和原始文件名使用弱化的小号文字,图片或 PDF 内容成为卡片主体 | + +## 方案选择 + +采用浏览器原生 PDF 阅读能力。它能直接复用现有支持登录校验和 Range 请求的 `content_url`,不增加依赖,也不增加服务端接口。待上传文件使用短生命周期的 `blob:` URL:图片通过 `img` 显示,PDF 通过禁用交互的小型内嵌阅读区显示首页;移除条目、上传完成或离开页面时释放 URL。 + +不引入 PDF.js。PDF.js 能提供统一工具栏和分页体验,但会增加依赖、构建体积与后续安全升级成本,当前“快速浏览”无需承担这些成本。也不新增服务端临时预览接口,避免在正式创建说明书前引入临时文件生命周期和权限状态。 + +## 页面结构 + +编辑页的待上传资料卡片和已保存资料卡片都采用“缩略图 + 信息 + 操作”的结构。桌面端缩略图位于左侧,移动端位于上方;顺序按钮仍使用现有数组移动逻辑,预览只跟随资料对象移动,不参与排序状态。 + +详情页取消高对比度圆形序号和大号文件名。每项顶部显示低对比度的“资料 N · 类型”,自定义标题保留为普通标题,原始文件名降为辅助说明。PDF 阅读区在桌面端提供接近视口高度的浏览区域,移动端压缩高度;阅读器不可用时,用户仍可通过原有链接打开或下载原件。 + +## 安全与兼容性 + +- PDF 和缩略图继续从原有受保护接口读取,不绕过 owner、authenticated、public 权限。 +- 内嵌阅读器不执行站外内容,不改变 URL 资料的 `noopener noreferrer` 约束。 +- 本地预览 URL 只存在于当前页面生命周期,不写入存储、日志或 API。 +- Safari 或移动浏览器不能内嵌 PDF 时,保留的新窗口与下载入口作为可见降级路径。 +- 缩略图加载失败只影响预览,不阻止上传、排序、保存或删除。 + +## 验收 + +浏览器测试覆盖待上传图片/PDF 预览、已保存资料缩略图、排序后预览与资料对应关系、详情 PDF 内嵌阅读器、弱化元信息、下载链接以及 320 px 宽度下无横向溢出。交付前运行前端单元测试、说明书浏览器测试、lint 和生产构建,并在 pi 上验证真实 PDF、图片、登录权限和既有页面回归。 diff --git a/docs/specs/manual-management/verification.md b/docs/specs/manual-management/verification.md index da3cfab..bd0fe7e 100644 --- a/docs/specs/manual-management/verification.md +++ b/docs/specs/manual-management/verification.md @@ -13,7 +13,7 @@ | 独立文件边界 | pi 实际 pdftoppm 与格式解码 | PDF 预览最长边 ≤480、损坏 PNG 拒绝且无最终目录、损坏 PDF 保留原件并标记不可预览、超限文件拒绝均通过 | | 上传预留并发 | 32 路并发、10 轮 | 每轮 1 成功、31 限流;该项与数据库配额测试分别验证 | | 前端行为 | macOS,`npm run test:frontend` | 92/92 通过 | -| 说明书浏览器 | 实际 Vue 页面与 Chrome,`npm run test:manuals-browser` | 8/8 通过,覆盖 320 px、多次选图追加、慢上传期间写互斥、部分失败重试、409、revision 类型及混合资料阅读 | +| 说明书浏览器 | 实际 Vue 页面与 Chrome,`npm run test:manuals-browser` | 9/9 通过,覆盖 320 px、多次选图追加、上传及排序缩略图、object URL 释放、伪 PDF 脚本隔离、生产同款 CSP 下的内嵌 PDF 阅读、慢上传期间写互斥、部分失败重试、409、revision 类型及混合资料阅读 | | 账号浏览器回归 | Chrome,`npm run test:account-center-browser` | 6/6 通过 | | 前端构建与语法 | 变更文件 ESLint;`VUE_APP_API_BASE_URL=/ npm run build` | 通过;保留既有 bundle 大小及 caniuse-lite 警告 | | Python 与 skill | 实际本机合成 HTTPS fixture,`test_manuals_skill`、`test_web_hosting_skill`、`test_home_server_api` | 33/33 通过:新 skill 5 项、旧 skill 15 项、客户端 13 项 | @@ -21,6 +21,8 @@ | 网关候选配置 | 实际 TLS 网关,备份原配置并对候选配置执行 `nginx -t` | 通过;提交前未替换生效配置或 reload | | 文档与差异 | mmdc + Chrome、公开部署模板检查、`git diff --check` | 1 张 Mermaid 成功渲染,模板与格式检查通过 | +本次交互调整先运行新增浏览器用例并观察到 2 项预期失败:编辑器不存在缩略图节点,详情页不存在弱化元信息和 PDF iframe。独立审查随后发现仅凭 `.pdf` 后缀预览会把 HTML MIME 的 blob 放进同源 iframe;新增主动脚本载荷后旧实现为 8/9,通过按 `application/pdf` MIME 决定本地 PDF 预览关闭该链路,同时覆盖无扩展名的合法 PDF。有效 PDF fixture 返回与生产一致的 `Content-Security-Policy: sandbox`,测试确认 Chrome 内置 PDF 阅读器 frame 已加载;另用真实 Chrome 独立截图确认文档页实际可见。最终说明书浏览器测试 9/9 通过;完整前端 Node 测试 92/92 通过;变更文件 ESLint 通过;生产构建成功,只有既有 caniuse-lite 和 bundle 大小提示。全仓 `npm run lint` 仍会在未修改的 `Common.vue`、`AddBook.vue` 和 `ScanCodePage.vue` 报告 4 个基线错误,因此不把全仓 lint 记为通过。 + 完整主测试覆盖 DB 与 legacy/config 会话撤销、重复创建回放、上传中撤销、重复上传时撤销或删除、同说明书条目配额、跨说明书用户字节配额、分类二页游标、真实 pdftoppm PDF 最长边不超过 480 px,以及超过 4 KiB 的 APP1 EXIF 方向处理。测试中的真实权限矩阵同时检查原件、缩略图、HEAD、Range、所有者禁用和草稿/删除状态。 多个 pending 项恢复测试逐项保留原 request ID,断言无重复资料且最终顺序完整;不能把剩余多项重新组合成批次重编号。独立前向验证另外实际执行了 8 次 token exchange 和 16 次 HTTPS 业务调用。 diff --git a/docs/superpowers/plans/2026-09-19-manual-preview-ui.md b/docs/superpowers/plans/2026-09-19-manual-preview-ui.md new file mode 100644 index 0000000..bcca04b --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-manual-preview-ui.md @@ -0,0 +1,121 @@ +# Manual Preview UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add visual previews to manual upload/reorder cards, embed PDF reading in the detail page, and reduce the visual emphasis of item numbers and filenames. + +**Architecture:** Keep the existing manuals API and data model unchanged. `ManualEditor.vue` owns short-lived local `blob:` preview URLs and reuses persisted `thumbnail_url` values; `ManualDetail.vue` uses the authenticated `content_url` in the browser-native PDF viewer and retains explicit open/download fallbacks. + +**Tech Stack:** Vue 3 Options API, Element Plus, native object URLs and PDF iframe support, Node test runner, Playwright Chromium. + +--- + +### Task 1: Specify editor previews and PDF reading behavior + +**Files:** +- Modify: `front_vue/test/manuals-browser.cjs` + +- [x] **Step 1: Add a failing editor browser test** + +Add a test that opens `/manuals/500/edit?as=owner` with saved image and PDF items, selects one local PNG and one local PDF, then asserts: + +```js +assert.equal(await page.locator('.queue-preview-image').count(), 1) +assert.equal(await page.locator('.queue-preview-pdf').count(), 1) +assert.equal(await page.locator('.saved-thumbnail').count(), 2) +await page.locator('.upload-queue-item').filter({hasText: 'local.pdf'}).getByRole('button', {name: '上移'}).click() +assert.match(await page.locator('.upload-queue-item').first().innerText(), /local\.pdf/) +``` + +- [x] **Step 2: Extend the detail browser test and verify RED** + +Assert that the PDF item contains one `iframe.pdf-viewer`, its `src` is the protected `content_url`, the metadata uses `.item-meta`, and the former circular index selector is absent. + +Run: + +```bash +NODE_PATH=/Users/bytedance/go/src/github.com/mcoder2014/home_server/front_vue/node_modules node --test test/manuals-browser.cjs +``` + +Expected: FAIL because the preview and viewer selectors do not exist. + +### Task 2: Implement editor thumbnails with bounded object URL lifetime + +**Files:** +- Modify: `front_vue/src/views/ManualEditor.vue` +- Test: `front_vue/test/manuals-browser.cjs` + +- [x] **Step 1: Render previews without changing queue semantics** + +Add a preview block before each queue item's fields. Local images use an `img`; local PDFs use a lazy, non-interactive iframe. Saved image/PDF items use `thumbnail_url`; missing thumbnails render a type placeholder. Text and URL items keep compact type placeholders. + +- [x] **Step 2: Manage local object URLs** + +After `appendFileItems`, assign an object URL only to accepted image/PDF queue items. Revoke it when an item is removed, after a fully successful upload clears the queue, and in `beforeUnmount`. Moving an item must preserve its URL and identity. + +- [x] **Step 3: Add responsive styling** + +Use a fixed thumbnail column on desktop and a full-width preview above metadata below 640 px. Preview failures must not disable move, remove, upload, cover selection, or delete actions. + +- [x] **Step 4: Run the browser test and verify GREEN** + +Run the Task 1 command. Expected: all manuals browser tests pass. + +### Task 3: Embed PDFs and quiet item metadata + +**Files:** +- Modify: `front_vue/src/views/ManualDetail.vue` +- Test: `front_vue/test/manuals-browser.cjs` + +- [x] **Step 1: Replace the prominent item heading** + +Render `资料 N · 类型` as muted metadata. Render an explicit custom title as a modest heading and show `original_name` as small secondary text, avoiding duplicate title/filename output. + +- [x] **Step 2: Add the native PDF viewer** + +Render: + +```vue + +``` + +Keep the existing new-window and download links directly below it. Preserve the preview-unavailable notice and mobile fallback layout. + +- [x] **Step 3: Run focused tests and verify GREEN** + +Run the Task 1 command. Expected: all manuals browser tests pass, including mobile width and link-safety assertions. + +### Task 4: Verify, document, review, and publish + +**Files:** +- Modify: `docs/specs/manual-management/implementation.md` +- Modify: `docs/specs/manual-management/verification.md` + +- [x] **Step 1: Record final behavior and verification evidence** + +Update the existing implementation and verification documents with the editor thumbnails, embedded PDF reader, metadata hierarchy, exact commands, and observed results. + +- [x] **Step 2: Run the full frontend verification** + +Run from `front_vue` with the main checkout's dependency directory on `NODE_PATH`/`PATH`: + +```bash +npm run test:frontend +npm run test:manuals-browser +npm run lint +npm run build +``` + +Expected: zero failures and a successful production build. + +- [x] **Step 3: Commit and dispatch independent review** + +Commit the implementation, then give the review agent the base SHA, head SHA, requirements, design document, and validation results. Fix every blocking or important finding and rerun the affected checks. + +- [ ] **Step 4: Create and merge the PR** + +Push `feat/cq/manuals_ui`, create a PR describing the visible behavior and tests, wait for GitHub checks, and merge only if the independent review and required checks are clean. + +- [ ] **Step 5: Deploy the merged commit to pi** + +Build and stage the exact merged commit using the existing release-directory process, retain the previous release for rollback, switch `current`, restart services, and verify the embedded PDF, editor previews, permissions, `/ping`, and existing modules before removing the feature worktree. diff --git a/front_vue/src/views/ManualDetail.vue b/front_vue/src/views/ManualDetail.vue index 8f5fc45..8e52b7c 100644 --- a/front_vue/src/views/ManualDetail.vue +++ b/front_vue/src/views/ManualDetail.vue @@ -20,12 +20,19 @@
-
{{ index + 1 }}

{{ item.title || item.original_name || itemKind(item.kind) }}

{{ itemKind(item.kind) }}
+
+

资料 {{ index + 1 }} · {{ itemKind(item.kind) }}

+

{{ item.title }}

+ {{ item.original_name }} +
-
+
+

首页预览不可用,PDF 原件仍可打开或下载。

- 在新窗口打开 PDF - 下载 PDF +
{{ item.text }}
@@ -65,6 +72,10 @@ export default { safeURL(value) { return safeExternalURL(value) }, downloadURL(value) { return `${value}${String(value).includes('?') ? '&' : '?'}download=1` }, itemKind(kind) { return {image: '图片', pdf: 'PDF', text: '文本', url: '网页链接'}[kind] || '资料' }, + pdfViewerTitle(item) { + const label = item?.title || item?.original_name || '说明书' + return `${label} PDF 阅读器` + }, accessText(mode) { return {owner: '仅自己可见', authenticated: '登录用户可见', public: '所有人可见'}[mode] || mode }, }, } @@ -73,3 +84,7 @@ export default { + + diff --git a/front_vue/src/views/ManualEditor.vue b/front_vue/src/views/ManualEditor.vue index 066ba6a..33c8c4d 100644 --- a/front_vue/src/views/ManualEditor.vue +++ b/front_vue/src/views/ManualEditor.vue @@ -46,17 +46,26 @@
-
{{ index + 1 }}. {{ queueKind(item) }}{{ item.file.name }}·{{ formatBytes(item.file.size) }}
{{ queueStatus(item.status) }}
- - - -

这个文件与队列中的名称、大小和修改时间相同,仍已保留。

- {{ item.progress }}% -

{{ item.error }}

-
- 上移 - 下移 - 移除 +
+
+ + + {{ queueKind(item) }} +
+
+
{{ index + 1 }}. {{ queueKind(item) }}{{ item.file.name }} · {{ formatBytes(item.file.size) }}
{{ queueStatus(item.status) }}
+ + + +

这个文件与队列中的名称、大小和修改时间相同,仍已保留。

+ {{ item.progress }}% +

{{ item.error }}

+
+ 上移 + 下移 + 移除 +
+
@@ -71,6 +80,10 @@
封面
+
+ + {{ queueKind(item) }} +
{{ index + 1 }}. {{ item.title || item.original_name || queueKind(item) }}{{ queueKind(item) }}
上移下移删除
@@ -130,6 +143,9 @@ export default { this.fatalError = error.message || '编辑器加载失败' } finally { this.loading = false } }, + beforeUnmount() { + this.releaseQueuePreviews() + }, methods: { requireAccess() { const user = this.$store.state.userInfo @@ -156,7 +172,9 @@ export default { }, appendFiles(event) { if (this.saving || this.uploading) { if (event?.target) event.target.value = ''; return } + const existing = new Set(this.queue.map(item => item.local_id)) const result = appendFileItems(this.queue, event.target.files, this.manual?.items?.length || 0) + result.items.filter(item => !existing.has(item.local_id)).forEach(item => this.createQueuePreview(item)) this.queue = result.items event.target.value = '' if (result.errors.length) this.showNotice(result.errors.join(';'), 'error') @@ -169,12 +187,39 @@ export default { if (result.errors.length) this.showNotice(result.errors.join(';'), 'error') }, moveQueue(index, offset) { if (this.saving || this.uploading) return; this.queue = moveItem(this.queue, index, offset) }, - removeQueue(index) { if (this.saving || this.uploading) return; this.queue.splice(index, 1); this.updateInterruptedMarker() }, + removeQueue(index) { + if (this.saving || this.uploading) return + const [removed] = this.queue.splice(index, 1) + this.releaseQueuePreview(removed) + this.updateInterruptedMarker() + }, moveSaved(index, offset) { if (this.saving || this.uploading) return; this.manual.items = moveItem(this.manual.items, index, offset) }, editableQueueItem(item) { return !this.saving && !this.uploading && ['waiting', 'failed'].includes(item.status) }, editableQueueContent(item) { return !this.saving && !this.uploading && item.status === 'waiting' && !item.attempted }, queueStatus(status) { return {waiting: '等待上传', uploading: '上传中', success: '已成功', failed: '上传失败'}[status] || status }, - queueKind(item) { return {image: '图片', file: item.file?.name?.toLowerCase().endsWith('.txt') ? 'TXT 文本' : item.file?.name?.toLowerCase().endsWith('.pdf') ? 'PDF' : '文件', pdf: 'PDF', text: '文本', url: '网页链接'}[item.kind] || '资料' }, + queueKind(item) { return {image: '图片', file: item.file?.name?.toLowerCase().endsWith('.txt') ? 'TXT 文本' : String(item.file?.type || '').toLowerCase() === 'application/pdf' || item.file?.name?.toLowerCase().endsWith('.pdf') ? 'PDF' : '文件', pdf: 'PDF', text: '文本', url: '网页链接'}[item.kind] || '资料' }, + isQueuePDF(item) { + if (item?.kind === 'pdf') return true + return String(item?.file?.type || '').toLowerCase() === 'application/pdf' + }, + pdfPreviewURL(value) { + if (!value) return '' + return `${value}#page=1&view=FitH&toolbar=0&navpanes=0&scrollbar=0` + }, + createQueuePreview(item) { + if (!item?.file || (item.kind !== 'image' && !this.isQueuePDF(item)) || typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return + try { item.preview_url = URL.createObjectURL(item.file) } + catch { item.preview_url = '' } + }, + releaseQueuePreview(item) { + if (!item?.preview_url || typeof URL === 'undefined' || typeof URL.revokeObjectURL !== 'function') return + URL.revokeObjectURL(item.preview_url) + item.preview_url = '' + }, + releaseQueuePreviews(items = this.queue) { + if (!items?.length) return + items.forEach(item => this.releaseQueuePreview(item)) + }, formatBytes(bytes) { const size = Number(bytes || 0); return size < 1024 * 1024 ? `${(size / 1024).toFixed(1)} KiB` : `${(size / 1024 / 1024).toFixed(1)} MiB` }, showNotice(message, type = 'info') { this.notice = message; this.noticeType = type }, validateBeforeSave() { @@ -258,6 +303,7 @@ export default { const publishing = this.manual.status === 'draft' if (publishing) await this.applyPatch(this.patchPayload(itemIDs, true)) else await this.applyPatch(this.patchPayload(itemIDs, false)) + this.releaseQueuePreviews() this.queue = [] this.clearInterruptedMarkers() this.showNotice(publishing ? '说明书已发布' : '资料已上传,设置与顺序已保存', 'success') @@ -302,6 +348,7 @@ export default { try { await manualsApi.deleteItem(this.manual.id, String(item.id), Number(this.manual.revision)) const latest = await manualsApi.getManual(this.manual.id) + this.queue.filter(queueItem => String(queueItem.server_item?.id || '') === String(item.id)).forEach(queueItem => this.releaseQueuePreview(queueItem)) this.queue = this.queue.filter(queueItem => String(queueItem.server_item?.id || '') !== String(item.id)) this.applyManual(latest) this.form = draft @@ -331,3 +378,7 @@ export default { + + diff --git a/front_vue/test/manuals-browser.cjs b/front_vue/test/manuals-browser.cjs index 3402206..5eab160 100644 --- a/front_vue/test/manuals-browser.cjs +++ b/front_vue/test/manuals-browser.cjs @@ -11,6 +11,26 @@ if (!playwrightPath) { const {chromium} = require(playwrightPath) const root = path.join(__dirname, '../src') let browser, server, origin + +function fixturePDF() { + const stream = 'BT /F1 24 Tf 28 100 Td (Manual preview) Tj ET\n' + const objects = [ + '1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n', + '2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n', + '3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 180] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n', + `4 0 obj\n<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}endstream\nendobj\n`, + '5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n', + ] + let body = '%PDF-1.4\n' + const offsets = [0] + for (const object of objects) { offsets.push(Buffer.byteLength(body)); body += object } + const xref = Buffer.byteLength(body) + body += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n` + for (const offset of offsets.slice(1)) body += `${String(offset).padStart(10, '0')} 00000 n \n` + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` + return Buffer.from(body) +} + const artifacts = { '/vue.js': fs.readFileSync(require.resolve('vue/dist/vue.global.prod.js')), '/element.js': fs.readFileSync(require.resolve('element-plus/dist/index.full.js')), @@ -50,13 +70,20 @@ app.use(ElementPlus);app.component('RouterLink',RouterLink);Object.assign(app.co artifacts['/manuals.js'] = Buffer.from(script) artifacts['/manuals.css'] = Buffer.from(css) artifacts['/content/front.png'] = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') +artifacts['/thumb/saved-image.png'] = artifacts['/content/front.png'] +artifacts['/thumb/saved-pdf.png'] = artifacts['/content/front.png'] +artifacts['/content/guide.pdf'] = fixturePDF() const html = '
' before(async () => { server = http.createServer((req, res) => { const artifact = artifacts[req.url] - const contentType = req.url.endsWith('.css') ? 'text/css' : req.url.endsWith('.png') ? 'image/png' : 'application/javascript' + const contentType = req.url.endsWith('.css') ? 'text/css' : req.url.endsWith('.png') ? 'image/png' : req.url.endsWith('.pdf') ? 'application/pdf' : 'application/javascript' res.setHeader('Content-Type', artifact ? contentType : 'text/html; charset=utf-8') + if (req.url.endsWith('.pdf')) { + res.setHeader('Content-Disposition', 'inline; filename="manual.pdf"') + res.setHeader('Content-Security-Policy', 'sandbox') + } res.end(artifact || html) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -367,10 +394,56 @@ test('删除资料和说明书的真实请求体保持数字 revision', async () } finally { await context.close() } }) +test('编辑器为待上传和已保存文件显示缩略图,排序保持预览对应并释放移除项 URL', async () => { + const savedImage = {id: 'image-1', kind: 'image', title: '正面', original_name: 'front.png', position: 1, thumbnail_url: '/thumb/saved-image.png', preview_status: 'ready'} + const savedPDF = {id: 'pdf-1', kind: 'pdf', title: '完整手册', original_name: 'guide.pdf', position: 2, thumbnail_url: '/thumb/saved-pdf.png', preview_status: 'ready'} + const handler = async ({route, request, url}) => { + if (url.pathname === '/api/manuals/categories') { await route.fulfill({json: {code: 0, data: {items: []}}}); return true } + if (url.pathname === '/api/manuals/500' && request.method() === 'GET') { + await route.fulfill({json: {code: 0, data: baseManual({id: '500', can_edit: true, items: [savedImage, savedPDF], item_count: 2})}}); return true + } + return false + } + const {page, context} = await setup({path: '/manuals/500/edit?as=owner', width: 320, handler}) + try { + await page.evaluate(() => { + window.__revokedManualPreviewURLs = [] + const revoke = URL.revokeObjectURL.bind(URL) + URL.revokeObjectURL = value => { window.__revokedManualPreviewURLs.push(value); revoke(value) } + }) + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + await page.getByLabel('选择图片').setInputFiles({name: 'local.png', mimeType: 'image/png', buffer: png}) + await page.getByLabel('选择 PDF 或 TXT').setInputFiles({name: 'local-manual', mimeType: 'application/pdf', buffer: fixturePDF()}) + await page.getByLabel('选择 PDF 或 TXT').setInputFiles({name: 'attack.pdf', mimeType: 'text/html', buffer: Buffer.from('')}) + + assert.equal(await page.locator('.queue-preview-image').count(), 1) + assert.match(await page.locator('.queue-preview-image').getAttribute('src'), /^blob:/) + const localPDFCard = page.locator('.upload-queue-item').filter({hasText: 'local-manual'}) + assert.equal(await localPDFCard.locator('.queue-preview-pdf').count(), 1) + assert.match(await localPDFCard.locator('.queue-preview-pdf').getAttribute('src'), /^blob:/) + const attackPDFCard = page.locator('.upload-queue-item').filter({hasText: 'attack.pdf'}) + assert.equal(await attackPDFCard.locator('.queue-preview-pdf').count(), 0) + assert.equal(await page.evaluate(() => window.__manualEditorXSS), undefined) + assert.equal(await page.locator('.saved-thumbnail').count(), 2) + const savedPDFCard = page.locator('.saved-item').filter({hasText: '完整手册'}) + await savedPDFCard.getByRole('button', {name: '上移', exact: true}).click() + assert.match(await page.locator('.saved-item').first().locator('.saved-thumbnail').getAttribute('src'), /saved-pdf\.png$/) + + const pdfCard = page.locator('.upload-queue-item').filter({hasText: 'local-manual'}) + const pdfPreview = await pdfCard.locator('.queue-preview-pdf').getAttribute('src') + await pdfCard.getByRole('button', {name: '上移', exact: true}).click() + assert.match(await page.locator('.upload-queue-item').first().innerText(), /local-manual/) + assert.equal(await page.locator('.upload-queue-item').first().locator('.queue-preview-pdf').getAttribute('src'), pdfPreview) + await page.locator('.upload-queue-item').first().getByRole('button', {name: '移除', exact: true}).click() + assert.equal(await page.evaluate(() => window.__revokedManualPreviewURLs.length), 1) + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false) + } finally { await context.close() } +}) + test('匿名详情按顺序安全展示图片、PDF、纯文本和 HTTP(S) 链接', async () => { const manual = baseManual({items: [ {id: '1', kind: 'image', title: '正面', content_url: '/content/front.png', thumbnail_url: '/thumb/front.png', preview_status: 'ready'}, - {id: '2', kind: 'pdf', title: '完整手册', content_url: '/content/guide.pdf', preview_status: 'ready'}, + {id: '2', kind: 'pdf', title: '完整手册', original_name: 'guide.pdf', content_url: '/content/guide.pdf', preview_status: 'ready'}, {id: '3', kind: 'text', title: '保养', text: '\n用清水冲洗'}, {id: '4', kind: 'url', title: '官网支持', url: 'https://example.com/help'}, {id: '5', kind: 'url', title: '无效链接', url: 'javascript:alert(1)'}, @@ -391,6 +464,16 @@ test('匿名详情按顺序安全展示图片、PDF、纯文本和 HTTP(S) 链 assert.match(await external.getAttribute('rel'), /noopener/) assert.match(await external.getAttribute('rel'), /noreferrer/) assert.equal(await page.getByRole('link', {name: '打开无效链接'}).count(), 0) + const pdfCard = page.locator('.manual-item').filter({hasText: '完整手册'}) + assert.equal(await pdfCard.locator('.item-meta').textContent(), '资料 2 · PDF') + assert.equal(await pdfCard.locator('.item-filename').textContent(), 'guide.pdf') + assert.equal(await page.locator('.item-heading > span').count(), 0) + assert.equal(await pdfCard.locator('iframe.pdf-viewer').count(), 1) + assert.match(await pdfCard.locator('iframe.pdf-viewer').getAttribute('src'), /\/content\/guide\.pdf$/) + assert.equal(await pdfCard.locator('iframe.pdf-viewer').getAttribute('title'), '完整手册 PDF 阅读器') + if (!page.frames().some(frame => frame.url().startsWith('chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/'))) { + await page.waitForEvent('framenavigated', {predicate: frame => frame.url().startsWith('chrome-extension://mhjfbmdgcfjbbpaeojofohoefgiehjai/')}) + } assert.equal(await page.getByRole('link', {name: '打开 PDF'}).getAttribute('target'), '_blank') assert.match(await page.getByRole('link', {name: '下载 PDF'}).getAttribute('href'), /download=1/) assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)