From 44db5db8386afc0d81ba46c3b42185dac8fc66d1 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Mon, 13 Jul 2026 18:40:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD(=E6=96=87=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2)=EF=BC=9A=E5=AE=8C=E5=96=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=A2=84=E8=A7=88=E4=B8=8B=E8=BD=BD=E4=B8=8E=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=9E=84=E5=BB=BA=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档 --- .github/workflows/client-quality-gates.yml | 4 +- .../workflows/desktop-release-candidate.yml | 2 +- .../workflows/desktop-windows-internal.yml | 2 +- ..._add_document_version_original_filename.py | 25 + backend/app/models/document_version.py | 1 + backend/app/schemas/document_version.py | 1 + backend/app/services/document_service.py | 33 +- backend/tests/test_document_file_names.py | 22 + docker-compose.dev.yaml | 13 +- docker-compose.yaml | 3 + ...desktop-release-stabilization-checklist.md | 2 +- .../guides/branch-environment-installation.md | 1 + docs/guides/client-release.md | 4 + docs/guides/release-checklist.md | 1 + frontend/Dockerfile | 2 +- frontend/package-lock.json | 281 ++++++ frontend/package.json | 4 + frontend/scripts/verify-desktop-release.mjs | 28 + frontend/src-tauri/capabilities/default.json | 1 + frontend/src/api/documents.ts | 1 + .../src/components/Layout.desktop.test.ts | 15 +- frontend/src/components/PdfViewer.test.ts | 119 +++ frontend/src/components/PdfViewer.vue | 835 ++++++++++++++++++ .../components/attachments/AttachmentList.vue | 134 ++- frontend/src/locales/zh-CN.ts | 1 + frontend/src/runtime/files.test.ts | 50 ++ frontend/src/runtime/files.ts | 69 +- frontend/src/runtime/index.ts | 2 + frontend/src/styles/main.css | 30 +- frontend/src/types/documents.ts | 1 + frontend/src/utils/apiErrorMessage.test.ts | 24 + frontend/src/utils/apiErrorMessage.ts | 51 ++ frontend/src/utils/contentDisposition.test.ts | 14 + frontend/src/utils/contentDisposition.ts | 12 + frontend/src/utils/fileTaskFeedback.test.ts | 10 + frontend/src/utils/fileTaskFeedback.ts | 8 +- .../views/documents/DocumentDetail.test.ts | 23 + .../src/views/documents/DocumentDetail.vue | 69 +- nginx/Dockerfile | 2 +- scripts/uninstall.sh | 4 +- 40 files changed, 1817 insertions(+), 87 deletions(-) create mode 100644 backend/alembic/versions/20260713_02_add_document_version_original_filename.py create mode 100644 backend/tests/test_document_file_names.py create mode 100644 frontend/src/components/PdfViewer.test.ts create mode 100644 frontend/src/components/PdfViewer.vue create mode 100644 frontend/src/runtime/files.test.ts create mode 100644 frontend/src/utils/apiErrorMessage.test.ts create mode 100644 frontend/src/utils/apiErrorMessage.ts create mode 100644 frontend/src/utils/contentDisposition.test.ts create mode 100644 frontend/src/utils/contentDisposition.ts diff --git a/.github/workflows/client-quality-gates.yml b/.github/workflows/client-quality-gates.yml index 804b0dd0..a9c7c74c 100644 --- a/.github/workflows/client-quality-gates.yml +++ b/.github/workflows/client-quality-gates.yml @@ -50,7 +50,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.13" cache: npm cache-dependency-path: frontend/package-lock.json @@ -114,7 +114,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.13" cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml index 2220b6d0..b0237d00 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -42,7 +42,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.13" cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/.github/workflows/desktop-windows-internal.yml b/.github/workflows/desktop-windows-internal.yml index dde18b2b..280e639a 100644 --- a/.github/workflows/desktop-windows-internal.yml +++ b/.github/workflows/desktop-windows-internal.yml @@ -24,7 +24,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22.13" cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/backend/alembic/versions/20260713_02_add_document_version_original_filename.py b/backend/alembic/versions/20260713_02_add_document_version_original_filename.py new file mode 100644 index 00000000..62860cba --- /dev/null +++ b/backend/alembic/versions/20260713_02_add_document_version_original_filename.py @@ -0,0 +1,25 @@ +"""Store original filenames for document versions. + +Revision ID: 20260713_02 +Revises: 20260713_01 +""" + +import sqlalchemy as sa +from alembic import op + + +revision = "20260713_02" +down_revision = "20260713_01" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "document_versions", + sa.Column("original_filename", sa.String(length=255), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("document_versions", "original_filename") diff --git a/backend/app/models/document_version.py b/backend/app/models/document_version.py index 427d11a3..d0ed3c7f 100644 --- a/backend/app/models/document_version.py +++ b/backend/app/models/document_version.py @@ -47,6 +47,7 @@ class DocumentVersion(Base): effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) file_uri: Mapped[str] = mapped_column(String(500), nullable=False) + original_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) file_hash: Mapped[str] = mapped_column(String(128), nullable=False) file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) diff --git a/backend/app/schemas/document_version.py b/backend/app/schemas/document_version.py index 7528e996..74f4d12e 100644 --- a/backend/app/schemas/document_version.py +++ b/backend/app/schemas/document_version.py @@ -36,6 +36,7 @@ class DocumentVersionRead(BaseModel): effective_at: Optional[datetime] = None superseded_at: Optional[datetime] = None file_uri: str + original_filename: Optional[str] = None file_hash: str file_size: int mime_type: Optional[str] = None diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 0d7fd6d9..4316fb11 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -6,6 +6,7 @@ import uuid from datetime import datetime, timezone from pathlib import Path from typing import Iterable +from urllib.parse import quote import aiofiles from fastapi import HTTPException, UploadFile, status @@ -52,6 +53,29 @@ DOCUMENT_ACTION_PERMISSIONS = { } +def _safe_original_filename(value: str | None) -> str: + filename = (value or "").replace("\\", "/").rsplit("/", 1)[-1].strip() + filename = "".join(character for character in filename if ord(character) >= 32 and ord(character) != 127) + return filename[:255] or "document" + + +def _content_disposition(filename: str, disposition: str = "attachment") -> str: + fallback = "".join(character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_" for character in filename) + fallback = fallback or "download" + encoded = quote(filename, safe="") + return f'{disposition}; filename="{fallback}"; filename*=UTF-8\'\'{encoded}' + + +def _legacy_download_filename(version: DocumentVersion, document: Document) -> str: + """Return a readable fallback for rows created before original_filename existed.""" + stored_name = Path(version.file_uri).name + suffix = Path(stored_name).suffix + title = _safe_original_filename(document.title) + if suffix and title.lower().endswith(suffix.lower()): + return title + return f"{title}{suffix}" + + def _audit_detail(before: dict | None, after: dict | None) -> str: payload = {"before": before, "after": after} return json.dumps(payload, ensure_ascii=True) @@ -356,7 +380,8 @@ async def create_version( file_hash = hashlib.sha256(content).hexdigest() dest_dir = UPLOAD_ROOT / str(document_id) dest_dir.mkdir(parents=True, exist_ok=True) - unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" + original_filename = _safe_original_filename(file.filename) + unique_name = f"{uuid.uuid4()}{Path(original_filename).suffix}" dest_path = dest_dir / unique_name async with aiofiles.open(dest_path, "wb") as out_file: await out_file.write(content) @@ -377,6 +402,7 @@ async def create_version( status=DocumentVersionStatus.EFFECTIVE, effective_at=effective_at, file_uri=str(dest_path), + original_filename=original_filename, file_hash=file_hash, file_size=len(content), mime_type=file.content_type, @@ -552,12 +578,11 @@ async def get_version_download_response( file_path = Path(version.file_uri) if not file_path.exists(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在") - filename = file_path.name + filename = version.original_filename or _legacy_download_filename(version, doc) return FileResponse( path=str(file_path), - filename=filename, media_type=version.mime_type or "application/octet-stream", - headers={"Content-Disposition": f'inline; filename="{filename}"'}, + headers={"Content-Disposition": _content_disposition(filename)}, ) diff --git a/backend/tests/test_document_file_names.py b/backend/tests/test_document_file_names.py new file mode 100644 index 00000000..0935b472 --- /dev/null +++ b/backend/tests/test_document_file_names.py @@ -0,0 +1,22 @@ +from types import SimpleNamespace + +from app.services.document_service import _content_disposition, _legacy_download_filename, _safe_original_filename + + +def test_safe_original_filename_strips_paths_and_control_characters(): + assert _safe_original_filename(r"C:\fakepath\研究方案.xlsx") == "研究方案.xlsx" + assert _safe_original_filename("../研究\n方案.xlsx") == "研究方案.xlsx" + + +def test_content_disposition_preserves_unicode_filename(): + header = _content_disposition("研究方案 V1.0.xlsx") + + assert header.startswith('attachment; filename="') + assert "filename*=UTF-8''%E7%A0%94%E7%A9%B6%E6%96%B9%E6%A1%88%20V1.0.xlsx" in header + + +def test_legacy_download_filename_uses_document_title_instead_of_storage_uuid(): + version = SimpleNamespace(file_uri="/uploads/fa6ab7ab-f4f5-4d4a-a052-693f780010fd.pdf") + + assert _legacy_download_filename(version, SimpleNamespace(title="研究方案")) == "研究方案.pdf" + assert _legacy_download_filename(version, SimpleNamespace(title="研究方案.PDF")) == "研究方案.PDF" diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 37fbaf80..cc3f52b2 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -15,7 +15,7 @@ services: service: backend-init frontend-dev: - image: node:20-alpine + image: node:22.13-alpine container_name: ctms_frontend_dev restart: always working_dir: /app @@ -25,16 +25,17 @@ services: - | set -e lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')" + dependency_hash="$$lock_hash:$$(node --version)" marker="node_modules/.ctms-package-lock.sha256" missing_runtime_deps=0 - for dep in @tauri-apps/api @tauri-apps/plugin-dialog @tauri-apps/plugin-fs @tauri-apps/plugin-opener; do + for dep in @tauri-apps/api @tauri-apps/plugin-dialog @tauri-apps/plugin-fs @tauri-apps/plugin-opener pdfjs-dist; do if [ ! -d "node_modules/$$dep" ]; then missing_runtime_deps=1 fi done - if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ] || [ "$$missing_runtime_deps" = "1" ]; then + if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$dependency_hash" ] || [ "$$missing_runtime_deps" = "1" ]; then npm ci - printf '%s' "$$lock_hash" > "$$marker" + printf '%s' "$$dependency_hash" > "$$marker" fi npm run dev -- --host 0.0.0.0 environment: @@ -50,7 +51,7 @@ services: start_period: 5s volumes: - ./frontend:/app - - frontend_node_modules:/app/node_modules + - frontend_node_modules_node22:/app/node_modules depends_on: - backend networks: @@ -75,7 +76,7 @@ services: - ctms_net volumes: - frontend_node_modules: + frontend_node_modules_node22: networks: ctms_net: diff --git a/docker-compose.yaml b/docker-compose.yaml index 38dd88d7..a425eaf8 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -49,6 +49,9 @@ services: depends_on: db: condition: service_healthy + volumes: + # 文档版本和附件必须跨容器重建保留;数据库中的 file_uri 统一指向 /code/app/uploads。 + - ./backend/app/uploads:/code/app/uploads healthcheck: test: - CMD diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md index 96f00e7f..ab9d07c0 100644 --- a/docs/audits/desktop-release-stabilization-checklist.md +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -45,7 +45,7 @@ npm run desktop:build:app - [ ] updater public key 已配置。 - [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。 - [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。 -- [ ] 文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。 +- [ ] 文件系统仅开放临时文件所需的 read/write/mkdir/remove 命令,且文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。 - [ ] 单实例插件先于其他桌面插件注册。 - [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。 - [ ] macOS 红色按钮保持真正关闭窗口的系统语义,Dock/Finder reopen 事件在需要时重建、显示并聚焦主窗口。 diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md index 1ec562cf..a0090dc7 100644 --- a/docs/guides/branch-environment-installation.md +++ b/docs/guides/branch-environment-installation.md @@ -164,6 +164,7 @@ git checkout git pull docker compose down -v 删除 pg_data +删除 backend/app/uploads 删除数据库 volume 自动安装系统依赖 ``` diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index 095929c9..dcd9326e 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -65,6 +65,10 @@ Manual edits that leave these files inconsistent fail CI. ## Stabilization Gates +Client builds require Node.js 22.13.0 or newer. The development frontend container, +Web CI, macOS release candidates, Windows internal validation, and the production +Web image use the same Node 22.13 baseline. + Client release candidates must pass the shared Web checks and the Desktop release/security gate before promotion: diff --git a/docs/guides/release-checklist.md b/docs/guides/release-checklist.md index 49162d89..62459c75 100644 --- a/docs/guides/release-checklist.md +++ b/docs/guides/release-checklist.md @@ -11,6 +11,7 @@ ## 1.1 容器构建校验 - [ ] `docker compose config` 渲染成功 +- [ ] 后端 `/code/app/uploads` 已持久化挂载到宿主机 `backend/app/uploads` - [ ] `docker compose build backend frontend` 成功 - [ ] `docker compose run --rm --build backend-init` 成功 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 74ad55c6..340b1b93 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS build +FROM node:22.13-alpine AS build WORKDIR /app diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a48a90f9..6bfc715d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "date-fns": "^3.6.0", "echarts": "^6.0.0", "element-plus": "^2.4.4", + "pdfjs-dist": "6.1.200", "pinia": "^2.1.7", "vue": "^3.4.15", "vue-echarts": "^8.0.1", @@ -34,6 +35,9 @@ "vite": "^7.3.1", "vitest": "^3.2.4", "vue-tsc": "^3.2.2" + }, + "engines": { + "node": ">=22.13.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -707,6 +711,271 @@ "version": "1.5.5", "license": "MIT" }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.2.tgz", + "integrity": "sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.2", + "@napi-rs/canvas-darwin-arm64": "1.0.2", + "@napi-rs/canvas-darwin-x64": "1.0.2", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.2", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.2", + "@napi-rs/canvas-linux-arm64-musl": "1.0.2", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-gnu": "1.0.2", + "@napi-rs/canvas-linux-x64-musl": "1.0.2", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.2", + "@napi-rs/canvas-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.2.tgz", + "integrity": "sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.2.tgz", + "integrity": "sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.2.tgz", + "integrity": "sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@one-ini/wasm": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", @@ -3186,6 +3455,18 @@ "node": ">= 14.16" } }, + "node_modules/pdfjs-dist": { + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" diff --git a/frontend/package.json b/frontend/package.json index ef7237ba..ddf8a124 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", + "engines": { + "node": ">=22.13.0" + }, "scripts": { "dev": "vite", "build": "vite build", @@ -36,6 +39,7 @@ "date-fns": "^3.6.0", "echarts": "^6.0.0", "element-plus": "^2.4.4", + "pdfjs-dist": "6.1.200", "pinia": "^2.1.7", "vue": "^3.4.15", "vue-echarts": "^8.0.1", diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index 3800f194..dd546228 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -114,6 +114,12 @@ const verifyCapabilities = async () => { "notification:allow-request-permission", "notification:allow-notify", ]; + const requiredTemporaryFilePermissions = [ + "fs:allow-read-file", + "fs:allow-write-file", + "fs:allow-mkdir", + "fs:allow-remove", + ]; for (const file of files) { const capability = await readJson(resolve(capabilitiesDir, file)); @@ -141,6 +147,9 @@ const verifyCapabilities = async () => { for (const identifier of requiredNotificationPermissions) { assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`); } + for (const identifier of requiredTemporaryFilePermissions) { + assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`); + } const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope"); assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`); @@ -320,6 +329,7 @@ const verifyUpdaterBoundary = async () => { const verifyWorkflowGates = async () => { const packageInfo = await readJson(resolve(frontendDir, "package.json")); + assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0."); const requiredScripts = [ "desktop:build:macos-release", "desktop:update-feed:create", @@ -350,6 +360,7 @@ const verifyWorkflowGates = async () => { } assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL."); assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT."); + assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs."); const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8"); const requiredReleaseWorkflowTokens = [ @@ -368,6 +379,7 @@ const verifyWorkflowGates = async () => { for (const token of requiredReleaseWorkflowTokens) { assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`); } + assert(releaseWorkflow.includes('node-version: "22.13"'), "Desktop release candidates must use Node.js 22.13."); const windowsInternalWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-windows-internal.yml"), "utf8"); const requiredWindowsInternalWorkflowTokens = [ @@ -407,6 +419,22 @@ const verifyWorkflowGates = async () => { for (const token of forbiddenWindowsInternalWorkflowTokens) { assert(!windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must not include ${token}.`); } + assert(windowsInternalWorkflow.includes('node-version: "22.13"'), "Desktop Windows internal builds must use Node.js 22.13."); + + for (const dockerfile of [resolve(frontendDir, "Dockerfile"), resolve(rootDir, "nginx/Dockerfile")]) { + const dockerSource = await readFile(dockerfile, "utf8"); + assert(dockerSource.startsWith("FROM node:22.13-alpine"), `${relative(rootDir, dockerfile)} must build with Node.js 22.13.`); + } + + const developmentCompose = await readFile(resolve(rootDir, "docker-compose.dev.yaml"), "utf8"); + assert(developmentCompose.includes("image: node:22.13-alpine"), "Development frontend container must use Node.js 22.13."); + assert(developmentCompose.includes('dependency_hash="$$lock_hash:$$(node --version)"'), "Development dependency cache must include the Node.js version."); + assert(developmentCompose.includes("pdfjs-dist"), "Development dependency checks must include PDF.js."); + assert( + developmentCompose.includes("frontend_node_modules_node22:/app/node_modules") && + developmentCompose.includes("frontend_node_modules_node22:"), + "Development dependencies must use the Node.js 22-specific volume.", + ); }; await verifyTauriConfig(); diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 86d05e1a..72896cd6 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -9,6 +9,7 @@ "dialog:allow-save", "fs:allow-read-file", "fs:allow-write-file", + "fs:allow-mkdir", "fs:allow-remove", { "identifier": "fs:scope", diff --git a/frontend/src/api/documents.ts b/frontend/src/api/documents.ts index 2feafddd..1c9dfed9 100644 --- a/frontend/src/api/documents.ts +++ b/frontend/src/api/documents.ts @@ -32,6 +32,7 @@ export const uploadDocumentVersion = (documentId: string, payload: FormData) => export const downloadDocumentVersion = (versionId: string) => apiGet(`/api/v1/versions/${versionId}/download`, { responseType: "blob", + suppressErrorMessage: true, }); export const deleteDocumentVersion = (versionId: string) => diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index ae2ff136..91a5caf6 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -519,6 +519,18 @@ describe("desktop layout shell", () => { expect(documentDetail).not.toContain("openFile, pickFiles, saveFile"); }); + it("keeps attachment upload mode flat and responsive across web and desktop", () => { + const attachments = readAttachmentListSource(); + + expect(attachments).not.toContain('class="attachment-list-panel unified-shell"'); + expect(attachments).toContain("'unified-shell': displayMode === 'table'"); + expect(attachments).toContain("'attachment-list-panel--upload': displayMode === 'upload'"); + expect(attachments).toContain("'upload-grid--single': uploadGroups.length === 1"); + expect(attachments).toContain(".upload-grid--single {"); + expect(attachments).toContain('class="pending-file-info"'); + expect(attachments).toContain('class="pending-file-meta"'); + }); + it("surfaces persistent desktop activity feedback without local task persistence", () => { const desktopLayout = readDesktopLayoutSource(); const activityCenter = readDesktopActivityCenterSource(); @@ -595,7 +607,8 @@ describe("desktop layout shell", () => { expect(styles).toContain("backdrop-filter: blur(12px) saturate(135%);"); expect(styles).toContain("body.is-desktop-runtime .el-overlay-dialog"); expect(styles).toContain("body.is-desktop-runtime .el-overlay-message-box"); - expect(styles).toContain("body.is-desktop-runtime .el-dialog,"); + expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog),"); + expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog__body) {"); expect(styles).toContain("body.is-desktop-runtime .el-message-box"); expect(styles).toContain("border-radius: 12px;"); expect(styles).toContain("body.is-desktop-runtime .el-dialog__header"); diff --git a/frontend/src/components/PdfViewer.test.ts b/frontend/src/components/PdfViewer.test.ts new file mode 100644 index 00000000..d5a94a61 --- /dev/null +++ b/frontend/src/components/PdfViewer.test.ts @@ -0,0 +1,119 @@ +import { flushPromises, mount } from "@vue/test-utils"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getDocumentMock = vi.hoisted(() => vi.fn()); +const renderMock = vi.hoisted(() => vi.fn()); +const destroyMock = vi.hoisted(() => vi.fn()); + +vi.mock("pdfjs-dist", () => ({ + GlobalWorkerOptions: { workerSrc: "" }, + getDocument: getDocumentMock, +})); + +import PdfViewer from "./PdfViewer.vue"; + +describe("PdfViewer", () => { + beforeEach(() => { + vi.clearAllMocks(); + renderMock.mockReturnValue({ promise: Promise.resolve(), cancel: vi.fn() }); + destroyMock.mockResolvedValue(undefined); + getDocumentMock.mockReturnValue({ + promise: Promise.resolve({ + numPages: 2, + getPage: vi.fn().mockResolvedValue({ + getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }), + render: renderMock, + }), + destroy: destroyMock, + }), + destroy: destroyMock, + }); + }); + + it("renders PDF pages to canvas without an iframe", async () => { + const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } }); + await flushPromises(); + + expect(getDocumentMock).toHaveBeenCalledWith({ url: "blob:test-pdf" }); + expect(wrapper.findAll("canvas")).toHaveLength(2); + expect(wrapper.find("iframe").exists()).toBe(false); + expect(renderMock).toHaveBeenCalled(); + expect(wrapper.text()).toContain("/ 2"); + + await wrapper.get("button:nth-of-type(2)").trigger("click"); + await flushPromises(); + expect(wrapper.text()).toContain("/ 2"); + + wrapper.unmount(); + await flushPromises(); + expect(destroyMock).toHaveBeenCalled(); + }); + + it("integrates the dialog close action into the viewer toolbar", async () => { + const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf", showClose: true } }); + await flushPromises(); + + await wrapper.get('[aria-label="关闭预览"]').trigger("click"); + + expect(wrapper.emitted("close")).toHaveLength(1); + }); + + it("keeps the visible document center anchored while zooming", async () => { + const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } }); + await flushPromises(); + const viewport = wrapper.get(".pdf-viewer__viewport").element as HTMLElement; + Object.defineProperties(viewport, { + clientWidth: { configurable: true, value: 800 }, + clientHeight: { configurable: true, value: 600 }, + scrollWidth: { + configurable: true, + get: () => Math.max(800, Number.parseInt(wrapper.get("canvas").element.style.width, 10) || 0), + }, + scrollHeight: { + configurable: true, + get: () => Math.max(600, Number.parseInt(wrapper.get("canvas").element.style.height, 10) || 0), + }, + }); + await wrapper.get("button.is-active").trigger("click"); + await flushPromises(); + + await wrapper.get('[aria-label="放大"]').trigger("click"); + await flushPromises(); + + expect(viewport.scrollLeft).toBeGreaterThan(0); + expect(viewport.scrollTop).toBeGreaterThan(0); + }); + + it("shows the original filename in the center of the toolbar", async () => { + const wrapper = mount(PdfViewer, { + props: { src: "blob:test-pdf", filename: "研究方案最终版.pdf" }, + }); + await flushPromises(); + + expect(wrapper.get(".pdf-viewer__filename").text()).toBe("研究方案最终版.pdf"); + expect(wrapper.text()).not.toContain("页文档"); + }); + + it("updates the current page while scrolling through the continuous document", async () => { + const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } }); + await flushPromises(); + const viewport = wrapper.get(".pdf-viewer__viewport"); + const pages = wrapper.findAll(".pdf-viewer__page"); + Object.defineProperties(viewport.element, { + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, writable: true, value: 850 }, + }); + Object.defineProperties(pages[0].element, { + offsetTop: { configurable: true, value: 0 }, + offsetHeight: { configurable: true, value: 800 }, + }); + Object.defineProperties(pages[1].element, { + offsetTop: { configurable: true, value: 816 }, + offsetHeight: { configurable: true, value: 800 }, + }); + + await viewport.trigger("scroll"); + + expect((wrapper.get(".pdf-viewer__page-input").element as HTMLInputElement).value).toBe("2"); + }); +}); diff --git a/frontend/src/components/PdfViewer.vue b/frontend/src/components/PdfViewer.vue new file mode 100644 index 00000000..19f4446a --- /dev/null +++ b/frontend/src/components/PdfViewer.vue @@ -0,0 +1,835 @@ + + + + + + + diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 00938eb0..0706b3bd 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -1,5 +1,11 @@