功能(文档与桌面):完善文件预览下载与客户端构建基线
- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
This commit is contained in:
@@ -50,7 +50,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22.13"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22.13"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22.13"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "22.13"
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: frontend/package-lock.json
|
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")
|
||||||
@@ -47,6 +47,7 @@ class DocumentVersion(Base):
|
|||||||
effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
superseded_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)
|
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_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||||
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class DocumentVersionRead(BaseModel):
|
|||||||
effective_at: Optional[datetime] = None
|
effective_at: Optional[datetime] = None
|
||||||
superseded_at: Optional[datetime] = None
|
superseded_at: Optional[datetime] = None
|
||||||
file_uri: str
|
file_uri: str
|
||||||
|
original_filename: Optional[str] = None
|
||||||
file_hash: str
|
file_hash: str
|
||||||
file_size: int
|
file_size: int
|
||||||
mime_type: Optional[str] = None
|
mime_type: Optional[str] = None
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import HTTPException, UploadFile, status
|
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:
|
def _audit_detail(before: dict | None, after: dict | None) -> str:
|
||||||
payload = {"before": before, "after": after}
|
payload = {"before": before, "after": after}
|
||||||
return json.dumps(payload, ensure_ascii=True)
|
return json.dumps(payload, ensure_ascii=True)
|
||||||
@@ -356,7 +380,8 @@ async def create_version(
|
|||||||
file_hash = hashlib.sha256(content).hexdigest()
|
file_hash = hashlib.sha256(content).hexdigest()
|
||||||
dest_dir = UPLOAD_ROOT / str(document_id)
|
dest_dir = UPLOAD_ROOT / str(document_id)
|
||||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
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
|
dest_path = dest_dir / unique_name
|
||||||
async with aiofiles.open(dest_path, "wb") as out_file:
|
async with aiofiles.open(dest_path, "wb") as out_file:
|
||||||
await out_file.write(content)
|
await out_file.write(content)
|
||||||
@@ -377,6 +402,7 @@ async def create_version(
|
|||||||
status=DocumentVersionStatus.EFFECTIVE,
|
status=DocumentVersionStatus.EFFECTIVE,
|
||||||
effective_at=effective_at,
|
effective_at=effective_at,
|
||||||
file_uri=str(dest_path),
|
file_uri=str(dest_path),
|
||||||
|
original_filename=original_filename,
|
||||||
file_hash=file_hash,
|
file_hash=file_hash,
|
||||||
file_size=len(content),
|
file_size=len(content),
|
||||||
mime_type=file.content_type,
|
mime_type=file.content_type,
|
||||||
@@ -552,12 +578,11 @@ async def get_version_download_response(
|
|||||||
file_path = Path(version.file_uri)
|
file_path = Path(version.file_uri)
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
|
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(
|
return FileResponse(
|
||||||
path=str(file_path),
|
path=str(file_path),
|
||||||
filename=filename,
|
|
||||||
media_type=version.mime_type or "application/octet-stream",
|
media_type=version.mime_type or "application/octet-stream",
|
||||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
headers={"Content-Disposition": _content_disposition(filename)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
service: backend-init
|
service: backend-init
|
||||||
|
|
||||||
frontend-dev:
|
frontend-dev:
|
||||||
image: node:20-alpine
|
image: node:22.13-alpine
|
||||||
container_name: ctms_frontend_dev
|
container_name: ctms_frontend_dev
|
||||||
restart: always
|
restart: always
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
@@ -25,16 +25,17 @@ services:
|
|||||||
- |
|
- |
|
||||||
set -e
|
set -e
|
||||||
lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')"
|
lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')"
|
||||||
|
dependency_hash="$$lock_hash:$$(node --version)"
|
||||||
marker="node_modules/.ctms-package-lock.sha256"
|
marker="node_modules/.ctms-package-lock.sha256"
|
||||||
missing_runtime_deps=0
|
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
|
if [ ! -d "node_modules/$$dep" ]; then
|
||||||
missing_runtime_deps=1
|
missing_runtime_deps=1
|
||||||
fi
|
fi
|
||||||
done
|
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
|
npm ci
|
||||||
printf '%s' "$$lock_hash" > "$$marker"
|
printf '%s' "$$dependency_hash" > "$$marker"
|
||||||
fi
|
fi
|
||||||
npm run dev -- --host 0.0.0.0
|
npm run dev -- --host 0.0.0.0
|
||||||
environment:
|
environment:
|
||||||
@@ -50,7 +51,7 @@ services:
|
|||||||
start_period: 5s
|
start_period: 5s
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- frontend_node_modules:/app/node_modules
|
- frontend_node_modules_node22:/app/node_modules
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
@@ -75,7 +76,7 @@ services:
|
|||||||
- ctms_net
|
- ctms_net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
frontend_node_modules:
|
frontend_node_modules_node22:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
ctms_net:
|
ctms_net:
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
# 文档版本和附件必须跨容器重建保留;数据库中的 file_uri 统一指向 /code/app/uploads。
|
||||||
|
- ./backend/app/uploads:/code/app/uploads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ npm run desktop:build:app
|
|||||||
- [ ] updater public key 已配置。
|
- [ ] updater public key 已配置。
|
||||||
- [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。
|
- [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。
|
||||||
- [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。
|
- [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。
|
||||||
- [ ] 文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。
|
- [ ] 文件系统仅开放临时文件所需的 read/write/mkdir/remove 命令,且文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。
|
||||||
- [ ] 单实例插件先于其他桌面插件注册。
|
- [ ] 单实例插件先于其他桌面插件注册。
|
||||||
- [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。
|
- [ ] macOS 首个顶层 submenu 为应用菜单,包含关于、设置、服务、隐藏和退出;文件菜单保持独立。
|
||||||
- [ ] macOS 红色按钮保持真正关闭窗口的系统语义,Dock/Finder reopen 事件在需要时重建、显示并聚焦主窗口。
|
- [ ] macOS 红色按钮保持真正关闭窗口的系统语义,Dock/Finder reopen 事件在需要时重建、显示并聚焦主窗口。
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ git checkout
|
|||||||
git pull
|
git pull
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
删除 pg_data
|
删除 pg_data
|
||||||
|
删除 backend/app/uploads
|
||||||
删除数据库 volume
|
删除数据库 volume
|
||||||
自动安装系统依赖
|
自动安装系统依赖
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ Manual edits that leave these files inconsistent fail CI.
|
|||||||
|
|
||||||
## Stabilization Gates
|
## 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
|
Client release candidates must pass the shared Web checks and the Desktop
|
||||||
release/security gate before promotion:
|
release/security gate before promotion:
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
## 1.1 容器构建校验
|
## 1.1 容器构建校验
|
||||||
- [ ] `docker compose config` 渲染成功
|
- [ ] `docker compose config` 渲染成功
|
||||||
|
- [ ] 后端 `/code/app/uploads` 已持久化挂载到宿主机 `backend/app/uploads`
|
||||||
- [ ] `docker compose build backend frontend` 成功
|
- [ ] `docker compose build backend frontend` 成功
|
||||||
- [ ] `docker compose run --rm --build backend-init` 成功
|
- [ ] `docker compose run --rm --build backend-init` 成功
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:22.13-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
Generated
+281
@@ -18,6 +18,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.4.4",
|
"element-plus": "^2.4.4",
|
||||||
|
"pdfjs-dist": "6.1.200",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "^8.0.1",
|
||||||
@@ -34,6 +35,9 @@
|
|||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^3.2.4",
|
||||||
"vue-tsc": "^3.2.2"
|
"vue-tsc": "^3.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
@@ -707,6 +711,271 @@
|
|||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@one-ini/wasm": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
|
||||||
@@ -3186,6 +3455,18 @@
|
|||||||
"node": ">= 14.16"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"element-plus": "^2.4.4",
|
"element-plus": "^2.4.4",
|
||||||
|
"pdfjs-dist": "6.1.200",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.4.15",
|
||||||
"vue-echarts": "^8.0.1",
|
"vue-echarts": "^8.0.1",
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ const verifyCapabilities = async () => {
|
|||||||
"notification:allow-request-permission",
|
"notification:allow-request-permission",
|
||||||
"notification:allow-notify",
|
"notification:allow-notify",
|
||||||
];
|
];
|
||||||
|
const requiredTemporaryFilePermissions = [
|
||||||
|
"fs:allow-read-file",
|
||||||
|
"fs:allow-write-file",
|
||||||
|
"fs:allow-mkdir",
|
||||||
|
"fs:allow-remove",
|
||||||
|
];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const capability = await readJson(resolve(capabilitiesDir, file));
|
const capability = await readJson(resolve(capabilitiesDir, file));
|
||||||
@@ -141,6 +147,9 @@ const verifyCapabilities = async () => {
|
|||||||
for (const identifier of requiredNotificationPermissions) {
|
for (const identifier of requiredNotificationPermissions) {
|
||||||
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
|
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");
|
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
|
||||||
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
|
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 verifyWorkflowGates = async () => {
|
||||||
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
|
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 = [
|
const requiredScripts = [
|
||||||
"desktop:build:macos-release",
|
"desktop:build:macos-release",
|
||||||
"desktop:update-feed:create",
|
"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_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.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 releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
|
||||||
const requiredReleaseWorkflowTokens = [
|
const requiredReleaseWorkflowTokens = [
|
||||||
@@ -368,6 +379,7 @@ const verifyWorkflowGates = async () => {
|
|||||||
for (const token of requiredReleaseWorkflowTokens) {
|
for (const token of requiredReleaseWorkflowTokens) {
|
||||||
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
|
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 windowsInternalWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-windows-internal.yml"), "utf8");
|
||||||
const requiredWindowsInternalWorkflowTokens = [
|
const requiredWindowsInternalWorkflowTokens = [
|
||||||
@@ -407,6 +419,22 @@ const verifyWorkflowGates = async () => {
|
|||||||
for (const token of forbiddenWindowsInternalWorkflowTokens) {
|
for (const token of forbiddenWindowsInternalWorkflowTokens) {
|
||||||
assert(!windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must not include ${token}.`);
|
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();
|
await verifyTauriConfig();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"fs:allow-read-file",
|
"fs:allow-read-file",
|
||||||
"fs:allow-write-file",
|
"fs:allow-write-file",
|
||||||
|
"fs:allow-mkdir",
|
||||||
"fs:allow-remove",
|
"fs:allow-remove",
|
||||||
{
|
{
|
||||||
"identifier": "fs:scope",
|
"identifier": "fs:scope",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const uploadDocumentVersion = (documentId: string, payload: FormData) =>
|
|||||||
export const downloadDocumentVersion = (versionId: string) =>
|
export const downloadDocumentVersion = (versionId: string) =>
|
||||||
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
|
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
|
suppressErrorMessage: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteDocumentVersion = (versionId: string) =>
|
export const deleteDocumentVersion = (versionId: string) =>
|
||||||
|
|||||||
@@ -519,6 +519,18 @@ describe("desktop layout shell", () => {
|
|||||||
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
|
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", () => {
|
it("surfaces persistent desktop activity feedback without local task persistence", () => {
|
||||||
const desktopLayout = readDesktopLayoutSource();
|
const desktopLayout = readDesktopLayoutSource();
|
||||||
const activityCenter = readDesktopActivityCenterSource();
|
const activityCenter = readDesktopActivityCenterSource();
|
||||||
@@ -595,7 +607,8 @@ describe("desktop layout shell", () => {
|
|||||||
expect(styles).toContain("backdrop-filter: blur(12px) saturate(135%);");
|
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-dialog");
|
||||||
expect(styles).toContain("body.is-desktop-runtime .el-overlay-message-box");
|
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("body.is-desktop-runtime .el-message-box");
|
||||||
expect(styles).toContain("border-radius: 12px;");
|
expect(styles).toContain("border-radius: 12px;");
|
||||||
expect(styles).toContain("body.is-desktop-runtime .el-dialog__header");
|
expect(styles).toContain("body.is-desktop-runtime .el-dialog__header");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
<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'">
|
<template v-if="displayMode === 'table'">
|
||||||
<div v-if="showHeader" class="header">
|
<div v-if="showHeader" class="header">
|
||||||
<span>{{ headerTitle }}</span>
|
<span>{{ headerTitle }}</span>
|
||||||
@@ -60,7 +66,14 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</template>
|
</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">
|
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
|
||||||
<el-upload
|
<el-upload
|
||||||
v-if="!nativeFiles"
|
v-if="!nativeFiles"
|
||||||
@@ -92,7 +105,10 @@
|
|||||||
'is-disabled': !canUploadGroup(group),
|
'is-disabled': !canUploadGroup(group),
|
||||||
'attachment-upload-card--centered': centerUploadCardContent,
|
'attachment-upload-card--centered': centerUploadCardContent,
|
||||||
}"
|
}"
|
||||||
|
role="button"
|
||||||
|
:tabindex="canUploadGroup(group) ? 0 : -1"
|
||||||
@click="pickPendingNative(group)"
|
@click="pickPendingNative(group)"
|
||||||
|
@keydown.enter.space.prevent="pickPendingNative(group)"
|
||||||
>
|
>
|
||||||
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
|
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
|
||||||
<div class="upload-trigger">
|
<div class="upload-trigger">
|
||||||
@@ -107,11 +123,17 @@
|
|||||||
class="pending-file"
|
class="pending-file"
|
||||||
:class="{ 'is-failed': item.status === 'failed' }"
|
:class="{ 'is-failed': item.status === 'failed' }"
|
||||||
>
|
>
|
||||||
<span class="pending-file-name">{{ item.file.name }}</span>
|
<span class="pending-file-icon" aria-hidden="true">
|
||||||
<span v-if="item.status === 'failed'" class="pending-file-status">
|
<el-icon><Document /></el-icon>
|
||||||
{{ item.error || TEXT.common.messages.uploadFailed }}
|
|
||||||
</span>
|
</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 }}
|
{{ TEXT.common.actions.remove }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,13 +147,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div v-if="previewError" class="preview-error">
|
||||||
{{ previewError }}
|
{{ previewError }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
|
<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">
|
<div v-else class="preview-error">
|
||||||
{{ TEXT.common.messages.previewNotSupported }}
|
{{ TEXT.common.messages.previewNotSupported }}
|
||||||
</div>
|
</div>
|
||||||
@@ -140,9 +162,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { 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 { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
||||||
import { formatFileSize } from "./attachmentUtils";
|
import { formatFileSize } from "./attachmentUtils";
|
||||||
import { useAuthStore } from "../../store/auth";
|
import { useAuthStore } from "../../store/auth";
|
||||||
@@ -162,6 +184,8 @@ type AttachmentEntityGroup = {
|
|||||||
uploadText?: string;
|
uploadText?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PdfViewer = defineAsyncComponent(() => import("../PdfViewer.vue"));
|
||||||
|
|
||||||
type UploadGroup = {
|
type UploadGroup = {
|
||||||
key: string;
|
key: string;
|
||||||
label: 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));
|
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 = () =>
|
const pendingSnapshot = () =>
|
||||||
uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
|
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));
|
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
|
||||||
@@ -580,6 +610,11 @@ defineExpose({
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attachment-list-panel--upload {
|
||||||
|
overflow: visible;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -621,6 +656,10 @@ defineExpose({
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-grid--single {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-upload-item {
|
.attachment-upload-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -632,6 +671,7 @@ defineExpose({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.attachment-upload-card {
|
.attachment-upload-card {
|
||||||
|
box-sizing: border-box;
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px dashed #d0dced;
|
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;
|
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:hover,
|
||||||
.attachment-upload-card:focus-within {
|
.attachment-upload-card:focus-within {
|
||||||
border-color: var(--ctms-primary);
|
border-color: var(--ctms-primary);
|
||||||
@@ -709,50 +757,90 @@ defineExpose({
|
|||||||
.pending-list {
|
.pending-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending-file {
|
.pending-file {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
min-width: 0;
|
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);
|
color: var(--ctms-text-regular);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending-file.is-failed {
|
.pending-file.is-failed {
|
||||||
|
border-color: #fecaca;
|
||||||
|
background: #fff7f7;
|
||||||
color: var(--ctms-danger, #f56c6c);
|
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;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-file-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
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 {
|
.pending-file-status {
|
||||||
flex: 0 0 auto;
|
color: #64748b;
|
||||||
color: var(--ctms-danger, #f56c6c);
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
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) {
|
@media (max-width: 640px) {
|
||||||
.upload-grid {
|
.upload-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-frame {
|
|
||||||
width: 100%;
|
|
||||||
height: 520px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-media {
|
.preview-media {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const TEXT = {
|
|||||||
uploadSuccess: "上传成功",
|
uploadSuccess: "上传成功",
|
||||||
uploadFailed: "上传失败",
|
uploadFailed: "上传失败",
|
||||||
downloadFailed: "下载失败",
|
downloadFailed: "下载失败",
|
||||||
|
openFailed: "文件打开失败",
|
||||||
actionFailed: "操作失败",
|
actionFailed: "操作失败",
|
||||||
noPermission: "无权限执行该操作",
|
noPermission: "无权限执行该操作",
|
||||||
networkError: "网络错误,请稍后重试",
|
networkError: "网络错误,请稍后重试",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,25 @@ export interface FileOutput {
|
|||||||
|
|
||||||
export type SaveFileResult = "saved" | "cancelled";
|
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 sanitizeFileName = (value: string): string => {
|
||||||
const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim();
|
const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim();
|
||||||
return normalized.slice(0, 180) || "download";
|
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> => {
|
export const prepareSaveFile = async (suggestedName: string): Promise<SaveFileDestination | null> => {
|
||||||
const suggestedName = sanitizeFileName(output.suggestedName);
|
const safeName = sanitizeFileName(suggestedName);
|
||||||
if (!isTauriRuntime()) {
|
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 url = URL.createObjectURL(toBlob(output));
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
@@ -97,13 +153,8 @@ export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
|
|||||||
return "saved";
|
return "saved";
|
||||||
}
|
}
|
||||||
|
|
||||||
const [{ save }, { writeFile }] = await Promise.all([
|
const { writeFile } = await import("@tauri-apps/plugin-fs");
|
||||||
import("@tauri-apps/plugin-dialog"),
|
await writeFile(destination.path, await toBytes(output));
|
||||||
import("@tauri-apps/plugin-fs"),
|
|
||||||
]);
|
|
||||||
const path = await save({ defaultPath: suggestedName });
|
|
||||||
if (!path) return "cancelled";
|
|
||||||
await writeFile(path, await toBytes(output));
|
|
||||||
return "saved";
|
return "saved";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ export {
|
|||||||
cleanupTemporaryFiles,
|
cleanupTemporaryFiles,
|
||||||
openFile,
|
openFile,
|
||||||
pickFiles,
|
pickFiles,
|
||||||
|
prepareSaveFile,
|
||||||
saveFile,
|
saveFile,
|
||||||
type FileOutput,
|
type FileOutput,
|
||||||
type FilePickerOptions,
|
type FilePickerOptions,
|
||||||
|
type SaveFileDestination,
|
||||||
type SaveFileResult,
|
type SaveFileResult,
|
||||||
} from "./files";
|
} from "./files";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1038,7 +1038,7 @@ body.is-desktop-runtime .el-overlay-message-box {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.is-desktop-runtime .el-dialog,
|
body.is-desktop-runtime :where(.el-dialog),
|
||||||
body.is-desktop-runtime .el-message-box {
|
body.is-desktop-runtime .el-message-box {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 auto !important;
|
margin: 0 auto !important;
|
||||||
@@ -1052,7 +1052,7 @@ body.is-desktop-runtime .el-message-box {
|
|||||||
color: #0f172a;
|
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));
|
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 .desktop-preferences-dialog,
|
||||||
body.is-desktop-runtime .user-form-dialog,
|
body.is-desktop-runtime .user-form-dialog,
|
||||||
body.is-desktop-runtime .reset-password-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;
|
overflow: visible !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
@@ -1070,6 +1071,26 @@ body.is-desktop-runtime .project-form-dialog {
|
|||||||
backdrop-filter: none !important;
|
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 {
|
body.is-desktop-runtime .el-message-box {
|
||||||
width: min(420px, calc(100vw - 72px));
|
width: min(420px, calc(100vw - 72px));
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -1105,7 +1126,8 @@ body.is-desktop-runtime .el-message-box__headerbtn .el-message-box__close {
|
|||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.is-desktop-runtime .el-dialog__body {
|
/* 低优先级桌面基线:普通弹窗保留间距,带专属布局的弹窗可直接覆盖。 */
|
||||||
|
body.is-desktop-runtime :where(.el-dialog__body) {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
color: #334155;
|
color: #334155;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface DocumentVersion {
|
|||||||
effective_at?: string | null;
|
effective_at?: string | null;
|
||||||
superseded_at?: string | null;
|
superseded_at?: string | null;
|
||||||
file_uri: string;
|
file_uri: string;
|
||||||
|
original_filename?: string | null;
|
||||||
file_hash: string;
|
file_hash: string;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
mime_type?: string | null;
|
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("下载失败");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("文件下载已开始");
|
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 () => {
|
it("uses persistent desktop activity for native save flows", async () => {
|
||||||
capabilitiesMock.mockReturnValue({ nativeFiles: true });
|
capabilitiesMock.mockReturnValue({ nativeFiles: true });
|
||||||
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
|
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
saveFile,
|
saveFile,
|
||||||
type FileOutput,
|
type FileOutput,
|
||||||
type FilePickerOptions,
|
type FilePickerOptions,
|
||||||
|
type SaveFileDestination,
|
||||||
type SaveFileResult,
|
type SaveFileResult,
|
||||||
} from "../runtime";
|
} from "../runtime";
|
||||||
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../session/desktopActivityCenter";
|
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../session/desktopActivityCenter";
|
||||||
@@ -38,15 +39,16 @@ export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Pr
|
|||||||
export const saveFileWithFeedback = async (
|
export const saveFileWithFeedback = async (
|
||||||
output: FileOutput,
|
output: FileOutput,
|
||||||
options: FileTaskFeedbackOptions = {},
|
options: FileTaskFeedbackOptions = {},
|
||||||
|
destination?: SaveFileDestination,
|
||||||
): Promise<SaveFileResult> => {
|
): Promise<SaveFileResult> => {
|
||||||
const kind = options.kind || "download";
|
const kind = options.kind || "download";
|
||||||
if (!desktopFileActivitiesEnabled()) {
|
if (!desktopFileActivitiesEnabled()) {
|
||||||
const result = await saveFile(output);
|
const result = destination ? await saveFile(output, destination) : await saveFile(output);
|
||||||
if (result === "cancelled") {
|
if (result === "cancelled") {
|
||||||
ElMessage.info("已取消保存文件");
|
ElMessage.info("已取消保存文件");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
|
ElMessage.success(destination?.kind === "web-file-handle" ? "文件已保存" : "文件下载已开始");
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ export const saveFileWithFeedback = async (
|
|||||||
});
|
});
|
||||||
updateDesktopActivity(activityId, { detail: options.pendingDetail || "等待选择保存位置", progress: 70 });
|
updateDesktopActivity(activityId, { detail: options.pendingDetail || "等待选择保存位置", progress: 70 });
|
||||||
try {
|
try {
|
||||||
const result = await saveFile(output);
|
const result = destination ? await saveFile(output, destination) : await saveFile(output);
|
||||||
if (result === "cancelled") {
|
if (result === "cancelled") {
|
||||||
finishDesktopActivity(activityId, "cancelled", { detail: options.cancelledDetail || "已取消保存" });
|
finishDesktopActivity(activityId, "cancelled", { detail: options.cancelledDetail || "已取消保存" });
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -62,6 +62,29 @@ describe("DocumentDetail permissions", () => {
|
|||||||
expect(source).toContain("TEXT.common.messages.previewNotSupported");
|
expect(source).toContain("TEXT.common.messages.previewNotSupported");
|
||||||
expect(source).toContain("URL.createObjectURL(blob)");
|
expect(source).toContain("URL.createObjectURL(blob)");
|
||||||
expect(source).toContain("URL.revokeObjectURL(previewObjectUrl.value)");
|
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", () => {
|
it("prefers backend creator display data instead of exposing creator ids", () => {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<div class="cell-actions">
|
<div class="cell-actions">
|
||||||
<el-button link type="primary" size="small" @click="previewVersion(row)" v-if="canReadDocument">
|
<el-button link type="primary" size="small" @click="previewVersion(row)" v-if="canReadDocument">
|
||||||
@@ -172,14 +172,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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">
|
<div v-if="previewError" class="preview-error">
|
||||||
{{ previewError }}
|
{{ previewError }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-loading="previewLoading" class="preview-body">
|
<div v-loading="previewLoading" class="preview-body">
|
||||||
<img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -350,7 +350,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||||
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
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 StateLoading from "../../components/StateLoading.vue";
|
||||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||||
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
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 route = useRoute();
|
||||||
|
const PdfViewer = defineAsyncComponent(() => import("../../components/PdfViewer.vue"));
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
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) => {
|
const getVersionFileName = (version: DocumentVersion) => {
|
||||||
|
if (version.original_filename?.trim()) return version.original_filename.trim();
|
||||||
const uri = version.file_uri || "";
|
const uri = version.file_uri || "";
|
||||||
const name = uri.split(/[\\/]/).filter(Boolean).pop() || "";
|
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 detectPreviewType = (version: DocumentVersion) => {
|
||||||
const mime = (version.mime_type || "").toLowerCase();
|
const mime = (version.mime_type || "").toLowerCase();
|
||||||
if (mime.startsWith("image/")) return "image";
|
if (mime.startsWith("image/")) return "image";
|
||||||
if (mime === "application/pdf") return "pdf";
|
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 ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
|
||||||
if (name.endsWith(".pdf")) return "pdf";
|
if (name.endsWith(".pdf")) return "pdf";
|
||||||
return "other";
|
return "other";
|
||||||
@@ -786,7 +790,7 @@ const previewVersion = async (version: DocumentVersion) => {
|
|||||||
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
|
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||||
clearPreviewObjectUrl();
|
clearPreviewObjectUrl();
|
||||||
previewType.value = detectPreviewType(version);
|
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 = "";
|
previewUrl.value = "";
|
||||||
previewError.value = "";
|
previewError.value = "";
|
||||||
previewVisible.value = true;
|
previewVisible.value = true;
|
||||||
@@ -802,34 +806,49 @@ const previewVersion = async (version: DocumentVersion) => {
|
|||||||
previewObjectUrl.value = URL.createObjectURL(blob);
|
previewObjectUrl.value = URL.createObjectURL(blob);
|
||||||
previewUrl.value = previewObjectUrl.value;
|
previewUrl.value = previewObjectUrl.value;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
previewError.value = e?.response?.data?.message || TEXT.common.messages.previewNotSupported;
|
previewError.value = await getApiErrorMessage(e, TEXT.common.messages.previewNotSupported);
|
||||||
} finally {
|
} finally {
|
||||||
previewLoading.value = false;
|
previewLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadVersion = async (version: DocumentVersion) => {
|
const downloadVersion = async (version: DocumentVersion) => {
|
||||||
|
const suggestedName = getVersionFileName(version) || `document-${version.version_no || version.id}.bin`;
|
||||||
try {
|
try {
|
||||||
|
const destination = await prepareSaveFile(suggestedName);
|
||||||
|
if (!destination) {
|
||||||
|
ElMessage.info("已取消保存文件");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const response = await downloadDocumentVersion(version.id);
|
const response = await downloadDocumentVersion(version.id);
|
||||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
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 });
|
const blob = new Blob([response.data], { type: contentType });
|
||||||
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
|
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob }, {}, destination);
|
||||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
|
} catch (e: any) { ElMessage.error(await getApiErrorMessage(e, TEXT.common.messages.downloadFailed)); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const openVersion = async (version: DocumentVersion) => {
|
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 {
|
try {
|
||||||
const response = await downloadDocumentVersion(version.id);
|
|
||||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
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({
|
await openFileWithFeedback({
|
||||||
suggestedName: filename,
|
suggestedName: filename,
|
||||||
mimeType: contentType,
|
mimeType: contentType,
|
||||||
data: new Blob([response.data], { type: contentType }),
|
data: new Blob([response.data], { type: contentType }),
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch {
|
||||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
|
ElMessage.error(TEXT.common.messages.openFailed);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1119,12 +1138,6 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-frame {
|
|
||||||
width: 100%;
|
|
||||||
height: 520px;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-media {
|
.preview-media {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 520px;
|
max-height: 520px;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-build
|
FROM node:22.13-alpine AS frontend-build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ ${C_BOLD}环境:${C_RESET}
|
|||||||
|
|
||||||
${C_BOLD}说明:${C_RESET}
|
${C_BOLD}说明:${C_RESET}
|
||||||
默认仅停止并移除对应 Compose 项目的容器和网络。
|
默认仅停止并移除对应 Compose 项目的容器和网络。
|
||||||
不删除 .env、pg_data、Docker volume 或镜像。
|
不删除 .env、pg_data、backend/app/uploads、Docker volume 或镜像。
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ main() {
|
|||||||
ctms_require_docker_compose
|
ctms_require_docker_compose
|
||||||
ctms_confirm_danger \
|
ctms_confirm_danger \
|
||||||
"停止并移除 CTMS 容器" \
|
"停止并移除 CTMS 容器" \
|
||||||
"Compose 项目 ${project};不会删除 .env、pg_data、volume 或镜像。" \
|
"Compose 项目 ${project};不会删除 .env、pg_data、backend/app/uploads、volume 或镜像。" \
|
||||||
"服务会停止,对外访问中断;数据默认保留。"
|
"服务会停止,对外访问中断;数据默认保留。"
|
||||||
|
|
||||||
cd "$CTMS_ROOT_DIR"
|
cd "$CTMS_ROOT_DIR"
|
||||||
|
|||||||
Reference in New Issue
Block a user