功能(文档与桌面):完善文件预览下载与客户端构建基线
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
This commit is contained in:
Cheng Zhou
2026-07-13 18:40:48 +08:00
parent ab59476d10
commit 44db5db838
40 changed files with 1817 additions and 87 deletions
+2 -2
View File
@@ -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
@@ -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
@@ -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
@@ -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")
+1
View File
@@ -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)
+1
View File
@@ -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
+29 -4
View File
@@ -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)},
)
+22
View File
@@ -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"
+7 -6
View File
@@ -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:
+3
View File
@@ -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
@@ -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 事件在需要时重建、显示并聚焦主窗口。
@@ -164,6 +164,7 @@ git checkout
git pull
docker compose down -v
删除 pg_data
删除 backend/app/uploads
删除数据库 volume
自动安装系统依赖
```
+4
View File
@@ -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:
+1
View File
@@ -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` 成功
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS build
FROM node:22.13-alpine AS build
WORKDIR /app
+281
View File
@@ -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"
+4
View File
@@ -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",
@@ -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();
@@ -9,6 +9,7 @@
"dialog:allow-save",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:allow-remove",
{
"identifier": "fs:scope",
+1
View File
@@ -32,6 +32,7 @@ export const uploadDocumentVersion = (documentId: string, payload: FormData) =>
export const downloadDocumentVersion = (versionId: string) =>
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
responseType: "blob",
suppressErrorMessage: true,
});
export const deleteDocumentVersion = (versionId: string) =>
+14 -1
View File
@@ -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");
+119
View File
@@ -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");
});
});
+835
View File
@@ -0,0 +1,835 @@
<template>
<section class="pdf-viewer" aria-label="PDF 预览器">
<header class="pdf-viewer__toolbar">
<div class="pdf-viewer__group" aria-label="翻页控制">
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber <= 1" aria-label="上一页" title="上一页" @click="previousPage">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 15-5-5 5-5" /></svg>
</button>
<label class="pdf-viewer__page-control">
<span class="sr-only">当前页</span>
<input
v-model.number="pageInput"
class="pdf-viewer__page-input"
type="number"
min="1"
:max="pageCount || 1"
:disabled="busy || !pageCount"
@change="commitPageInput"
@keyup.enter="commitPageInput"
/>
<span class="pdf-viewer__page-total">/ {{ pageCount || 0 }}</span>
</label>
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber >= pageCount" aria-label="下一页" title="下一页" @click="nextPage">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 5 5 5-5 5" /></svg>
</button>
</div>
<div class="pdf-viewer__document-meta" :title="filename || 'PDF 文档'">
<span class="pdf-viewer__filename">{{ filename || "PDF 文档" }}</span>
</div>
<div class="pdf-viewer__actions">
<div class="pdf-viewer__group" aria-label="缩放控制">
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent <= MIN_ZOOM" aria-label="缩小" title="缩小" @click="zoomOut">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10" /></svg>
</button>
<span class="pdf-viewer__zoom">{{ renderedScalePercent }}%</span>
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent >= MAX_ZOOM" aria-label="放大" title="放大" @click="zoomIn">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10M10 5v10" /></svg>
</button>
<button type="button" class="pdf-viewer__button" :class="{ 'is-active': fitWidth }" :disabled="busy" @click="useFitWidth">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 6v8M16.5 6v8M6.5 10h7M8.5 8l-2 2 2 2M11.5 8l2 2-2 2" /></svg>
适合宽度
</button>
</div>
<span v-if="showClose" class="pdf-viewer__divider" aria-hidden="true" />
<button v-if="showClose" type="button" class="pdf-viewer__button pdf-viewer__button--icon pdf-viewer__close" aria-label="关闭预览" title="关闭预览" @click="emit('close')">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 5 10 10M15 5 5 15" /></svg>
</button>
</div>
</header>
<div ref="viewportRef" class="pdf-viewer__viewport" @scroll.passive="handleViewportScroll">
<div v-if="errorMessage" class="pdf-viewer__state pdf-viewer__state--error" role="alert">
{{ errorMessage }}
</div>
<div v-else-if="loadingDocument" class="pdf-viewer__state" aria-live="polite">
<span class="pdf-viewer__spinner" aria-hidden="true" />
<span>正在加载 PDF</span>
</div>
<div v-else class="pdf-viewer__pages">
<article
v-for="pageIndex in pageCount"
:key="pageIndex"
:ref="(element) => setPageElement(element, pageIndex)"
class="pdf-viewer__page"
:class="{ 'is-rendering': renderingPages.has(pageIndex) }"
:data-page-number="pageIndex"
>
<canvas
:ref="(element) => setCanvasElement(element, pageIndex)"
class="pdf-viewer__canvas"
:aria-label="`PDF 第 ${pageIndex} 页`"
/>
<div v-if="renderingPages.has(pageIndex)" class="pdf-viewer__rendering" aria-live="polite">
<span class="pdf-viewer__spinner pdf-viewer__spinner--small" aria-hidden="true" />
正在渲染第 {{ pageIndex }}
</div>
</article>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
getDocument,
GlobalWorkerOptions,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
type PDFPageProxy,
type RenderTask,
} from "pdfjs-dist";
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
const MIN_ZOOM = 25;
const MAX_ZOOM = 300;
const ZOOM_STEP = 25;
const props = defineProps<{
src: string;
filename?: string;
showClose?: boolean;
}>();
const emit = defineEmits<{
close: [];
}>();
const viewportRef = ref<HTMLElement | null>(null);
const loadingDocument = ref(false);
const renderingPages = ref(new Set<number>());
const errorMessage = ref("");
const pageNumber = ref(1);
const pageInput = ref(1);
const pageCount = ref(0);
const zoomPercent = ref(100);
const renderedScalePercent = ref(100);
const fitWidth = ref(true);
let loadingTask: PDFDocumentLoadingTask | null = null;
let pdfDocument: PDFDocumentProxy | null = null;
const renderTasks = new Map<number, RenderTask>();
const canvasElements = new Map<number, HTMLCanvasElement>();
const pageElements = new Map<number, HTMLElement>();
const pageScales = new Map<number, number>();
const pageLayouts = new Map<number, {
pageIndex: number;
page: PDFPageProxy;
canvas: HTMLCanvasElement;
viewport: ReturnType<PDFPageProxy["getViewport"]>;
outputScale: number;
}>();
const renderedPages = new Set<number>();
let loadSequence = 0;
let renderSequence = 0;
let resizeObserver: ResizeObserver | null = null;
let resizeFrame = 0;
const busy = computed(() => loadingDocument.value || !pdfDocument);
const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value));
const describePdfError = (error: unknown) => {
const name = error instanceof Error ? error.name : "";
if (name === "PasswordException") return "该 PDF 已加密,暂时无法在线预览";
if (name === "InvalidPDFException") return "PDF 文件格式无效或已损坏";
return "PDF 加载失败,请下载后查看";
};
const setCanvasElement = (element: unknown, pageIndex: number) => {
if (element instanceof HTMLCanvasElement) canvasElements.set(pageIndex, element);
else canvasElements.delete(pageIndex);
};
const setPageElement = (element: unknown, pageIndex: number) => {
if (element instanceof HTMLElement) pageElements.set(pageIndex, element);
else pageElements.delete(pageIndex);
};
const cancelRenderTasks = () => {
renderTasks.forEach((task) => task.cancel());
renderTasks.clear();
};
const updateRenderingPage = (pageIndex: number, rendering: boolean) => {
const nextPages = new Set(renderingPages.value);
if (rendering) nextPages.add(pageIndex);
else nextPages.delete(pageIndex);
renderingPages.value = nextPages;
};
const updateCurrentPage = (value: number) => {
const nextPage = clamp(value, 1, pageCount.value || 1);
pageNumber.value = nextPage;
pageInput.value = nextPage;
const scale = pageScales.get(nextPage);
if (scale) renderedScalePercent.value = Math.round(scale * 100);
};
const renderPage = async (pageIndex: number, sequence: number) => {
if (sequence !== renderSequence || renderedPages.has(pageIndex) || renderTasks.has(pageIndex)) return;
const layout = pageLayouts.get(pageIndex);
if (!layout) return;
const { page, canvas, viewport, outputScale } = layout;
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
updateRenderingPage(pageIndex, true);
const task = page.render({
canvas,
viewport,
background: "#ffffff",
transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0],
});
renderTasks.set(pageIndex, task);
try {
await task.promise;
if (sequence === renderSequence) renderedPages.add(pageIndex);
} catch (error) {
if (sequence === renderSequence && !(error instanceof Error && error.name === "RenderingCancelledException")) {
errorMessage.value = describePdfError(error);
}
} finally {
if (renderTasks.get(pageIndex) === task) {
renderTasks.delete(pageIndex);
if (sequence === renderSequence) updateRenderingPage(pageIndex, false);
}
}
};
const renderVisiblePages = async (sequence = renderSequence) => {
const viewport = viewportRef.value;
if (!viewport || sequence !== renderSequence) return;
const viewportHeight = Math.max(viewport.clientHeight, 1);
const renderTop = Math.max(0, viewport.scrollTop - viewportHeight);
const renderBottom = viewport.scrollTop + viewportHeight * 2;
const retainTop = Math.max(0, viewport.scrollTop - viewportHeight * 3);
const retainBottom = viewport.scrollTop + viewportHeight * 4;
const pagesToRender: number[] = [];
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
const pageElement = pageElements.get(pageIndex);
if (!pageElement) continue;
const pageTop = pageElement.offsetTop;
const pageBottom = pageTop + pageElement.offsetHeight;
if (pageBottom >= renderTop && pageTop <= renderBottom) pagesToRender.push(pageIndex);
if (renderedPages.has(pageIndex) && (pageBottom < retainTop || pageTop > retainBottom)) {
const canvas = canvasElements.get(pageIndex);
if (canvas) {
canvas.width = 1;
canvas.height = 1;
}
renderedPages.delete(pageIndex);
}
}
await Promise.all(pagesToRender.map((pageIndex) => renderPage(pageIndex, sequence)));
};
const renderAllPages = async (preservePosition = true) => {
const sequence = ++renderSequence;
const document = pdfDocument;
const viewportElement = viewportRef.value;
if (!document || !viewportElement || errorMessage.value) return;
cancelRenderTasks();
renderingPages.value = new Set();
renderedPages.clear();
pageLayouts.clear();
try {
const previousScrollWidth = Math.max(viewportElement.scrollWidth, viewportElement.clientWidth);
const previousScrollHeight = Math.max(viewportElement.scrollHeight, viewportElement.clientHeight);
const horizontalAnchor = (viewportElement.scrollLeft + viewportElement.clientWidth / 2) / Math.max(previousScrollWidth, 1);
const verticalAnchor = (viewportElement.scrollTop + viewportElement.clientHeight / 2) / Math.max(previousScrollHeight, 1);
const pageNumbers = Array.from({ length: pageCount.value }, (_, index) => index + 1);
const pages = await Promise.all(pageNumbers.map((pageIndex) => document.getPage(pageIndex)));
if (sequence !== renderSequence) return;
const availableWidth = Math.max(240, viewportElement.clientWidth - 2);
const outputScale = Math.min(window.devicePixelRatio || 1, 2);
pages.forEach((page, index) => {
const pageIndex = index + 1;
const canvas = canvasElements.get(pageIndex);
if (!canvas) return;
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = fitWidth.value
? clamp(availableWidth / unscaledViewport.width, MIN_ZOOM / 100, MAX_ZOOM / 100)
: zoomPercent.value / 100;
const viewport = page.getViewport({ scale });
canvas.width = 1;
canvas.height = 1;
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
pageScales.set(pageIndex, scale);
pageLayouts.set(pageIndex, { pageIndex, page, canvas, viewport, outputScale });
});
const currentScale = pageScales.get(pageNumber.value);
if (currentScale) renderedScalePercent.value = Math.round(currentScale * 100);
await nextTick();
if (preservePosition) {
viewportElement.scrollLeft = Math.max(0, horizontalAnchor * viewportElement.scrollWidth - viewportElement.clientWidth / 2);
viewportElement.scrollTop = Math.max(0, verticalAnchor * viewportElement.scrollHeight - viewportElement.clientHeight / 2);
} else {
viewportElement.scrollLeft = 0;
viewportElement.scrollTop = 0;
}
await renderVisiblePages(sequence);
} catch (error) {
if (!(error instanceof Error && error.name === "RenderingCancelledException")) {
errorMessage.value = describePdfError(error);
}
} finally {
if (sequence === renderSequence) {
renderTasks.clear();
renderingPages.value = new Set();
}
}
};
const disposeDocument = async () => {
renderSequence += 1;
cancelRenderTasks();
renderingPages.value = new Set();
canvasElements.clear();
pageElements.clear();
pageScales.clear();
pageLayouts.clear();
renderedPages.clear();
const task = loadingTask;
loadingTask = null;
pdfDocument = null;
await task?.destroy().catch(() => {});
};
const loadPdf = async () => {
const sequence = ++loadSequence;
await disposeDocument();
errorMessage.value = "";
pageCount.value = 0;
pageNumber.value = 1;
pageInput.value = 1;
loadingDocument.value = true;
if (!props.src) {
loadingDocument.value = false;
return;
}
try {
const task = getDocument({ url: props.src });
loadingTask = task;
const document = await task.promise;
if (sequence !== loadSequence) {
await task.destroy();
return;
}
pdfDocument = document;
pageCount.value = document.numPages;
loadingDocument.value = false;
await nextTick();
await renderAllPages(false);
} catch (error) {
if (sequence === loadSequence) errorMessage.value = describePdfError(error);
} finally {
if (sequence === loadSequence) loadingDocument.value = false;
}
};
const scrollToPage = (value: number, behavior: ScrollBehavior = "smooth") => {
const nextPage = clamp(value, 1, pageCount.value || 1);
updateCurrentPage(nextPage);
const viewport = viewportRef.value;
const page = pageElements.get(nextPage);
if (!viewport || !page) return;
if (typeof viewport.scrollTo === "function") viewport.scrollTo({ top: page.offsetTop, behavior });
else viewport.scrollTop = page.offsetTop;
};
const previousPage = () => scrollToPage(pageNumber.value - 1);
const nextPage = () => scrollToPage(pageNumber.value + 1);
const handleViewportScroll = () => {
const viewport = viewportRef.value;
if (!viewport || !pageCount.value) return;
const readingLine = viewport.scrollTop + viewport.clientHeight / 2;
let visiblePage = pageCount.value;
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
const page = pageElements.get(pageIndex);
if (!page) continue;
const pageBottom = page.offsetTop + page.offsetHeight + 8;
if (readingLine < pageBottom) {
visiblePage = pageIndex;
break;
}
}
updateCurrentPage(visiblePage);
void renderVisiblePages();
};
const commitPageInput = () => {
scrollToPage(Number(pageInput.value) || 1);
};
const zoomOut = () => {
fitWidth.value = false;
zoomPercent.value = clamp(renderedScalePercent.value - ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
};
const zoomIn = () => {
fitWidth.value = false;
zoomPercent.value = clamp(renderedScalePercent.value + ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
};
const useFitWidth = () => {
fitWidth.value = true;
void renderAllPages(true);
};
watch(() => props.src, () => void loadPdf(), { immediate: true });
watch(zoomPercent, () => {
if (!fitWidth.value) void renderAllPages(true);
});
onMounted(() => {
if (typeof ResizeObserver === "undefined" || !viewportRef.value) return;
resizeObserver = new ResizeObserver(() => {
if (!fitWidth.value) return;
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(() => void renderAllPages(true));
});
resizeObserver.observe(viewportRef.value);
});
onBeforeUnmount(() => {
loadSequence += 1;
window.cancelAnimationFrame(resizeFrame);
resizeObserver?.disconnect();
void disposeDocument();
});
</script>
<style scoped>
.pdf-viewer {
display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
border: 0;
border-radius: 0;
background: #e8edf2;
}
.pdf-viewer__toolbar {
position: relative;
z-index: 2;
display: grid;
min-height: 52px;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
padding: 7px 12px;
border-bottom: 1px solid #dce2e8;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(12px);
}
.pdf-viewer__group {
display: flex;
align-items: center;
justify-self: start;
gap: 3px;
}
.pdf-viewer__actions {
display: flex;
align-items: center;
justify-self: end;
gap: 6px;
}
.pdf-viewer__divider {
width: 1px;
height: 24px;
margin-left: 2px;
background: #e1e6eb;
}
.pdf-viewer__page-control {
display: flex;
align-items: center;
gap: 7px;
margin: 0 4px;
color: #66717e;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.pdf-viewer__button {
display: inline-flex;
min-height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 5px 10px;
border: 0;
border-radius: 6px;
color: #43505e;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 1;
cursor: pointer;
transition: color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.pdf-viewer__button--icon {
width: 32px;
padding: 0;
}
.pdf-viewer__button svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.pdf-viewer__button:hover:not(:disabled) {
color: #2f536d;
background: #e8f0f5;
}
.pdf-viewer__button.is-active {
color: #365d77;
background: #e5eef4;
box-shadow: inset 0 0 0 1px rgba(69, 103, 127, 0.12);
}
.pdf-viewer__button:active:not(:disabled) {
transform: translateY(1px);
}
.pdf-viewer__button:focus-visible,
.pdf-viewer__page-input:focus-visible {
outline: 2px solid rgba(64, 111, 145, 0.35);
outline-offset: 1px;
}
.pdf-viewer__button:disabled {
cursor: not-allowed;
opacity: 0.36;
}
.pdf-viewer__page-input {
width: 42px;
height: 28px;
box-sizing: border-box;
border: 1px solid #d7dde4;
border-radius: 6px;
color: #25313d;
background: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
text-align: center;
appearance: textfield;
}
.pdf-viewer__page-input::-webkit-inner-spin-button,
.pdf-viewer__page-input::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
.pdf-viewer__page-total {
min-width: 26px;
}
.pdf-viewer__document-meta {
min-width: 0;
max-width: min(38vw, 460px);
justify-self: center;
color: #536171;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.pdf-viewer__filename {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.pdf-viewer__zoom {
min-width: 52px;
color: #42505d;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: center;
}
.pdf-viewer__close {
color: #7a8693;
}
.pdf-viewer__close:hover:not(:disabled) {
color: #344252;
background: #eef2f5;
}
.pdf-viewer__viewport {
position: relative;
min-height: 0;
flex: 1;
overflow: auto;
padding: 0;
background-color: #e7ebf0;
background-image: radial-gradient(rgba(92, 106, 121, 0.12) 0.7px, transparent 0.7px);
background-size: 12px 12px;
scrollbar-color: #aeb7c1 transparent;
scrollbar-width: thin;
}
.pdf-viewer__viewport::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.pdf-viewer__viewport::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: #aeb7c1;
background-clip: padding-box;
}
.pdf-viewer__viewport::-webkit-scrollbar-corner {
background: transparent;
}
.pdf-viewer__pages {
width: max-content;
min-width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pdf-viewer__page {
position: relative;
width: max-content;
flex: 0 0 auto;
}
.pdf-viewer__canvas {
display: block;
border: 1px solid rgba(116, 127, 140, 0.2);
background: #fff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.08),
0 12px 34px rgba(42, 51, 62, 0.2);
transition: opacity 0.18s ease;
}
.pdf-viewer__page.is-rendering .pdf-viewer__canvas {
opacity: 0.76;
}
.pdf-viewer__rendering {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
border-radius: 999px;
color: #fff;
background: rgba(41, 52, 64, 0.82);
box-shadow: 0 5px 16px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(8px);
font-size: 12px;
}
.pdf-viewer__state {
display: flex;
min-height: 100%;
align-items: center;
justify-content: center;
gap: 10px;
color: #6b7280;
font-size: 14px;
}
.pdf-viewer__state--error {
color: #c45656;
}
.pdf-viewer__spinner {
width: 19px;
height: 19px;
box-sizing: border-box;
border: 2px solid rgba(69, 103, 127, 0.2);
border-top-color: #45677f;
border-radius: 50%;
animation: pdf-viewer-spin 0.8s linear infinite;
}
.pdf-viewer__spinner--small {
width: 13px;
height: 13px;
border-color: rgba(255, 255, 255, 0.32);
border-top-color: #fff;
}
@keyframes pdf-viewer-spin {
to { transform: rotate(360deg); }
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 760px) {
.pdf-viewer {
min-height: 0;
height: 100%;
}
.pdf-viewer__toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 7px;
padding: 8px;
}
.pdf-viewer__document-meta {
display: none;
}
}
</style>
<style>
.pdf-preview-dialog.el-dialog {
overflow: hidden;
padding: 0;
border: 1px solid rgba(205, 214, 223, 0.8);
border-radius: 16px;
background: #f8fafb;
box-shadow: 0 24px 72px rgba(15, 23, 42, 0.24);
}
.pdf-preview-dialog .el-dialog__header {
margin: 0;
padding: 19px 24px 17px;
border-bottom: 1px solid #e6ebef;
background: #fff;
}
.pdf-preview-dialog .el-dialog__title {
color: #202c38;
font-size: 18px;
font-weight: 650;
letter-spacing: -0.01em;
}
.pdf-preview-dialog .el-dialog__headerbtn {
top: 7px;
right: 8px;
width: 44px;
height: 44px;
border-radius: 9px;
transition: background-color 0.16s ease;
}
.pdf-preview-dialog .el-dialog__headerbtn:hover {
background: #f1f4f7;
}
.pdf-preview-dialog .el-dialog__close {
color: #7a8693;
font-size: 20px;
}
.pdf-preview-dialog .el-dialog__body {
padding: 18px 20px 22px;
}
.pdf-preview-dialog--flush.el-dialog {
height: min(84dvh, 880px);
margin-bottom: 0;
background: #e7ebf0;
}
.pdf-preview-dialog--flush .el-dialog__header {
display: none;
}
.pdf-preview-dialog--flush .el-dialog__body {
height: 100%;
box-sizing: border-box;
padding: 0;
}
.pdf-preview-dialog--flush .preview-body,
.pdf-preview-dialog--flush .pdf-viewer {
height: 100%;
min-height: 0;
}
@media (max-width: 760px) {
.pdf-preview-dialog--flush.el-dialog {
width: calc(100vw - 16px) !important;
height: calc(100dvh - 16px);
margin-top: 8px;
}
.pdf-preview-dialog .el-dialog__header {
padding: 16px 18px 14px;
}
.pdf-preview-dialog .el-dialog__body {
padding: 12px;
}
.pdf-preview-dialog--flush .el-dialog__body {
padding: 0;
}
}
</style>
@@ -1,5 +1,11 @@
<template>
<div class="attachment-list-panel unified-shell">
<div
class="attachment-list-panel"
:class="{
'unified-shell': displayMode === 'table',
'attachment-list-panel--upload': displayMode === 'upload',
}"
>
<template v-if="displayMode === 'table'">
<div v-if="showHeader" class="header">
<span>{{ headerTitle }}</span>
@@ -60,7 +66,14 @@
</el-table-column>
</el-table>
</template>
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
<div
v-else
class="upload-grid"
:class="{
'upload-grid--single': uploadGroups.length === 1,
'upload-grid--three': uploadGroups.length > 1 && uploadCardColumns === 3,
}"
>
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
<el-upload
v-if="!nativeFiles"
@@ -92,7 +105,10 @@
'is-disabled': !canUploadGroup(group),
'attachment-upload-card--centered': centerUploadCardContent,
}"
role="button"
:tabindex="canUploadGroup(group) ? 0 : -1"
@click="pickPendingNative(group)"
@keydown.enter.space.prevent="pickPendingNative(group)"
>
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<div class="upload-trigger">
@@ -107,11 +123,17 @@
class="pending-file"
:class="{ 'is-failed': item.status === 'failed' }"
>
<span class="pending-file-name">{{ item.file.name }}</span>
<span v-if="item.status === 'failed'" class="pending-file-status">
{{ item.error || TEXT.common.messages.uploadFailed }}
<span class="pending-file-icon" aria-hidden="true">
<el-icon><Document /></el-icon>
</span>
<el-button link type="danger" size="small" @click="removePendingUpload(group.key, item)">
<span class="pending-file-info">
<span class="pending-file-name">{{ item.file.name }}</span>
<span class="pending-file-meta">
<span>{{ formatFileSize(item.file.size) }}</span>
<span class="pending-file-status">{{ pendingStatusText(item) }}</span>
</span>
</span>
<el-button class="pending-file-remove" link type="danger" size="small" @click="removePendingUpload(group.key, item)">
{{ TEXT.common.actions.remove }}
</el-button>
</div>
@@ -125,13 +147,13 @@
</div>
</div>
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" :width="previewType === 'pdf' ? 'min(86vw, 1440px)' : 'min(1180px, 94vw)'" :top="previewType === 'pdf' ? '8vh' : '3vh'" :close-on-click-modal="false" :show-close="previewType !== 'pdf'" :class="['pdf-preview-dialog', { 'pdf-preview-dialog--flush': previewType === 'pdf' }]">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
<PdfViewer v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" :filename="previewTitle" show-close @close="previewVisible = false" />
<div v-else class="preview-error">
{{ TEXT.common.messages.previewNotSupported }}
</div>
@@ -140,9 +162,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { Document, UploadFilled } from "@element-plus/icons-vue";
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
@@ -162,6 +184,8 @@ type AttachmentEntityGroup = {
uploadText?: string;
};
const PdfViewer = defineAsyncComponent(() => import("../PdfViewer.vue"));
type UploadGroup = {
key: string;
label: string;
@@ -291,6 +315,12 @@ const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
};
const pendingStatusText = (item: PendingUploadItem) => {
if (item.status === "uploading") return "正在上传";
if (item.status === "failed") return item.error || TEXT.common.messages.uploadFailed;
return "待上传";
};
const pendingSnapshot = () =>
uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
@@ -580,6 +610,11 @@ defineExpose({
box-shadow: none;
}
.attachment-list-panel--upload {
overflow: visible;
background: transparent;
}
.header {
display: flex;
justify-content: space-between;
@@ -621,6 +656,10 @@ defineExpose({
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.upload-grid--single {
grid-template-columns: minmax(0, 1fr);
}
.attachment-upload-item {
min-width: 0;
}
@@ -632,6 +671,7 @@ defineExpose({
}
.attachment-upload-card {
box-sizing: border-box;
min-height: 72px;
padding: 14px 16px;
border: 1px dashed #d0dced;
@@ -641,6 +681,14 @@ defineExpose({
transition: border-color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
}
.upload-grid--single .attachment-upload-card {
min-height: 92px;
}
.upload-grid--single .upload-trigger {
justify-content: center;
}
.attachment-upload-card:hover,
.attachment-upload-card:focus-within {
border-color: var(--ctms-primary);
@@ -709,50 +757,90 @@ defineExpose({
.pending-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 8px;
gap: 8px;
margin-top: 10px;
}
.pending-file {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
min-width: 0;
min-height: 52px;
box-sizing: border-box;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
color: var(--ctms-text-regular);
font-size: 12px;
}
.pending-file.is-failed {
border-color: #fecaca;
background: #fff7f7;
color: var(--ctms-danger, #f56c6c);
}
.pending-file-name {
.pending-file-icon {
display: inline-flex;
flex: 0 0 34px;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: #eaf1fb;
color: #314a6b;
font-size: 18px;
}
.pending-file-info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.pending-file-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
font-size: 13px;
font-weight: 600;
}
.pending-file-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--ctms-text-secondary);
font-size: 11px;
}
.pending-file-status {
flex: 0 0 auto;
color: var(--ctms-danger, #f56c6c);
font-size: 12px;
color: #64748b;
white-space: nowrap;
}
.pending-file.is-failed .pending-file-status {
color: var(--ctms-danger, #f56c6c);
}
.pending-file-remove {
flex: 0 0 auto;
margin-left: auto;
}
@media (max-width: 640px) {
.upload-grid {
grid-template-columns: 1fr;
}
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
+1
View File
@@ -50,6 +50,7 @@ export const TEXT = {
uploadSuccess: "上传成功",
uploadFailed: "上传失败",
downloadFailed: "下载失败",
openFailed: "文件打开失败",
actionFailed: "操作失败",
noPermission: "无权限执行该操作",
networkError: "网络错误,请稍后重试",
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./platform", () => ({ isTauriRuntime: () => false }));
describe("web file saving", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
delete (window as any).showSaveFilePicker;
delete (URL as any).createObjectURL;
delete (URL as any).revokeObjectURL;
vi.restoreAllMocks();
});
it("selects a browser save path before writing the file", async () => {
const write = vi.fn().mockResolvedValue(undefined);
const close = vi.fn().mockResolvedValue(undefined);
const createWritable = vi.fn().mockResolvedValue({ write, close });
(window as any).showSaveFilePicker = vi.fn().mockResolvedValue({ createWritable });
const { prepareSaveFile, saveFile } = await import("./files");
const destination = await prepareSaveFile("研究方案.xlsx");
const output = { suggestedName: "研究方案.xlsx", data: new Blob(["content"]) };
await expect(saveFile(output, destination)).resolves.toBe("saved");
expect((window as any).showSaveFilePicker).toHaveBeenCalledWith({ suggestedName: "研究方案.xlsx" });
expect(write).toHaveBeenCalledWith(output.data);
expect(close).toHaveBeenCalledOnce();
});
it("does not download when the save picker is cancelled", async () => {
(window as any).showSaveFilePicker = vi.fn().mockRejectedValue(new DOMException("cancelled", "AbortError"));
const { prepareSaveFile } = await import("./files");
await expect(prepareSaveFile("protocol.pdf")).resolves.toBeNull();
});
it("falls back to a browser download when the file picker is unavailable", async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
(URL as any).createObjectURL = vi.fn().mockReturnValue("blob:test");
(URL as any).revokeObjectURL = vi.fn();
const { prepareSaveFile, saveFile } = await import("./files");
const destination = await prepareSaveFile("protocol.pdf");
await expect(saveFile({ suggestedName: "protocol.pdf", data: "content" }, destination)).resolves.toBe("saved");
expect(click).toHaveBeenCalledOnce();
});
});
+60 -9
View File
@@ -14,6 +14,25 @@ export interface FileOutput {
export type SaveFileResult = "saved" | "cancelled";
type WebWritableFile = {
write(data: Blob): Promise<void>;
close(): Promise<void>;
abort?(): Promise<void>;
};
type WebSaveFileHandle = {
createWritable(): Promise<WebWritableFile>;
};
export type SaveFileDestination =
| { kind: "desktop-path"; path: string }
| { kind: "web-file-handle"; handle: WebSaveFileHandle }
| { kind: "web-download" };
type WebSaveFilePickerWindow = Window & {
showSaveFilePicker?: (options: { suggestedName: string }) => Promise<WebSaveFileHandle>;
};
const sanitizeFileName = (value: string): string => {
const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim();
return normalized.slice(0, 180) || "download";
@@ -83,9 +102,46 @@ export const pickFiles = async (options: FilePickerOptions = {}): Promise<File[]
);
};
export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
const suggestedName = sanitizeFileName(output.suggestedName);
export const prepareSaveFile = async (suggestedName: string): Promise<SaveFileDestination | null> => {
const safeName = sanitizeFileName(suggestedName);
if (!isTauriRuntime()) {
const picker = (window as WebSaveFilePickerWindow).showSaveFilePicker;
if (!picker) return { kind: "web-download" };
try {
const handle = await picker.call(window, { suggestedName: safeName });
return { kind: "web-file-handle", handle };
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return null;
return { kind: "web-download" };
}
}
const { save } = await import("@tauri-apps/plugin-dialog");
const path = await save({ defaultPath: safeName });
return path ? { kind: "desktop-path", path } : null;
};
export const saveFile = async (
output: FileOutput,
preparedDestination?: SaveFileDestination | null,
): Promise<SaveFileResult> => {
const suggestedName = sanitizeFileName(output.suggestedName);
const destination = preparedDestination === undefined ? await prepareSaveFile(suggestedName) : preparedDestination;
if (!destination) return "cancelled";
if (destination.kind === "web-file-handle") {
const writable = await destination.handle.createWritable();
try {
await writable.write(toBlob(output));
await writable.close();
} catch (error) {
await writable.abort?.().catch(() => {});
throw error;
}
return "saved";
}
if (destination.kind === "web-download") {
const url = URL.createObjectURL(toBlob(output));
const link = document.createElement("a");
link.href = url;
@@ -97,13 +153,8 @@ export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
return "saved";
}
const [{ save }, { writeFile }] = await Promise.all([
import("@tauri-apps/plugin-dialog"),
import("@tauri-apps/plugin-fs"),
]);
const path = await save({ defaultPath: suggestedName });
if (!path) return "cancelled";
await writeFile(path, await toBytes(output));
const { writeFile } = await import("@tauri-apps/plugin-fs");
await writeFile(destination.path, await toBytes(output));
return "saved";
};
+2
View File
@@ -64,9 +64,11 @@ export {
cleanupTemporaryFiles,
openFile,
pickFiles,
prepareSaveFile,
saveFile,
type FileOutput,
type FilePickerOptions,
type SaveFileDestination,
type SaveFileResult,
} from "./files";
export {
+26 -4
View File
@@ -1038,7 +1038,7 @@ body.is-desktop-runtime .el-overlay-message-box {
overflow: auto;
}
body.is-desktop-runtime .el-dialog,
body.is-desktop-runtime :where(.el-dialog),
body.is-desktop-runtime .el-message-box {
overflow: hidden;
margin: 0 auto !important;
@@ -1052,7 +1052,7 @@ body.is-desktop-runtime .el-message-box {
color: #0f172a;
}
body.is-desktop-runtime .el-dialog {
body.is-desktop-runtime :where(.el-dialog) {
max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px));
}
@@ -1061,7 +1061,8 @@ body.is-desktop-runtime .profile-settings-dialog,
body.is-desktop-runtime .desktop-preferences-dialog,
body.is-desktop-runtime .user-form-dialog,
body.is-desktop-runtime .reset-password-dialog,
body.is-desktop-runtime .project-form-dialog {
body.is-desktop-runtime .project-form-dialog,
body.is-desktop-runtime .pdf-preview-dialog--flush {
overflow: visible !important;
border: none !important;
border-radius: 0 !important;
@@ -1070,6 +1071,26 @@ body.is-desktop-runtime .project-form-dialog {
backdrop-filter: none !important;
}
body.is-desktop-runtime .pdf-preview-dialog--flush {
padding: 0 !important;
}
body.is-desktop-runtime .pdf-preview-dialog--flush .el-dialog__body {
height: 100%;
padding: 0 !important;
background: transparent !important;
}
body.is-desktop-runtime .pdf-preview-dialog--flush .pdf-viewer {
box-sizing: border-box;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.42);
border-radius: 12px;
box-shadow:
0 28px 84px rgba(15, 23, 42, 0.26),
0 1px 0 rgba(255, 255, 255, 0.82) inset;
}
body.is-desktop-runtime .el-message-box {
width: min(420px, calc(100vw - 72px));
padding: 0;
@@ -1105,7 +1126,8 @@ body.is-desktop-runtime .el-message-box__headerbtn .el-message-box__close {
color: #64748b;
}
body.is-desktop-runtime .el-dialog__body {
/* 低优先级桌面基线:普通弹窗保留间距,带专属布局的弹窗可直接覆盖。 */
body.is-desktop-runtime :where(.el-dialog__body) {
padding: 14px;
color: #334155;
font-size: 13px;
+1
View File
@@ -40,6 +40,7 @@ export interface DocumentVersion {
effective_at?: string | null;
superseded_at?: string | null;
file_uri: string;
original_filename?: string | null;
file_hash: string;
file_size: number;
mime_type?: string | null;
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { getApiErrorMessage } from "./apiErrorMessage";
describe("getApiErrorMessage", () => {
it("extracts FastAPI detail from a blob error response", async () => {
const error = {
response: {
data: new Blob([JSON.stringify({ detail: "文件不存在" })], { type: "application/json" }),
},
};
await expect(getApiErrorMessage(error, "下载失败")).resolves.toBe("文件不存在");
});
it("extracts nested detail messages", async () => {
const error = { response: { data: { detail: { message: "权限不足" } } } };
await expect(getApiErrorMessage(error, "请求失败")).resolves.toBe("权限不足");
});
it("uses the fallback when no readable message exists", async () => {
await expect(getApiErrorMessage({}, "下载失败")).resolves.toBe("下载失败");
});
});
+51
View File
@@ -0,0 +1,51 @@
const payloadMessage = (payload: unknown): string | null => {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.message === "string" && record.message.trim()) return record.message;
if (typeof record.detail === "string" && record.detail.trim()) return record.detail;
if (record.detail && typeof record.detail === "object") {
const detail = record.detail as Record<string, unknown>;
if (typeof detail.message === "string" && detail.message.trim()) return detail.message;
}
return null;
};
const textPayloadMessage = (value: string): string | null => {
const text = value.trim();
if (!text) return null;
try {
return payloadMessage(JSON.parse(text)) || text;
} catch {
return text;
}
};
const readBlobText = async (blob: Blob): Promise<string> => {
if (typeof blob.text === "function") return blob.text();
if (typeof FileReader !== "undefined") {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(typeof reader.result === "string" ? reader.result : ""), { once: true });
reader.addEventListener("error", () => reject(reader.error), { once: true });
reader.readAsText(blob);
});
}
return "";
};
/**
* Axios blob JSON Blob
* message/detail
*/
export const getApiErrorMessage = async (error: unknown, fallback: string): Promise<string> => {
const data = (error as any)?.response?.data;
if (typeof Blob !== "undefined" && data instanceof Blob) {
try {
return textPayloadMessage(await readBlobText(data)) || fallback;
} catch {
return fallback;
}
}
if (typeof data === "string") return textPayloadMessage(data) || fallback;
return payloadMessage(data) || fallback;
};
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { getContentDispositionFilename } from "./contentDisposition";
describe("getContentDispositionFilename", () => {
it("prefers the UTF-8 filename even when the ASCII fallback appears first", () => {
const header = 'attachment; filename="____ V1.0.xlsx"; filename*=UTF-8\'\'%E7%A0%94%E7%A9%B6%E6%96%B9%E6%A1%88%20V1.0.xlsx';
expect(getContentDispositionFilename(header)).toBe("研究方案 V1.0.xlsx");
});
it("falls back to a quoted filename", () => {
expect(getContentDispositionFilename('attachment; filename="protocol.pdf"')).toBe("protocol.pdf");
});
});
+12
View File
@@ -0,0 +1,12 @@
export const getContentDispositionFilename = (header?: string | null): string | null => {
if (!header) return null;
const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded.trim().replace(/^"|"$/g, ""));
} catch {
// Fall through to the ASCII filename when the RFC 5987 value is malformed.
}
}
return /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(header)?.slice(1).find(Boolean)?.trim() || null;
};
@@ -80,6 +80,16 @@ describe("file task feedback", () => {
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
});
it("writes to a destination that was selected before the download", async () => {
const destination = { kind: "web-file-handle", handle: { createWritable: vi.fn() } } as const;
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
await expect(saveFileWithFeedback(output, {}, destination)).resolves.toBe("saved");
expect(saveFileMock).toHaveBeenCalledWith(output, destination);
expect(messageSuccessMock).toHaveBeenCalledWith("文件已保存");
});
it("uses persistent desktop activity for native save flows", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
+5 -3
View File
@@ -6,6 +6,7 @@ import {
saveFile,
type FileOutput,
type FilePickerOptions,
type SaveFileDestination,
type SaveFileResult,
} from "../runtime";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../session/desktopActivityCenter";
@@ -38,15 +39,16 @@ export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Pr
export const saveFileWithFeedback = async (
output: FileOutput,
options: FileTaskFeedbackOptions = {},
destination?: SaveFileDestination,
): Promise<SaveFileResult> => {
const kind = options.kind || "download";
if (!desktopFileActivitiesEnabled()) {
const result = await saveFile(output);
const result = destination ? await saveFile(output, destination) : await saveFile(output);
if (result === "cancelled") {
ElMessage.info("已取消保存文件");
return result;
}
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
ElMessage.success(destination?.kind === "web-file-handle" ? "文件已保存" : "文件下载已开始");
return result;
}
@@ -57,7 +59,7 @@ export const saveFileWithFeedback = async (
});
updateDesktopActivity(activityId, { detail: options.pendingDetail || "等待选择保存位置", progress: 70 });
try {
const result = await saveFile(output);
const result = destination ? await saveFile(output, destination) : await saveFile(output);
if (result === "cancelled") {
finishDesktopActivity(activityId, "cancelled", { detail: options.cancelledDetail || "已取消保存" });
return result;
@@ -62,6 +62,29 @@ describe("DocumentDetail permissions", () => {
expect(source).toContain("TEXT.common.messages.previewNotSupported");
expect(source).toContain("URL.createObjectURL(blob)");
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
expect(source).toContain("<PdfViewer");
expect(source).not.toContain("<iframe");
});
it("keeps all four version actions visible and reports blob download errors", () => {
const source = readSource();
expect(source).toContain('columns.actions" width="220"');
expect(source).toContain("getApiErrorMessage");
expect(source).toContain("await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)");
expect(source).toContain("ElMessage.error(TEXT.common.messages.openFailed)");
});
it("preserves upload filenames and selects a web save destination before downloading", () => {
const source = readSource();
expect(source).toContain("version.original_filename?.trim()");
expect(source).toContain("uuidStorageName.test(name)");
expect(source).toContain("detail.title?.trim()");
expect(source).toContain("await prepareSaveFile(suggestedName)");
expect(source.indexOf("await prepareSaveFile(suggestedName)")).toBeLessThan(source.indexOf("const response = await downloadDocumentVersion(version.id)", source.indexOf("const downloadVersion")));
expect(source).toContain("getContentDispositionFilename");
expect(source).not.toContain("const getFilename =");
});
it("prefers backend creator display data instead of exposing creator ids", () => {
+41 -28
View File
@@ -114,7 +114,7 @@
</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="160" fixed="right">
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="220" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button link type="primary" size="small" @click="previewVersion(row)" v-if="canReadDocument">
@@ -172,14 +172,14 @@
</div>
</template>
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" :width="previewType === 'pdf' ? 'min(86vw, 1440px)' : 'min(1180px, 94vw)'" :top="previewType === 'pdf' ? '8vh' : '3vh'" :close-on-click-modal="false" :show-close="previewType !== 'pdf'" :class="['pdf-preview-dialog', { 'pdf-preview-dialog--flush': previewType === 'pdf' }]">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<div v-loading="previewLoading" class="preview-body">
<img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" class="preview-frame" />
<PdfViewer v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" :filename="previewTitle" show-close @close="previewVisible = false" />
</div>
</template>
</el-dialog>
@@ -350,7 +350,7 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Edit, Upload, Share } from "@element-plus/icons-vue";
@@ -380,8 +380,12 @@ import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { getApiErrorMessage } from "../../utils/apiErrorMessage";
import { getContentDispositionFilename } from "../../utils/contentDisposition";
import { prepareSaveFile } from "../../runtime";
const route = useRoute();
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
const auth = useAuthStore();
const study = useStudyStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
@@ -753,24 +757,24 @@ const submitUpload = async () => {
});
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const getVersionFileName = (version: DocumentVersion) => {
if (version.original_filename?.trim()) return version.original_filename.trim();
const uri = version.file_uri || "";
const name = uri.split(/[\\/]/).filter(Boolean).pop() || "";
return name.toLowerCase();
const uuidStorageName = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(\.[^.]*)?$/i;
if (uuidStorageName.test(name)) {
const extension = name.match(/(\.[^.]*)$/)?.[1] || "";
const title = detail.title?.trim() || `document-${version.version_no || version.id}`;
return extension && title.toLowerCase().endsWith(extension.toLowerCase()) ? title : `${title}${extension}`;
}
return name;
};
const detectPreviewType = (version: DocumentVersion) => {
const mime = (version.mime_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getVersionFileName(version);
const name = getVersionFileName(version).toLowerCase();
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
@@ -786,7 +790,7 @@ const previewVersion = async (version: DocumentVersion) => {
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
clearPreviewObjectUrl();
previewType.value = detectPreviewType(version);
previewTitle.value = `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
previewTitle.value = getVersionFileName(version) || `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
previewUrl.value = "";
previewError.value = "";
previewVisible.value = true;
@@ -802,34 +806,49 @@ const previewVersion = async (version: DocumentVersion) => {
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
} catch (e: any) {
previewError.value = e?.response?.data?.message || TEXT.common.messages.previewNotSupported;
previewError.value = await getApiErrorMessage(e, TEXT.common.messages.previewNotSupported);
} finally {
previewLoading.value = false;
}
};
const downloadVersion = async (version: DocumentVersion) => {
const suggestedName = getVersionFileName(version) || `document-${version.version_no || version.id}.bin`;
try {
const destination = await prepareSaveFile(suggestedName);
if (!destination) {
ElMessage.info("已取消保存文件");
return;
}
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const filename = getContentDispositionFilename(response.headers?.["content-disposition"]) || suggestedName;
const blob = new Blob([response.data], { type: contentType });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob }, {}, destination);
} catch (e: any) { ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)); }
};
const openVersion = async (version: DocumentVersion) => {
let response;
try {
response = await downloadDocumentVersion(version.id);
} catch (e: any) {
ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed));
return;
}
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const filename = getContentDispositionFilename(response.headers?.["content-disposition"])
|| getVersionFileName(version)
|| `document-${version.version_no || version.id}.bin`;
await openFileWithFeedback({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
} catch {
ElMessage.error(TEXT.common.messages.openFailed);
}
};
@@ -1119,12 +1138,6 @@ onBeforeUnmount(() => {
min-height: 120px;
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS frontend-build
FROM node:22.13-alpine AS frontend-build
WORKDIR /app
+2 -2
View File
@@ -15,7 +15,7 @@ ${C_BOLD}环境:${C_RESET}
${C_BOLD}说明:${C_RESET}
默认仅停止并移除对应 Compose 项目的容器和网络。
不删除 .env、pg_data、Docker volume 或镜像。
不删除 .env、pg_data、backend/app/uploads、Docker volume 或镜像。
EOF
}
@@ -33,7 +33,7 @@ main() {
ctms_require_docker_compose
ctms_confirm_danger \
"停止并移除 CTMS 容器" \
"Compose 项目 ${project};不会删除 .env、pg_data、volume 或镜像。" \
"Compose 项目 ${project};不会删除 .env、pg_data、backend/app/uploads、volume 或镜像。" \
"服务会停止,对外访问中断;数据默认保留。"
cd "$CTMS_ROOT_DIR"