Compare commits

8 Commits

Author SHA1 Message Date
Cheng Zhou d082920ae1 style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标 2026-07-08 20:20:41 +08:00
Cheng Zhou 7d88d6053a 优化桌面端界面布局 2026-07-07 11:11:43 +08:00
Cheng Zhou 4aa1eb00b6 feat(desktop): 收口桌面工作台视觉与活动反馈 2026-07-02 15:52:16 +08:00
Cheng Zhou 5b387226a3 完善桌面体验与系统通知收口 2026-07-02 15:13:08 +08:00
Cheng Zhou a7b631f468 完善桌面端回归与安全边界复审 2026-07-02 10:43:30 +08:00
Cheng Zhou c385ca7de9 补齐桌面端附件文件流回归 2026-07-02 10:13:45 +08:00
Cheng Zhou 9a470d3a75 完善桌面端端到端回归收口 2026-07-02 10:04:31 +08:00
Cheng Zhou 965c38d3b2 完善桌面端发布稳定化门禁 2026-07-02 09:41:55 +08:00
84 changed files with 9002 additions and 1100 deletions
@@ -0,0 +1,134 @@
name: Desktop Release Candidate
on:
workflow_dispatch:
inputs:
artifact_base_url:
description: "Versioned HTTPS prefix for immutable desktop artifacts, for example https://ctms.example.com/desktop-updates/stable/v0.1.0/"
required: true
type: string
push:
tags:
- "v*"
permissions:
contents: read
jobs:
macos-release-candidate:
name: Signed macOS release candidate
runs-on: macos-latest
defaults:
run:
working-directory: frontend
env:
VITE_BUILD_CHANNEL: release
VITE_BUILD_COMMIT: ${{ github.sha }}
RELEASE_BUILD: "true"
REQUIRE_DESKTOP_SIGNING: "true"
DESKTOP_UPDATE_BASE_URL: ${{ github.event_name == 'workflow_dispatch' && inputs.artifact_base_url || vars.DESKTOP_UPDATE_BASE_URL }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
steps:
- name: Checkout release source
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Enforce release tag context
shell: bash
run: |
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
echo "Signed desktop release candidates must run from a vX.Y.Z tag."
exit 1
fi
expected_tag="v$(node -p "require('./package.json').version")"
if [[ "${GITHUB_REF_NAME}" != "${expected_tag}" ]]; then
echo "Release tag ${GITHUB_REF_NAME} does not match package version ${expected_tag}."
exit 1
fi
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,x86_64-apple-darwin
- name: Install dependencies
run: npm ci
- name: Check release build metadata and signing environment
run: npm run release:env:check
- name: Check signed desktop release readiness
run: npm run desktop:release-readiness:check
- name: Check synchronized client version
run: npm run version:check
- name: Check runtime boundary
run: npm run runtime:check
- name: Check desktop release and security gate
run: npm run desktop:release:check
- name: Check UI contract
run: npm run ui:contract
- name: Type check
run: npm run type-check
- name: Unit tests
run: npm run test:unit
- name: Build Web artifact
run: npm run build
- name: Build signed Universal macOS artifacts
run: npm run desktop:build:macos-release -- --ci
- name: Create desktop update feed
shell: bash
run: |
if [[ -z "${DESKTOP_UPDATE_BASE_URL}" ]]; then
echo "DESKTOP_UPDATE_BASE_URL or workflow input artifact_base_url is required."
exit 1
fi
artifact="$(find src-tauri/target -path '*/release/bundle/macos/*.app.tar.gz' -print -quit)"
if [[ -z "${artifact}" ]]; then
echo "No macOS updater artifact was produced."
exit 1
fi
include_args=()
dmg="$(find src-tauri/target -path '*/release/bundle/dmg/*.dmg' -print -quit)"
if [[ -n "${dmg}" ]]; then
include_args+=(--include "${dmg}")
fi
npm run desktop:update-feed:create -- \
--artifact "${artifact}" \
"${include_args[@]}" \
--output-dir src-tauri/target/desktop-release-feed \
--base-url "${DESKTOP_UPDATE_BASE_URL}"
- name: Verify desktop update feed
run: npm run desktop:update-feed:check -- --feed src-tauri/target/desktop-release-feed/latest.json --artifacts-dir src-tauri/target/desktop-release-feed --base-url "${DESKTOP_UPDATE_BASE_URL}"
- name: Upload verified desktop release directory
uses: actions/upload-artifact@v4
with:
name: ctms-desktop-release-${{ github.ref_name }}
path: frontend/src-tauri/target/desktop-release-feed/*
if-no-files-found: error
@@ -2,7 +2,7 @@
状态: `active`
适用范围: Web 与 macOS Desktop 统一客户端发布
最后更新: `2026-07-01`
最后更新: `2026-07-02`
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。
@@ -28,10 +28,12 @@ npm run desktop:build:app
- [ ] `frontend/package.json``package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
- [ ] `VITE_BUILD_CHANNEL=release``VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
- [ ] 在正式 release tag 和签名环境中执行 `npm run desktop:release-readiness:check`,确认 tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址齐备。
- [ ] macOS app 已签名和公证。
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY``TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build -- --bundles app`
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <latest.json> --artifacts-dir <artifact-dir>`
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY``TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build:macos-release -- --ci`
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>` 生成 `latest.json``SHA256SUMS.txt`
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>`,并确认 checksum manifest、updater artifact、`.sig``latest.json` 均通过校验。
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
@@ -48,12 +50,19 @@ npm run desktop:build:app
- [ ] Tauri command 白名单仅包含凭据和更新命令。
- [ ] 前端源码不通过 query string 传递 token。
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
- [ ] 登录表单密码不写入 `localStorage``sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。
- [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。
- [ ] 通知 capability 只暴露权限查询、权限请求和发送通知,不使用 `notification:default`
- [ ] opener capability 只允许打开 `$TEMP/ctms-desktop/**` 下的临时文件,不开放 URL 或 reveal 权限。
- [ ] updater capability 不直接暴露给 WebView,自动更新只走受控 Tauri command。
- [ ] 更新弹窗 release notes 过滤 URL、token 查询参数和 Authorization/Bearer 形态文本。
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
- [ ] signed macOS release candidate workflow 只允许从 `vX.Y.Z` tag 运行,并包含签名环境检查、Universal macOS 构建、update feed 生成、checksum 校验和 verified release directory 上传。
人工复审还必须确认:
- [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。
- [ ] 密码不出现在 URL、日志、系统通知正文、诊断信息或明文浏览器存储中。
- [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。
- [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。
- [ ] Web 运行时不直接导入 Tauri API。
@@ -63,6 +72,7 @@ npm run desktop:build:app
| 场景 | Web | macOS Desktop | 预期 |
| --- | --- | --- | --- |
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
@@ -81,7 +91,8 @@ npm run desktop:build:app
## 4. 桌面体验验收
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不保存密码,不把 token 写入 URL、日志、通知正文或业务缓存。
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不把 token 写入 URL、日志、通知正文或业务缓存。
- [ ] 记住密码与 30 天免登录使用独立凭据记录;服务器切换后不复用旧服务器保存的密码。
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
@@ -127,3 +138,87 @@ npm run desktop:build:app
- 签名后的 updater artifacts、`.sig`、checksum manifest 和 `latest.json` 在真实发布目录内通过 `npm run desktop:update-feed:check`
- 不可变制品上传完成后,再原子替换线上 `latest.json`
- Desktop 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。
## 7. 2026-07-02 发布稳定化推进记录
本次推进补齐了发布链路自动化,不改变桌面端产品边界:
- 新增 `npm run desktop:build:macos-release`,封装 Universal macOS `app`/`dmg` release candidate 构建命令。
- 新增 `npm run desktop:update-feed:create`,从签名 updater artifact 和 `.sig` 生成 `latest.json`、复制发布目录文件并生成 `SHA256SUMS.txt`
- `npm run desktop:update-feed:check` 在传入 `--artifacts-dir` 时要求并校验 `SHA256SUMS.txt`
- 新增 `npm run desktop:release-readiness:check`,在正式签名候选构建前检查 release tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址。
- 新增 `.github/workflows/desktop-release-candidate.yml`,在 release tag 上执行签名候选构建、feed 生成、feed 校验并上传 verified release directory。
- `npm run desktop:release:check` 已检查上述脚本和 workflow,避免发布链路回退。
仍未自动完成、正式发布前必须人工确认:
- Apple Developer 凭据、证书、签名身份、公证结果和组织 updater 私钥。
- 生产下载源的不可变制品上传和线上 `latest.json` 原子替换。
- 真实环境下的自动更新安装、系统通知、单实例和完整人工回归。
## 8. 2026-07-02 端到端回归优化记录
本轮端到端优化仍保持在线桌面客户端边界,不引入离线、本地业务存储或本地权限裁决。
已完成的自动化收口:
- 服务器地址切换时,桌面设置页调用 `auth.logout({ rememberCurrentStudy: false })`,避免退出时把旧服务器项目记入当前用户的最近项目;随后继续清除当前项目上下文。
- 系统偏好连接页与独立服务器设置页保持同一切换服务器语义,切换时不记忆旧服务器项目并清除当前项目上下文。
- 系统通知轮询在部分通知显示失败或系统通知未实际发起时,先 ack 已成功发起系统通知的通知,再让失败项通过租约重试,贴合“显示成功后 ack;失败等待重试”的回归预期。
- 自动更新管理器新增稍后提醒 24 小时抑制、安装失败可重试、检查失败不打断业务和未启用更新状态的单元覆盖。
- 附件 API 新增 blob 下载、multipart 上传和删除端点单元覆盖,确保下载凭据继续由 axios Authorization header 承载而不是进入 URL。
- 文件任务反馈 helper 新增选择、保存、取消保存和打开的单元覆盖,约束桌面保存/打开继续走 `frontend/src/runtime/` 适配层。
- Keychain/凭据库会话新增旧浏览器 token 迁移、未配置服务器不读取凭据、本地 30 天上限、服务器切换删除旧服务器凭据,以及恢复 token 必须先经 `/me` 校验的单元覆盖。
- 桌面发布门禁新增单实例重复启动处理校验,要求重复启动时恢复、显示并聚焦 `main` 窗口。
- 会话刷新后的跨窗口 token 更新不再写入 `localStorage` fallback,只通过内存态 BroadcastChannel 通知,避免 token 进入明文广播缓存。
- `desktop:release:check` 新增 session broadcast 静态门禁,防止 `TOKEN_UPDATED` payload 回退写入 `localStorage`
- 新增相关单元测试覆盖服务器切换不记忆旧项目、通知权限未授权不领取、部分通知失败时只 ack 成功项、系统通知未发起时不 ack、自动更新失败恢复路径、附件文件流契约、30 天在线会话恢复边界、单实例恢复行为和 token 广播存储边界。
仍需人工或真实环境验证:
- Keychain/凭据库 30 天在线会话恢复。
- 原生附件上传、下载、保存和打开。
- 系统通知拒绝路径的 OS 级交互。
- 单实例重复启动聚焦主窗口。
- 签名 release 构建下的自动更新 feed、验签、安装和重启实物流。
## 9. 2026-07-02 安全边界复审记录
本轮安全复审在端到端自动化收口之后推进,不改变桌面端在线客户端边界。
已完成的安全边界收口:
- Tauri notification capability 从 `notification:default` 收敛为 `notification:allow-is-permission-granted``notification:allow-request-permission``notification:allow-notify`
- `desktop:release:check` 新增 capability 最小化约束,拒绝 `notification:default`、opener URL/reveal 权限和 WebView 直连 updater 权限。
- `desktop:release:check` 将 token URL 检查扩展到 `token``access_token` 查询参数,并扩大日志敏感词检查到 `token``access_token``authorization``bearer`
- 更新弹窗 release notes 增加清理逻辑,过滤 URL、token 查询参数、`access_token``Authorization``Bearer` 形态文本,避免 feed 内容把下载链接或凭据样式文本带入 UI。
- 凭据库 Rust 单测新增带凭据 server origin 拒绝,以及 Keychain/Credential Manager account 不暴露原始服务器 origin 的覆盖。
仍需人工复审确认:
- 真实生产 release notes 内容保持通用,不写入项目、文件、下载链接或敏感业务详情。
- 正式签名、公证和 updater feed 环境继续使用组织 secret,不在日志、artifact 或配置中泄露私钥材料。
## 10. 2026-07-02 桌面体验收口记录
本轮体验收口聚焦登录、服务器设置、个人中心诊断、系统偏好和最小窗口布局稳定性,不改变业务能力边界。
已完成的体验收口:
- 个人中心新增只读客户端诊断信息,展示客户端类型、版本、平台、构建通道、提交、服务器和能力状态,并支持复制诊断信息。
- 登录页在桌面最小窗口附近收紧左右分栏 padding 和卡片宽度,长服务器地址继续在登录面板内换行,不撑破布局。
- 服务器设置页增加面板内滚动和健康检查 URL 换行约束,避免 `1180x760` 下长 URL 或错误信息溢出。
- 系统偏好通知/更新控制区允许换行,长状态说明使用 `overflow-wrap`,避免按钮和权限状态标签在窄视口重叠。
- 个人中心对话框内容区改为内部滚动,诊断值使用强制换行,避免新增诊断信息后超过最小窗口高度。
- 通知权限运行时改为优先读取 WebView `Notification.permission``granted`/`denied` 状态;请求权限返回 `default` 时保持“待授权”,不再误标为“已拒绝”。
- 系统偏好通知页已移除授权成功态的固定标签文案,开启状态只由开关表达;待授权、已拒绝和不可用状态继续显示提示标签。
- 系统偏好通知页新增“测试通知”命令,完整走 `frontend/src/runtime/notifications.ts` -> `@tauri-apps/plugin-notification` 的系统通知发送链路;测试通知文案为固定通用内容,不包含项目、文件、token 或其他敏感业务信息。
- `showSystemNotification` 和测试通知调用会在发送前确认系统通知权限,只有真正发起系统通知时才返回成功;桌面通知轮询据此只 ack 已实际发起系统通知的后端通知。
- 真实 macOS `.app` 已验证系统偏好通知开关可调用系统通知权限并生效。验证截图见 `/Users/zcc/Library/Application Support/CleanShot/media/media_boKznpjOwt/CleanShot 2026-07-02 at 11.07.52@2x.png`
- `Layout.desktop.test.ts` 新增静态契约覆盖个人中心诊断、登录页最小窗口断点、服务器设置滚动和偏好页控制区换行约束。
- `notifications.test.ts` 新增授权、拒绝、待授权、取消授权请求和测试通知发送链路的单元覆盖。
仍需人工体验验收:
- 在真实 macOS `.app` 中以 `1180x760` 检查登录页、服务器设置、个人中心、系统偏好和更新弹窗无重叠、无横向溢出。
- 在真实系统通知权限拒绝流程中确认权限状态提示、开关回退和错误提示与 OS 状态一致。
+65 -2
View File
@@ -60,11 +60,12 @@
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
- `savedLoginCredentials`:隔离网页端浏览器凭据管理与桌面端系统凭据库中的登录表单密码保存;不得把密码写入 `localStorage``sessionStorage`、URL、日志或通知正文,且不等同于离线登录。
- `files`:隔离浏览器上传下载与原生文件能力。
- `notifications`:隔离 Web 通知与桌面系统通知。
- `updates`:隔离桌面自动更新检查与安装入口。
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、收藏模块等桌面体验状态。
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
@@ -75,7 +76,7 @@
- 非本地服务连接优先使用 HTTPS。
- 认证与授权决策保留在后端。
- 审计敏感决策保留在后端。
- 敏感凭据必须继续使用明确批准的安全存储方案。
- 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库
- 不向前端暴露宽泛文件系统访问权限。
- Tauri 权限保持最小化,并按功能精确授权。
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
@@ -110,6 +111,68 @@
5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。
6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。
## 2026-07-02 发布稳定化推进记录
本次推进仍保持第一、二阶段边界,不引入离线、本地业务存储、内嵌后端或独立桌面业务 UI。
已补齐的发布稳定化自动化:
- 新增 `npm run desktop:build:macos-release`,用于 macOS Universal `app`/`dmg` 正式候选构建。
- 新增 `npm run desktop:update-feed:create`,从签名 updater artifact 和 `.sig` 生成 `latest.json``SHA256SUMS.txt`,并要求 artifact URL 使用包含当前版本号的 HTTPS 不可变路径。
- `npm run desktop:update-feed:check` 在传入 `--artifacts-dir` 时同步校验 `SHA256SUMS.txt`、updater artifact、`.sig``latest.json`
- 新增 `npm run desktop:release-readiness:check`,用于在进入正式签名候选构建前确认当前提交精确匹配 `vX.Y.Z` tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址已经齐备。
- 新增 `.github/workflows/desktop-release-candidate.yml`,在 release tag 上执行签名 macOS 候选构建、feed 生成、feed 校验和 GitHub artifact 上传;它不替代人工发布审批和生产下载源原子替换。
- `npm run desktop:release:check` 已纳入上述发布候选工作流和脚本存在性检查,防止发布链路门禁被误删。
仍需正式发布负责人在真实发布环境完成:
- Apple Developer 签名、公证凭据和组织 updater 私钥配置。
- 从正式 `vX.Y.Z` tag 运行 signed macOS release candidate workflow。
- 将已校验的不可变制品上传到生产下载源,最后原子替换线上 `latest.json`
- 执行桌面端人工端到端回归、最小窗口体验验收和真实系统通知/自动更新验证。
进入端到端人工回归前,应先确认 `npm run desktop:release-readiness:check` 在正式 release tag 和签名环境中通过;否则只能进行普通 smoke 验证,不能判定 macOS 发布稳定化已经完成。
## 2026-07-02 安全边界复审推进记录
本次安全边界复审在端到端自动化收口之后继续推进,仍不引入离线能力、本地业务数据存储、内嵌后端或独立桌面业务 UI。
已补齐的安全边界自动化:
- Tauri 通知 capability 已从 `notification:default` 收敛为权限查询、权限请求和发送通知三项显式权限。
- `npm run desktop:release:check` 已拒绝 `notification:default`、opener URL/reveal 权限和 WebView 直连 updater 权限,继续要求文件与 opener scope 仅限 `$TEMP/ctms-desktop/**`
- `npm run desktop:release:check` 已扩展 token URL 和日志静态检查,覆盖 `token``access_token``Authorization``Bearer` 形态。
- 自动更新弹窗 release notes 已过滤 URL、token 查询参数和 Authorization/Bearer 形态文本,避免从 feed 将下载链接或凭据样式文本带入用户界面。
- Rust 凭据命令新增单测,确认带凭据 server origin 被拒绝,系统凭据库 account 使用 origin 哈希且不暴露原始服务器地址。
仍需正式发布负责人在真实发布环境确认:
- 生产 release notes 内容保持通用,不包含项目、文件、下载链接或敏感业务详情。
- 签名、公证、updater feed 和 artifact 上传日志不泄露 Apple 凭据、updater 私钥或下载源内部凭据。
## 2026-07-02 桌面体验收口推进记录
本次体验收口继续保持在线桌面客户端边界,不新增离线、本地业务存储或独立桌面业务 UI。
已补齐的桌面体验收口:
- 个人中心新增客户端诊断信息展示与复制能力,内容仅包含客户端类型、版本、平台、构建通道、提交、服务器和能力状态,不包含 token 或业务敏感数据。
- 桌面侧栏已移除被动“最近访问”入口和对应本地记录,只保留用户主动维护的收藏入口;收藏仅保存路由元数据,不保存业务数据或敏感凭据。
- 登录页、服务器设置页、个人中心和系统偏好增加最小窗口布局约束,长服务器地址、健康检查 URL、诊断值和更新/通知状态说明均可在容器内换行。
- 服务器设置页和个人中心在内容高度超过窗口时使用内部滚动,避免 `1180x760` 下对话框或面板溢出。
- 通知权限运行时已区分 WebView `Notification.permission` 的授权、拒绝和待授权状态;取消 macOS 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。
- 系统偏好通知页已移除授权成功态的固定标签文案,开启状态只由开关表达;待授权、已拒绝和不可用状态继续显示提示标签。
- 系统偏好通知页新增“测试通知”命令,完整走 `frontend/src/runtime/notifications.ts` -> `@tauri-apps/plugin-notification` 的系统通知发送链路;测试通知文案为固定通用内容,不包含项目、文件、token 或其他敏感业务信息。
- `showSystemNotification` 和测试通知调用会在发送前确认系统通知权限,只有真正发起系统通知时才返回成功;桌面通知轮询据此只 ack 已实际发起系统通知的后端通知。
- 真实 macOS `.app` 已验证系统偏好通知开关可调用系统通知权限并生效。验证截图位于 `/Users/zcc/Library/Application Support/CleanShot/media/media_boKznpjOwt/CleanShot 2026-07-02 at 11.07.52@2x.png`
- `Layout.desktop.test.ts` 已覆盖个人中心诊断、最小窗口断点、服务器设置滚动和偏好页控制区换行契约。
- `notifications.test.ts` 已覆盖通知权限状态映射和测试通知发送链路。
仍需真实桌面环境人工确认:
- macOS `.app``1180x760` 下的登录页、服务器设置、个人中心、系统偏好和更新弹窗实际布局。
- 系统通知权限拒绝后的开关状态、权限提示和错误提示是否与 OS 状态一致。
如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
## 当前质量门禁
+2 -2
View File
@@ -106,8 +106,8 @@ npm run desktop:build:app
npm run desktop:build:app
```
正式桌面发布构建仍必须设置 updater 签名私钥执行
`npm run desktop:build -- --bundles app`
正式桌面发布构建仍必须设置 updater 签名私钥和 Apple 签名/公证变量后,先执行
`npm run desktop:release-readiness:check`,再执行 `npm run desktop:build:macos-release -- --ci`
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
+13 -4
View File
@@ -122,8 +122,8 @@ The release pipeline must:
2. build macOS Universal desktop artifacts;
3. sign and notarize the macOS app;
4. produce updater artifacts and `.sig` files with the updater private key;
5. generate `latest.json` and a checksum manifest;
6. verify the feed with `npm run desktop:update-feed:check -- --feed <latest.json> --artifacts-dir <artifact-dir>`;
5. generate `latest.json` and a checksum manifest with `npm run desktop:update-feed:create`;
6. verify the feed with `npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>`;
7. upload immutable artifacts first;
8. atomically replace `latest.json` last.
@@ -131,6 +131,13 @@ For Universal macOS artifacts, `latest.json` must provide both
`darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
update package.
The signed release candidate workflow lives at
`.github/workflows/desktop-release-candidate.yml`. It must be run from a
matching `vX.Y.Z` tag and produces a verified release directory as a GitHub
artifact. That artifact is still only a release candidate; the release owner
must upload immutable files to the production download origin and replace
`latest.json` atomically after validation.
## Windows Build Readiness
Second-phase Windows work is limited to CI compatibility validation. A
@@ -167,8 +174,10 @@ export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
export REQUIRE_DESKTOP_SIGNING=true
npm run release:env:check
npm run desktop:build -- --bundles app
npm run desktop:update-feed:check -- --feed src-tauri/target/release/bundle/latest.json --artifacts-dir src-tauri/target/release/bundle
npm run desktop:release-readiness:check
npm run desktop:build:macos-release -- --ci
npm run desktop:update-feed:create -- --artifact <CTMS.app.tar.gz> --base-url <versioned-https-artifact-prefix> --output-dir <release-dir>
npm run desktop:update-feed:check -- --feed <release-dir>/latest.json --artifacts-dir <release-dir>
```
The Desktop build must run on macOS for the current first-phase target. A signed
+61 -1
View File
@@ -9,7 +9,67 @@
</head>
<body>
<div id="app"></div>
<div id="app">
<style>
.ctms-boot-splash {
display: grid;
min-height: 100vh;
min-height: 100dvh;
place-items: center;
background:
radial-gradient(circle at 22% 12%, rgba(58, 120, 183, 0.14), transparent 30%),
linear-gradient(135deg, #f4f7fb 0%, #e8eef5 100%);
color: #142033;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.ctms-boot-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 22px;
border: 1px solid rgba(191, 203, 217, 0.78);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 48px rgba(43, 63, 87, 0.12);
}
.ctms-boot-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: #183b63;
color: #ffffff;
font-size: 12px;
font-weight: 900;
letter-spacing: 0.08em;
}
.ctms-boot-title {
margin: 0;
font-size: 15px;
font-weight: 800;
}
.ctms-boot-subtitle {
margin: 4px 0 0;
color: #66758b;
font-size: 12px;
}
</style>
<div class="ctms-boot-splash">
<div class="ctms-boot-card">
<div class="ctms-boot-mark">CTMS</div>
<div>
<p class="ctms-boot-title">正在启动 CTMS</p>
<p class="ctms-boot-subtitle">正在加载桌面客户端...</p>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+3
View File
@@ -11,8 +11,11 @@
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
"desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg",
"desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
"desktop:bundle:dmg": "tauri build --bundles dmg",
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
"desktop:release-readiness:check": "node scripts/verify-desktop-release-readiness.mjs",
"release:env:check": "node scripts/verify-release-build-env.mjs",
"version:check": "node scripts/client-version.mjs --check",
"version:set": "node scripts/client-version.mjs --set",
@@ -0,0 +1,156 @@
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const args = process.argv.slice(2);
const values = (name) => {
const found = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === name && args[index + 1]) {
found.push(args[index + 1]);
index += 1;
}
}
return found;
};
const value = (name) => values(name)[0];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
const outputDir = resolve(
frontendDir,
value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed",
);
const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString();
const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES;
const includes = values("--include").map((path) => resolve(frontendDir, path));
assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required.");
assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required.");
const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined;
const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined;
const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined;
const uploadFiles = [];
const readRequiredFile = async (path, description) => {
try {
return await readFile(path);
} catch (error) {
fail(`${description} cannot be read: ${path} (${error.message})`);
return undefined;
}
};
const sha256 = async (path) => {
const data = await readFile(path);
return createHash("sha256").update(data).digest("hex");
};
const normalizedBaseUrl = () => {
if (!baseUrlRaw) return undefined;
try {
const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`);
assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS.");
assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters.");
assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`);
return url;
} catch (error) {
fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`);
return undefined;
}
};
const copyIntoOutput = async (sourcePath) => {
const destination = resolve(outputDir, basename(sourcePath));
if (sourcePath !== destination) {
await copyFile(sourcePath, destination);
}
return destination;
};
if (resolvedArtifactPath && signaturePath) {
await readRequiredFile(resolvedArtifactPath, "Updater artifact");
const signature = await readRequiredFile(signaturePath, "Updater artifact signature");
if (signature) {
const signatureText = signature.toString("utf8").trim();
assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short.");
}
}
for (const includePath of includes) {
try {
const metadata = await stat(includePath);
assert(metadata.isFile(), `Included release file must be a file: ${includePath}`);
} catch (error) {
fail(`Included release file cannot be read: ${includePath} (${error.message})`);
}
}
const baseUrl = normalizedBaseUrl();
if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) {
await mkdir(outputDir, { recursive: true });
const artifactUrl = new URL(artifactName, baseUrl).toString();
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
const signature = (await readFile(signaturePath, "utf8")).trim();
const latest = {
version: packageInfo.version,
pub_date: pubDate,
platforms: {
"darwin-aarch64": {
signature,
url: artifactUrl,
},
"darwin-x86_64": {
signature,
url: artifactUrl,
},
},
};
if (notes) {
latest.notes = notes;
}
uploadFiles.push(await copyIntoOutput(resolvedArtifactPath));
uploadFiles.push(await copyIntoOutput(signaturePath));
for (const includePath of includes) {
uploadFiles.push(await copyIntoOutput(includePath));
}
const latestPath = resolve(outputDir, "latest.json");
await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`);
uploadFiles.push(latestPath);
const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()];
const checksumLines = [];
for (const filePath of uniqueFiles) {
checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`);
}
const checksumPath = resolve(outputDir, "SHA256SUMS.txt");
await writeFile(checksumPath, `${checksumLines.join("\n")}\n`);
console.log(`Desktop update feed created in ${outputDir}`);
console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`);
console.log(` - SHA256SUMS.txt`);
}
if (failures.length > 0) {
console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
}
@@ -0,0 +1,101 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const env = process.env;
const fullShaPattern = /^[0-9a-f]{40}$/i;
const expectedTag = `v${packageInfo.version}`;
const requiredSecretLikeEnv = [
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const git = (args) =>
execFileSync("git", args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitMaybe = (args) => {
try {
return git(args);
} catch {
return undefined;
}
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
};
const validateBaseUrl = () => {
const raw = env.DESKTOP_UPDATE_BASE_URL;
requireEnv("DESKTOP_UPDATE_BASE_URL");
if (!raw) return;
let url;
try {
url = new URL(raw.endsWith("/") ? raw : `${raw}/`);
} catch (error) {
fail(`DESKTOP_UPDATE_BASE_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "DESKTOP_UPDATE_BASE_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "DESKTOP_UPDATE_BASE_URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "DESKTOP_UPDATE_BASE_URL must not include token query parameters.");
assert(
url.pathname.includes(packageInfo.version),
`DESKTOP_UPDATE_BASE_URL must include the immutable version segment ${packageInfo.version}.`,
);
};
const headSha = gitMaybe(["rev-parse", "HEAD"]);
const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]);
const status = gitMaybe(["status", "--porcelain"]);
assert(Boolean(headSha), "Current Git commit cannot be resolved.");
assert(exactTag === expectedTag, `Current commit must be exactly tagged ${expectedTag}; found ${exactTag || "<none>"}.`);
assert(status === "", "Release readiness requires a clean working tree.");
assert(env.VITE_BUILD_CHANNEL === "release", "VITE_BUILD_CHANNEL must be release.");
assert(fullShaPattern.test(env.VITE_BUILD_COMMIT || ""), "VITE_BUILD_COMMIT must be the full release commit SHA.");
if (headSha && env.VITE_BUILD_COMMIT) {
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
}
for (const name of requiredSecretLikeEnv) {
requireEnv(name);
}
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
validateBaseUrl();
if (failures.length > 0) {
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop release readiness check passed.");
}
+104 -2
View File
@@ -71,6 +71,13 @@ const verifyTauriConfig = async () => {
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
assert(mainWindow?.decorations === true, "Main desktop window must keep native window decorations enabled.");
assert(mainWindow?.titleBarStyle === "Overlay", "Main desktop window must use the macOS overlay title bar.");
assert(mainWindow?.hiddenTitle === true, "Main desktop window must hide the native title text.");
assert(
mainWindow?.trafficLightPosition?.x === 16 && mainWindow?.trafficLightPosition?.y === 18,
"Main desktop window must keep traffic light controls at x=16 y=18.",
);
};
const verifyCapabilities = async () => {
@@ -86,7 +93,21 @@ const verifyCapabilities = async () => {
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"notification:default",
"opener:default",
"opener:allow-open-url",
"opener:allow-reveal-item-in-dir",
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"updater:allow-download-and-install",
]);
const requiredNotificationPermissions = [
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
];
for (const file of files) {
const capability = await readJson(resolve(capabilitiesDir, file));
@@ -97,6 +118,22 @@ const verifyCapabilities = async () => {
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
assert(!identifier.startsWith("updater:"), `${file}: updater permissions must not be exposed directly to WebView.`);
if (identifier.startsWith("notification:")) {
assert(
requiredNotificationPermissions.includes(identifier),
`${file}: notification permission ${identifier} is broader than the CTMS Desktop notification boundary.`,
);
}
if (identifier.startsWith("core:window:")) {
fail(`${file}: window permissions are not allowed; use Tauri overlay drag regions instead.`);
}
if (identifier.startsWith("opener:")) {
assert(identifier === "opener:allow-open-path", `${file}: opener permission ${identifier} is not allowed.`);
}
}
for (const identifier of requiredNotificationPermissions) {
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
}
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
@@ -127,6 +164,19 @@ const verifyRustBoundary = async () => {
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
"Single-instance plugin must be registered before other desktop plugins.",
);
const singleInstanceSource = libSource.slice(
singleInstanceIndex,
dialogIndex > singleInstanceIndex ? dialogIndex : singleInstanceIndex + 600,
);
const restoreWindowTokens = ['get_webview_window("main")', "window.unminimize()", "window.show()", "window.set_focus()"];
for (const token of restoreWindowTokens) {
assert(singleInstanceSource.includes(token), `Single-instance duplicate launch handler must call ${token}.`);
}
assert(
singleInstanceSource.indexOf("window.unminimize()") < singleInstanceSource.indexOf("window.show()") &&
singleInstanceSource.indexOf("window.show()") < singleInstanceSource.indexOf("window.set_focus()"),
"Single-instance duplicate launch handler must restore, show, then focus the main window.",
);
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
@@ -134,6 +184,9 @@ const verifyRustBoundary = async () => {
"credentials::credential_get",
"credentials::credential_set",
"credentials::credential_delete",
"credentials::login_credential_get",
"credentials::login_credential_set",
"credentials::login_credential_delete",
"updates::desktop_update_check",
"updates::desktop_update_install",
];
@@ -153,14 +206,18 @@ const verifySourceSafety = async () => {
for (const path of files) {
const source = await readFile(path, "utf8");
const file = relative(rootDir, path);
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`);
assert(
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
!/console\.(log|debug|info|warn|error)\s*\([^)]*(?:token|access_token|authorization|bearer)/i.test(source),
`${file}: token-related values must not be written to console logs.`,
);
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
}
assert(
!/(?:localStorage|sessionStorage)\.setItem\([^)]*password/i.test(source),
`${file}: passwords must not be written to browser storage.`,
);
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
}
@@ -174,6 +231,19 @@ const verifyNotificationBoundary = async () => {
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
};
const verifySessionBoundary = async () => {
const source = await readFile(resolve(sourceDir, "session/sessionManager.ts"), "utf8");
const tokenBroadcastIndex = source.indexOf('message.type === "TOKEN_UPDATED"');
const storageBroadcastIndex = source.indexOf('localStorage.setItem("ctms_auth_broadcast"');
assert(tokenBroadcastIndex >= 0, "Session manager must branch TOKEN_UPDATED broadcasts before storage fallback.");
assert(storageBroadcastIndex >= 0, "Session manager must keep storage fallback for non-token auth broadcasts.");
assert(
tokenBroadcastIndex >= 0 && storageBroadcastIndex >= 0 && tokenBroadcastIndex < storageBroadcastIndex,
"Session manager must not persist TOKEN_UPDATED payloads through localStorage broadcast fallback.",
);
};
const verifyUpdaterBoundary = async () => {
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
@@ -182,6 +252,19 @@ const verifyUpdaterBoundary = async () => {
};
const verifyWorkflowGates = async () => {
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
const requiredScripts = [
"desktop:build:macos-release",
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
"release:env:check",
];
for (const script of requiredScripts) {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
}
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
@@ -200,6 +283,24 @@ const verifyWorkflowGates = async () => {
}
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
const requiredReleaseWorkflowTokens = [
"REQUIRE_DESKTOP_SIGNING",
"TAURI_SIGNING_PRIVATE_KEY",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
"npm run desktop:build:macos-release",
"npm run desktop:update-feed:create",
"npm run desktop:update-feed:check",
"npm run desktop:release-readiness:check",
"actions/upload-artifact",
];
for (const token of requiredReleaseWorkflowTokens) {
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
}
};
await verifyTauriConfig();
@@ -207,6 +308,7 @@ await verifyCapabilities();
await verifyRustBoundary();
await verifySourceSafety();
await verifyNotificationBoundary();
await verifySessionBoundary();
await verifyUpdaterBoundary();
await verifyWorkflowGates();
@@ -1,4 +1,5 @@
import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -18,6 +19,10 @@ const feedPath = resolve(
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const checksumManifestPath =
optionValue("--checksum-manifest") ||
process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST ||
(artifactDir ? resolve(artifactDir, "SHA256SUMS.txt") : undefined);
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
@@ -32,6 +37,55 @@ const assertFileExists = async (path, description) => {
}
};
const sha256 = async (path) => createHash("sha256").update(await readFile(path)).digest("hex");
const readChecksumManifest = async () => {
if (!checksumManifestPath) return undefined;
try {
const source = await readFile(checksumManifestPath, "utf8");
const checksums = new Map();
for (const line of source.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
if (!match) {
fail(`Checksum manifest contains an invalid line: ${line}`);
continue;
}
checksums.set(basename(match[2]), match[1].toLowerCase());
}
return checksums;
} catch (error) {
fail(`Cannot read checksum manifest ${checksumManifestPath}: ${error.message}`);
return undefined;
}
};
const assertChecksum = async (checksums, path, description) => {
if (!checksums) return;
const name = basename(path);
const expected = checksums.get(name);
assert(Boolean(expected), `Checksum manifest must include ${description}: ${name}`);
if (expected) {
const actual = await sha256(path);
assert(actual === expected, `${description} checksum mismatch for ${name}.`);
}
};
const assertManifestEntries = async (checksums) => {
if (!checksums || !artifactDir) return;
for (const [name, expected] of checksums.entries()) {
const path = resolve(artifactDir, name);
await assertFileExists(path, `checksum manifest entry ${name}`);
try {
const actual = await sha256(path);
assert(actual === expected, `Checksum manifest entry mismatch for ${name}.`);
} catch (error) {
fail(`Cannot verify checksum manifest entry ${name}: ${error.message}`);
}
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
@@ -40,6 +94,8 @@ try {
}
if (feed) {
const checksums = await readChecksumManifest();
await assertManifestEntries(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
@@ -80,14 +136,21 @@ if (feed) {
if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname));
const signaturePath = `${artifactPath}.sig`;
await assertFileExists(artifactPath, `${platform} updater artifact`);
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
await assertFileExists(signaturePath, `${platform} updater artifact signature`);
await assertChecksum(checksums, artifactPath, `${platform} updater artifact`);
await assertChecksum(checksums, signaturePath, `${platform} updater artifact signature`);
}
}
if (darwinArm?.url && darwinIntel?.url) {
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
}
if (artifactDir) {
await assertChecksum(checksums, feedPath, "latest.json");
}
}
if (failures.length > 0) {
@@ -47,6 +47,9 @@ if (isTagBuild) {
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
if (isCi) {
assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag.");
}
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("APPLE_ID");
@@ -56,6 +59,9 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") {
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
}
if (failures.length > 0) {
+3 -1
View File
@@ -16,7 +16,9 @@
{ "path": "$TEMP/ctms-desktop/**" }
]
},
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
{
"identifier": "opener:allow-open-path",
"allow": [
+91 -7
View File
@@ -1,7 +1,8 @@
use sha2::{Digest, Sha256};
use url::Url;
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
const SESSION_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
const LOGIN_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.login";
fn credential_account(server_origin: &str) -> Result<String, String> {
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
@@ -23,9 +24,9 @@ fn credential_account(server_origin: &str) -> Result<String, String> {
}
#[cfg(any(target_os = "macos", windows))]
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
fn get_entry(service: &str, server_origin: &str) -> Result<keyring::Entry, String> {
let account = credential_account(server_origin)?;
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
keyring::Entry::new(service, &account)
.map_err(|error| format!("无法访问系统凭据库:{error}"))
}
@@ -34,7 +35,7 @@ pub async fn credential_get(server_origin: String) -> Result<Option<String>, Str
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(&server_origin)?;
let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
@@ -59,7 +60,7 @@ pub async fn credential_set(server_origin: String, token: String) -> Result<(),
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
return get_entry(&server_origin)?
return get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?
.set_password(&token)
.map_err(|error| format!("保存系统凭据失败:{error}"));
}
@@ -78,7 +79,7 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(&server_origin)?;
let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除系统凭据失败:{error}")),
@@ -94,9 +95,74 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_get(server_origin: String) -> Result<Option<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.get_password() {
Ok(credential) => Ok(Some(credential)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(format!("读取登录凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("读取登录凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_set(server_origin: String, credential: String) -> Result<(), String> {
if credential.trim().is_empty() {
return Err("拒绝保存空登录凭据".to_string());
}
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
return get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?
.set_password(&credential)
.map_err(|error| format!("保存登录凭据失败:{error}"));
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("保存登录凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_delete(server_origin: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除登录凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("删除登录凭据任务失败:{error}"))?
}
#[cfg(test)]
mod tests {
use super::credential_account;
use super::{credential_account, LOGIN_CREDENTIAL_SERVICE, SESSION_CREDENTIAL_SERVICE};
#[test]
fn account_is_stable_for_same_origin() {
@@ -111,4 +177,22 @@ mod tests {
assert!(credential_account("http://ctms.example.com").is_err());
assert!(credential_account("http://localhost:8000").is_ok());
}
#[test]
fn rejects_origins_with_embedded_credentials() {
assert!(credential_account("https://user:secret@ctms.example.com").is_err());
}
#[test]
fn account_does_not_expose_server_origin() {
let account = credential_account("https://ctms.example.com/path").unwrap();
assert!(!account.contains("ctms.example.com"));
assert!(!account.contains("https"));
}
#[test]
fn login_credentials_use_a_separate_keyring_service() {
assert_ne!(SESSION_CREDENTIAL_SERVICE, LOGIN_CREDENTIAL_SERVICE);
}
}
+3
View File
@@ -144,6 +144,9 @@ pub fn run() {
credentials::credential_get,
credentials::credential_set,
credentials::credential_delete,
credentials::login_credential_get,
credentials::login_credential_set,
credentials::login_credential_delete,
updates::desktop_update_check,
updates::desktop_update_install,
])
+5 -1
View File
@@ -17,7 +17,11 @@
"width": 1440,
"height": 900,
"minWidth": 1180,
"minHeight": 760
"minHeight": 760,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 16, "y": 18 }
}
],
"security": {
+6 -1
View File
@@ -13,8 +13,13 @@ import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
if (isTauriRuntime()) {
const isDesktopRuntime = isTauriRuntime();
if (isDesktopRuntime) {
applyDesktopThemePreference();
document.body.classList.add("is-desktop-runtime");
} else {
document.body.classList.remove("is-desktop-runtime");
}
initSessionManager();
initDesktopNotificationManager();
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiDelete = vi.fn();
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({
apiDelete,
apiGet,
apiPost,
}));
describe("attachments api", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("downloads attachment blobs without putting credentials in the URL", async () => {
const { downloadAttachment } = await import("./attachments");
downloadAttachment("attachment-1");
expect(apiGet).toHaveBeenCalledWith("/api/v1/attachments/attachment-1/download", {
responseType: "blob",
});
expect(apiGet.mock.calls[0][0]).not.toContain("token");
expect(apiGet.mock.calls[0][0]).not.toContain("access_token");
});
it("uploads attachments as multipart form data", async () => {
const { uploadAttachment } = await import("./attachments");
const file = new File(["content"], "report.pdf", { type: "application/pdf" });
const onUploadProgress = vi.fn();
uploadAttachment("study-1", "startup_initiation", "entity-1", file, { onUploadProgress });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/startup_initiation/entity-1/attachments/",
expect.any(FormData),
{
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress,
},
);
const formData = apiPost.mock.calls[0][1] as FormData;
expect(formData.get("file")).toBe(file);
});
it("deletes attachments through the scoped attachment endpoint", async () => {
const { deleteAttachment } = await import("./attachments");
deleteAttachment("attachment-1");
expect(apiDelete).toHaveBeenCalledWith("/api/v1/attachments/attachment-1");
});
});
+5 -4
View File
@@ -1,5 +1,5 @@
import type { AxiosResponse } from "axios";
import api, { apiGet, apiPatch, apiPost } from "./axios";
import api, { apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
import type {
UserMeResponse,
LoginRequest,
@@ -24,10 +24,11 @@ export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginR
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export const fetchMe = (config?: ApiRequestConfig): Promise<AxiosResponse<UserMeResponse>> =>
apiGet<UserMeResponse>("/api/v1/auth/me", config);
export const fetchEmailDomains = (): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains");
export const fetchEmailDomains = (config?: ApiRequestConfig): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains", config);
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register", payload);
@@ -24,9 +24,17 @@ export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportO
const rows = formatAuditRows(events);
const csv = buildCsv(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
await saveFileWithFeedback({
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8",
data: blob,
});
await saveFileWithFeedback(
{
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8",
data: blob,
},
{
kind: "export",
title: "导出审计记录",
pendingDetail: "等待选择保存位置",
completedDetail: "导出文件已保存",
},
);
};
File diff suppressed because it is too large Load Diff
+60 -20
View File
@@ -88,12 +88,18 @@ const openManager = () => {
<style scoped>
.panel {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: hidden;
padding: 24px 14px;
background: transparent;
background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.12) 0%, transparent 38%),
linear-gradient(180deg, #eef7ff 0%, #f8fbff 52%, #edf3fb 100%);
}
.header {
@@ -101,37 +107,69 @@ const openManager = () => {
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 0 8px 16px;
border-bottom: 1px solid #edf2f8;
color: #8b98aa;
padding: 12px 12px;
border: 1px solid rgba(110, 153, 205, 0.18);
border-radius: 12px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(232, 244, 255, 0.86) 100%);
box-shadow:
0 10px 24px rgba(42, 86, 143, 0.07),
0 1px 0 rgba(255, 255, 255, 0.9) inset;
color: #385b7f;
font-size: 14px;
font-weight: 800;
}
.header > span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.header > span::before {
content: "";
width: 6px;
height: 20px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 14px rgba(47, 123, 232, 0.26);
}
.menu {
flex: 1 1 auto;
max-height: calc(100vh - 240px);
min-height: 0;
overflow: auto;
border-right: 0;
padding: 14px 0 0;
padding: 14px 2px 0;
background: transparent;
}
.menu :deep(.el-menu-item) {
position: relative;
height: 44px;
margin: 4px 0;
padding: 0 10px !important;
border: 1px solid transparent;
border-radius: 10px;
color: #5f6f7a;
color: #506475;
font-size: 14px;
font-weight: 700;
line-height: 44px;
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.menu :deep(.el-menu-item:hover) {
background: rgba(31, 95, 184, 0.06);
border-color: rgba(47, 123, 232, 0.1);
background: rgba(255, 255, 255, 0.66);
color: #1f5fb8;
}
.menu :deep(.el-menu-item.is-active) {
background: #e7f4ff;
color: #1f5fb8;
border-color: rgba(47, 123, 232, 0.18);
background:
linear-gradient(135deg, rgba(220, 239, 255, 0.96) 0%, rgba(240, 248, 255, 0.96) 100%);
box-shadow:
0 10px 22px rgba(47, 123, 232, 0.12),
3px 0 0 #2f7be8 inset;
color: #1559ad;
}
.cat-icon {
display: inline-flex;
@@ -142,10 +180,11 @@ const openManager = () => {
height: 24px;
margin-right: 10px;
border-radius: 7px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: linear-gradient(135deg, rgba(221, 234, 249, 0.9) 0%, rgba(242, 248, 255, 0.9) 100%);
color: #517194;
font-size: 13px;
font-weight: 800;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.82) inset;
}
.cat-icon :deep(svg) {
font-size: 13px;
@@ -153,20 +192,21 @@ const openManager = () => {
height: 1em;
}
.menu :deep(.el-menu-item.is-active) .cat-icon {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #2f7be8 0%, #24b7d8 100%);
color: #ffffff;
box-shadow: 0 8px 18px rgba(47, 123, 232, 0.25);
}
.category-create-btn {
height: 34px;
border: 0;
border-radius: 10px;
background: #415a77;
background: linear-gradient(135deg, #2f527a 0%, #203a5c 100%);
font-weight: 800;
box-shadow: 0 8px 18px rgba(65, 90, 119, 0.16);
box-shadow: 0 8px 18px rgba(37, 67, 105, 0.2);
}
.category-create-btn:hover,
.category-create-btn:focus {
background: #344960;
background: linear-gradient(135deg, #254866 0%, #172f4e 100%);
}
.name {
flex: 1;
@@ -183,8 +223,8 @@ const openManager = () => {
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: rgba(88, 111, 137, 0.1);
color: #5d7590;
font-size: 10px;
font-weight: 700;
line-height: 18px;
@@ -192,7 +232,7 @@ const openManager = () => {
}
.menu :deep(.el-menu-item.is-active) .cat-count {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #cfe7ff 0%, #dcf7ff 100%);
color: #1559ad;
}
</style>
+156 -19
View File
@@ -1,18 +1,20 @@
<template>
<div class="faq-list-card">
<div class="faq-list-card" :class="{ 'faq-list-card--desktop': isDesktop }">
<el-table
:data="items"
v-loading="loading"
style="width: 100%"
class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName"
:height="isDesktop ? '100%' : undefined"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip>
<template #default="scope">
<div class="question-cell">
<span class="question-icon">{{ questionInitial(scope.row.question) }}</span>
<span class="question-icon"><el-icon><QuestionFilled /></el-icon></span>
<el-link type="primary" :underline="false" class="question-link">
{{ scope.row.question }}
</el-link>
@@ -52,12 +54,14 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { deleteFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs";
import { displayDateTime, displayEnum } from "../utils/display";
import { TEXT } from "../locales";
import { isTauriRuntime } from "../runtime";
const props = defineProps<{
items: FaqItem[];
@@ -69,6 +73,7 @@ const props = defineProps<{
const emit = defineEmits(["refresh"]);
const router = useRouter();
const isDesktop = isTauriRuntime();
const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -81,10 +86,6 @@ const categoryLabel = (categoryId: string) => {
return categoryMap.value[categoryId] || categoryId || "--";
};
const questionInitial = (question?: string) => {
return String(question || "?").trim().slice(0, 1) || "?";
};
const splitDateTime = (value?: string | number | Date | null) => {
const displayValue = displayDateTime(value);
const [date, time] = displayValue.split(" ");
@@ -145,9 +146,10 @@ const remove = async (row: FaqItem) => {
}
.faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
border-bottom: 0;
background: transparent;
color: #8a98aa;
height: 46px;
border-bottom: 1px solid #dce8f7;
background: linear-gradient(180deg, #f7fbff 0%, #eef5fc 100%);
color: #5d7088;
font-size: 14px;
font-weight: 800;
}
@@ -166,21 +168,23 @@ const remove = async (row: FaqItem) => {
}
.faq-table :deep(.faq-row td.el-table__cell) {
padding: 12px 0;
padding: 14px 0;
border-bottom: 1px solid #e8eef6;
background: #ffffff;
transition: background 0.18s ease;
}
.faq-table :deep(.faq-row:hover td.el-table__cell) {
background: #f8f9fb;
background: #f7fbff;
}
.faq-table :deep(col:nth-child(1)) { width: 43%; }
.faq-table :deep(col:nth-child(2)) { width: 14%; }
.faq-table :deep(col:nth-child(3)) { width: 14%; }
.faq-table :deep(col:nth-child(4)) { width: 14%; }
.faq-table :deep(col:nth-child(5)) { width: 15%; }
.faq-table--with-actions :deep(col) {
width: 20%;
}
.faq-table--without-actions :deep(col) {
width: 25%;
}
@@ -205,10 +209,19 @@ const remove = async (row: FaqItem) => {
width: 34px;
height: 34px;
border-radius: 10px;
background: #f3f7fb;
color: #5e6f7b;
font-size: 15px;
background:
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.42) 0%, transparent 28%),
linear-gradient(135deg, #2f7be8 0%, #23b7d9 100%);
color: #ffffff;
font-size: 17px;
font-weight: 800;
box-shadow:
0 10px 20px rgba(47, 123, 232, 0.2),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.question-icon :deep(.el-icon) {
font-size: 1em;
}
.question-link {
@@ -257,4 +270,128 @@ const remove = async (row: FaqItem) => {
.question-link {
cursor: pointer;
}
.faq-list-card--desktop {
display: flex;
height: 100%;
min-height: 0;
flex: 1 1 auto;
background: #ffffff;
}
.faq-list-card--desktop .faq-table {
height: 100%;
min-height: 0;
flex: 1 1 auto;
}
.faq-list-card--desktop .faq-table :deep(.el-table__inner-wrapper) {
height: 100%;
}
.faq-list-card--desktop .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
background: linear-gradient(180deg, #f4f9ff 0%, #eaf3fc 100%) !important;
color: #49647f;
font-size: 12px;
font-weight: 850;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body) {
border-collapse: separate;
border-spacing: 0 6px;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper) {
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__wrap) {
scrollbar-width: none;
}
.faq-list-card--desktop .faq-table :deep(.el-scrollbar__bar.is-vertical) {
display: none !important;
}
.faq-list-card--desktop .faq-table :deep(.el-table__empty-block) {
min-height: 100%;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell) {
padding: 10px 14px;
border-top: 1px solid rgba(226, 235, 246, 0.92);
border-bottom: 1px solid rgba(226, 235, 246, 0.92);
background: #ffffff;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:first-child) {
border-left: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 9px 0 0 9px;
}
.faq-list-card--desktop .faq-table :deep(.faq-row td.el-table__cell:last-child) {
border-right: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 0 9px 9px 0;
}
.faq-list-card--desktop .faq-table :deep(.faq-row:hover td.el-table__cell) {
border-color: rgba(111, 159, 220, 0.34);
background: #f5faff;
box-shadow: 0 8px 18px rgba(45, 86, 143, 0.06);
}
.faq-list-card--desktop .question-cell {
gap: 10px;
}
.faq-list-card--desktop .question-icon {
width: 28px;
height: 28px;
border-radius: 8px;
font-size: 14px;
box-shadow:
0 8px 16px rgba(47, 123, 232, 0.18),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.faq-list-card--desktop .question-link {
color: #0f172a;
font-size: 13px;
font-weight: 700;
}
.faq-list-card--desktop .category-pill {
height: 24px;
padding: 0 9px;
border-radius: 7px;
background: linear-gradient(135deg, #e7f3ff 0%, #eefaff 100%);
font-size: 12px;
}
.faq-list-card--desktop .time-text {
gap: 0;
font-size: 11px;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row td.el-table__cell) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .faq-list-card--desktop .faq-table .faq-row:hover td.el-table__cell) {
background: #1d2b42;
}
</style>
+263 -8
View File
@@ -3,33 +3,57 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
const readAppSource = () => readFileSync(resolve(__dirname, "../App.vue"), "utf8");
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
const readTauriConfigSource = () => readFileSync(resolve(__dirname, "../../src-tauri/tauri.conf.json"), "utf8");
const readDesktopPreferencesSource = () => readFileSync(resolve(__dirname, "../views/DesktopPreferences.vue"), "utf8");
const readDesktopServerSettingsSource = () => readFileSync(resolve(__dirname, "../views/DesktopServerSettings.vue"), "utf8");
const readDesktopUpdateManagerSource = () => readFileSync(resolve(__dirname, "../session/desktopUpdateManager.ts"), "utf8");
const readDesktopActivityCenterSource = () => readFileSync(resolve(__dirname, "../session/desktopActivityCenter.ts"), "utf8");
const readProfileSettingsSource = () => readFileSync(resolve(__dirname, "../views/ProfileSettings.vue"), "utf8");
const readLoginSource = () => readFileSync(resolve(__dirname, "../views/Login.vue"), "utf8");
const readRegisterSource = () => readFileSync(resolve(__dirname, "../views/Register.vue"), "utf8");
const readForgotPasswordSource = () => readFileSync(resolve(__dirname, "../views/ForgotPassword.vue"), "utf8");
const readFileTaskFeedbackSource = () => readFileSync(resolve(__dirname, "../utils/fileTaskFeedback.ts"), "utf8");
const readAttachmentListSource = () => readFileSync(resolve(__dirname, "./attachments/AttachmentList.vue"), "utf8");
const readDocumentDetailSource = () => readFileSync(resolve(__dirname, "../views/documents/DocumentDetail.vue"), "utf8");
const readMainStyleSource = () => readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
const readFaqSource = () => readFileSync(resolve(__dirname, "../views/Faq.vue"), "utf8");
const readFaqListSource = () => readFileSync(resolve(__dirname, "./FaqList.vue"), "utf8");
const readProjectOverviewSource = () => readFileSync(resolve(__dirname, "../views/ia/ProjectOverview.vue"), "utf8");
describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => {
const source = readLayoutSource();
const app = readAppSource();
expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />");
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
expect(app).toContain('document.body.classList.add("is-desktop-runtime")');
expect(app).toContain('document.body.classList.remove("is-desktop-runtime")');
});
it("uses route-only desktop preference storage", () => {
const source = readDesktopLayoutSource();
const webSource = readWebLayoutSource();
expect(source).toContain("readDesktopRecentRoutes");
expect(source).toContain("readDesktopFavoriteRoutes");
expect(source).toContain("recordDesktopRecentRoute");
expect(source).toContain("currentDesktopRoutePreference");
expect(source).toContain("toggleDesktopFavoriteRoute");
expect(source).not.toContain("readDesktopRecentRoutes");
expect(source).not.toContain("recordDesktopRecentRoute");
expect(source).not.toContain("desktopRecentRoutes");
expect(source).not.toContain("最近访问");
expect(webSource).toContain("readDesktopFavoriteRoutes");
expect(webSource).toContain("toggleDesktopFavoriteRoute");
expect(webSource).not.toContain("readDesktopRecentRoutes");
expect(webSource).not.toContain("recordDesktopRecentRoute");
expect(webSource).not.toContain("desktopRecentRoutes");
expect(webSource).not.toContain("最近访问");
});
it("shares active route mapping between web and desktop shells", () => {
@@ -45,19 +69,82 @@ describe("desktop layout shell", () => {
it("keeps desktop context labels and command entry points de-duplicated", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("<h1>{{ TEXT.common.appName }} Desktop</h1>");
expect(source).toContain("<h1>{{ TEXT.common.appName }}</h1>");
expect(source).toContain('const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";');
expect(source).toContain('data-tauri-drag-region');
expect(source).toContain('class="toolbar-title"');
expect(source).toContain('class="toolbar-subtitle"');
expect(source).toContain("desktopToolbarTitle");
expect(source).toContain("desktopToolbarSubtitle");
expect(source).not.toContain("desktopBreadcrumbs");
expect(source).not.toContain("breadcrumb-chip");
expect(source).not.toContain("history-controls");
expect(source).not.toContain('title="后退"');
expect(source).not.toContain('title="前进"');
expect(source).not.toContain('title="刷新当前视图"');
expect(source).not.toContain("const sidebarTitle");
expect(source).not.toContain('title="搜索命令"');
expect(source).not.toContain("desktop-statusbar");
expect(source).not.toContain("status-item");
expect(source).not.toContain("accountProjectRoleLabel");
expect(source).not.toContain("projectStatusLabel");
expect(source).toContain("grid-template-rows: 48px auto minmax(0, 1fr);");
expect(source).toContain("grid-template-rows: 52px auto minmax(0, 1fr);");
expect(source).toContain("const adminDesktopNavigationItems");
expect(source).toContain("const projectDesktopNavigationItems");
expect(source).toContain('id: "desktop:refresh"');
expect(source).toContain('shortcut: "⌘R"');
expect(source).toContain('command === "ctms.desktop.back"');
expect(source).toContain('command === "ctms.desktop.forward"');
expect(source).not.toContain("const root = study.currentStudy?.name || TEXT.menu.currentProject");
});
it("uses macOS overlay titlebar without adding window permissions", () => {
const tauriConfig = readTauriConfigSource();
const layout = readDesktopLayoutSource();
expect(tauriConfig).toContain('"decorations": true');
expect(tauriConfig).toContain('"titleBarStyle": "Overlay"');
expect(tauriConfig).toContain('"hiddenTitle": true');
expect(tauriConfig).toContain('"trafficLightPosition": { "x": 16, "y": 18 }');
expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("-webkit-app-region: drag;");
expect(layout).toContain("-webkit-app-region: no-drag;");
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
});
it("keeps the desktop sidebar dense enough for navigation-heavy screens", () => {
const layout = readDesktopLayoutSource();
const sidebarHeadStart = layout.indexOf('<header class="sidebar-head">');
const sidebarHeadEnd = layout.indexOf("</header>", sidebarHeadStart);
const sidebarHeadTemplate = layout.slice(sidebarHeadStart, sidebarHeadEnd);
expect(layout).toContain("grid-template-columns: 248px minmax(0, 1fr);");
expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("grid-template-columns: auto minmax(0, 1fr);");
expect(layout).toContain("margin-top: 0;");
expect(layout).toContain("const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject)");
expect(layout).toContain("{{ desktopProjectSectionLabel }}");
expect(layout).toContain('command="projectEntry"');
expect(layout).toContain("projectEntryMenuLabel");
expect(layout).toContain("openDesktopProjectEntry");
expect(layout).toContain("padding: 10px 8px 24px;");
expect(layout).toContain("margin-top: 12px;");
expect(layout).toContain("font-size: 14px;");
expect(sidebarHeadTemplate).toContain('<div class="sidebar-title-row">');
expect(sidebarHeadTemplate.indexOf("<h1>{{ TEXT.common.appName }}</h1>")).toBeLessThan(
sidebarHeadTemplate.indexOf('<button v-if="!study.currentStudy" class="study-switcher-trigger empty"'),
);
expect(sidebarHeadTemplate).not.toContain("study-context-badge");
expect(layout).not.toContain(".study-context-badge");
expect(layout).not.toContain(".study-name");
expect(sidebarHeadTemplate).not.toContain('@command="handleStudySwitch"');
expect(layout).not.toContain("handleStudySwitch");
expect(layout).not.toContain("`切换项目:${item.name}`");
expect(layout).not.toContain("grid-template-columns: 272px minmax(0, 1fr);");
expect(layout).not.toContain("padding: 54px 16px 14px;");
expect(layout).not.toContain("padding: 44px 12px 12px;");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
@@ -108,7 +195,7 @@ describe("desktop layout shell", () => {
expect(webLayout).not.toContain('width="720px"');
});
it("keeps desktop notification and diagnostics settings out of profile settings", () => {
it("keeps client diagnostics out of profile while desktop controls stay in preferences", () => {
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
@@ -117,8 +204,15 @@ describe("desktop layout shell", () => {
expect(profile).not.toContain("getDesktopNotificationSubscription");
expect(profile).not.toContain("setDesktopNotificationSubscription");
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
expect(profile).not.toContain("clientMetadataRows");
expect(profile).not.toContain("desktopNotificationsEnabled");
expect(profile).not.toContain("form-section--diagnostics");
expect(profile).not.toContain("客户端诊断");
expect(profile).not.toContain("clientDiagnosticRows");
expect(profile).not.toContain("copyClientDiagnostics");
expect(profile).not.toContain("getAppMetadata");
expect(profile).not.toContain("getDesktopServerUrl");
expect(profile).not.toContain("clientRuntime.capabilities");
expect(profile).not.toContain("诊断信息已复制");
expect(preferences).toContain("getDesktopNotificationSubscription");
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
expect(preferences).toContain("clientMetadataRows");
@@ -148,6 +242,8 @@ describe("desktop layout shell", () => {
expect(tabsTemplate).not.toContain('@click="router.push(item.path)"');
expect(source).not.toContain(".slice(-7)");
expect(tabsTemplate).toContain('@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"');
expect(tabsTemplate).not.toContain('class="tab-group"');
expect(tabsTemplate).not.toContain("{{ item.group }}");
expect(source).toContain("const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);");
expect(source).toContain("const workspaceTabMenu = ref<");
expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {");
@@ -188,24 +284,31 @@ describe("desktop layout shell", () => {
expect(serverSettings).not.toContain("copyConnectionDiagnostic");
expect(serverSettings).toContain("确认切换服务器");
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
expect(serverSettings).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(serverSettings).not.toContain("服务器连接已确认");
expect(preferences).toContain("connectionDiagnostic");
expect(preferences).not.toContain("复制连接诊断");
expect(preferences).not.toContain("copyConnectionDiagnostic");
expect(preferences).not.toContain("服务器连通性正常");
expect(preferences).not.toContain("服务器连接已确认");
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(preferences).not.toContain("系统通知已开启");
expect(preferences).not.toContain("系统通知已关闭");
expect(preferences).not.toContain("已授权");
expect(preferences).toContain("showNotificationPermissionTag");
expect(preferences).toContain("showSystemNotificationProbe");
expect(preferences).toContain("sendDesktopNotificationTest");
expect(preferences).toContain("测试通知");
expect(preferences).not.toContain("通知诊断");
expect(preferences).not.toContain("更新诊断");
expect(preferences).not.toContain("listenDesktopNotificationDiagnostics");
expect(preferences).not.toContain("发送测试通知");
expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/"));
expect(preferences).toContain("listenDesktopUpdateStatus");
expect(preferences).toContain("promptForPendingDesktopUpdate");
expect(preferences).toContain("当前已是最新版本");
expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新");
expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒");
expect(desktopUpdateManager).not.toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus");
@@ -220,13 +323,165 @@ describe("desktop layout shell", () => {
expect(helper).toContain("export const pickFilesWithFeedback");
expect(helper).toContain("export const saveFileWithFeedback");
expect(helper).toContain("export const openFileWithFeedback");
expect(helper).toContain("startDesktopActivity");
expect(helper).toContain("finishDesktopActivity");
expect(helper).toContain("desktopFileActivitiesEnabled");
expect(attachments).toContain("pickFilesWithFeedback");
expect(attachments).toContain("saveFileWithFeedback");
expect(attachments).toContain("openFileWithFeedback");
expect(attachments).toContain("startDesktopActivity");
expect(attachments).toContain('title: "下载附件"');
expect(attachments).toContain('title: "上传附件"');
expect(documentDetail).toContain("pickFilesWithFeedback");
expect(documentDetail).toContain("saveFileWithFeedback");
expect(documentDetail).toContain("openFileWithFeedback");
expect(attachments).not.toContain("openFile, pickFiles, saveFile");
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
});
it("surfaces persistent desktop activity feedback without local task persistence", () => {
const desktopLayout = readDesktopLayoutSource();
const activityCenter = readDesktopActivityCenterSource();
const updateManager = readDesktopUpdateManagerSource();
expect(desktopLayout).toContain('popper-class="desktop-activity-popover"');
expect(desktopLayout).toContain("desktopActivities");
expect(desktopLayout).toContain("listenDesktopActivities");
expect(desktopLayout).toContain("clearFinishedDesktopActivities");
expect(desktopLayout).toContain("desktopActivityBadgeCount");
expect(activityCenter).toContain("DESKTOP_ACTIVITY_CHANGED_EVENT");
expect(activityCenter).toContain("safeActivityText");
expect(activityCenter).toContain("unsafeActivityTextPattern");
expect(activityCenter).not.toContain("localStorage");
expect(activityCenter).not.toContain("sessionStorage");
expect(updateManager).toContain('title: "检查桌面更新"');
expect(updateManager).toContain('title: "安装桌面更新"');
expect(updateManager).toContain("finishUpdateActivity");
});
it("keeps desktop business pages dense and workbench-like", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop workbench density */");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.page");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.ctms-page-shell");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-body");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-inner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .overview");
expect(styles).toContain("padding: 0 !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .table-card");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .hero-banner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .faq-hero");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-bg-dots");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .module-placeholder-surface");
expect(styles).toContain("box-shadow: none !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table th.el-table__cell");
expect(styles).toContain("height: 34px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
expect(styles).toContain("padding: 7px 10px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
});
it("keeps authentication styles self-contained for desktop CSP", () => {
const sources = [
readMainStyleSource(),
readLoginSource(),
readRegisterSource(),
readForgotPasswordSource(),
].join("\n");
expect(sources).not.toContain("fonts.googleapis.com");
expect(sources).not.toContain("fonts.gstatic.com");
expect(sources).not.toMatch(/@import\s+url\(["']https?:\/\//);
});
it("renders Element Plus modals with desktop-native surfaces in the desktop runtime", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop runtime modal surfaces */");
expect(styles).toContain("body.is-desktop-runtime .el-overlay");
expect(styles).toContain("left: 0 !important;");
expect(styles).toContain("backdrop-filter: blur(12px) saturate(135%);");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-dialog");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-message-box");
expect(styles).toContain("body.is-desktop-runtime .el-dialog,");
expect(styles).toContain("body.is-desktop-runtime .el-message-box");
expect(styles).toContain("border-radius: 12px;");
expect(styles).toContain("body.is-desktop-runtime .el-dialog__header");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__header");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {\n padding: 0;");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__btns");
expect(styles).toContain("body.is-desktop-runtime .dialog-fade-enter-from .el-dialog");
expect(styles).toContain("body.is-desktop-runtime .msgbox-fade-enter-from .el-message-box");
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog');
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog');
});
it("keeps first-pass desktop content pages workbench-oriented", () => {
const faq = readFaqSource();
const faqList = readFaqListSource();
const projectOverview = readProjectOverviewSource();
expect(faq).toContain("const isDesktop = isTauriRuntime();");
expect(faq).toContain(":class=\"{ 'medical-consult-page--desktop': isDesktop }\"");
expect(faq).toContain('v-if="!isDesktop" class="page-bg-dots"');
expect(faq).toContain('<h1 v-if="!isDesktop"');
expect(faq).toContain('v-else class="desktop-toolbar-meta"');
expect(faq).toContain('v-if="isDesktop" action="faq.create"');
expect(faq).toContain('<div v-if="!isDesktop" class="list-toolbar">');
expect(faq).toContain(".medical-consult-page--desktop .faq-workspace");
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
expect(faqList).toContain(":class=\"{ 'faq-list-card--desktop': isDesktop }\"");
expect(faqList).toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--desktop .faq-table");
expect(projectOverview).toContain(":class=\"{ 'project-overview--desktop': isDesktop }\"");
expect(projectOverview).not.toContain("overview-summary-strip");
expect(projectOverview).not.toContain("const activeCenterCount");
expect(projectOverview).toContain("desktop-attention-section");
expect(projectOverview).not.toContain("项目关注");
expect(projectOverview).not.toContain("desktop-attention-head");
expect(projectOverview).toContain('class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-updated-at"'));
expect(projectOverview.indexOf('class="overview-updated-at"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain("overview-workbench");
expect(projectOverview).toContain("enrollment-snapshot");
expect(projectOverview).toContain("desktop-attention-board");
expect(projectOverview).toContain("const stageStatusSummary");
expect(projectOverview).toContain("const activeStageItems");
expect(projectOverview).toContain("const attentionItems");
expect(projectOverview).toContain("grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);");
expect(projectOverview).toContain("max-height: min(360px, calc(100vh - 300px));");
expect(projectOverview).toContain("@media (max-width: 1240px)");
expect(projectOverview).toContain(".project-overview--desktop .overview-card");
expect(projectOverview).toContain(".project-overview--desktop .overview-card--enrollment");
expect(projectOverview).toContain(".project-overview--desktop :deep(.center-row)");
expect(projectOverview).toContain(".project-overview--desktop :deep(.stage-node)");
});
it("keeps desktop entry surfaces stable at the minimum window size", () => {
const login = readLoginSource();
const serverSettings = readDesktopServerSettingsSource();
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(login).toContain("@media (max-width: 1280px)");
expect(login).toContain("width: min(440px, 100%);");
expect(login).toContain("overflow-wrap: anywhere;");
expect(login).toContain("white-space: nowrap;");
expect(serverSettings).toContain("max-height: calc(100vh - 64px);");
expect(serverSettings).toContain("overflow: auto;");
expect(serverSettings).toContain(".diagnostic-grid code");
expect(profile).toContain('<div class="profile-layout">');
expect(profile).not.toContain('class="page"');
expect(profile).toContain("height: min(720px, calc(100vh - 64px));");
expect(profile).toContain("overflow: hidden;");
expect(profile).not.toContain("overflow: auto;");
expect(profile).toContain("overflow-wrap: anywhere;");
expect(preferences).toContain("flex-wrap: wrap;");
expect(preferences).toContain("overflow-wrap: anywhere;");
});
});
@@ -519,7 +519,14 @@ const openSecurityLogDialog = () => {
const downloadLogFile = async (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
await saveFileWithFeedback({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
await saveFileWithFeedback(
{ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob },
{
kind: "export",
title: "导出日志",
completedDetail: "日志文件已保存",
},
);
};
const downloadInterfaceLog = () => {
+22 -27
View File
@@ -22,16 +22,6 @@
</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="isDesktop && desktopRecentRoutes.length" class="menu-group desktop-menu-group">
<template #title>
<span class="menu-divider">最近访问</span>
</template>
<el-menu-item v-for="item in desktopRecentRoutes" :key="`recent:${item.path}`" :index="item.path">
<el-icon><Clock /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="auth.user" class="menu-group">
<template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
@@ -434,13 +424,12 @@ import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import {
DESKTOP_SERVER_URL_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
getAppMetadata,
getDesktopServerUrl,
isTauriRuntime,
listenDesktopMenuCommand,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
recordDesktopRecentRoute,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
} from "../runtime";
@@ -472,7 +461,6 @@ const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl());
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerOverviewStats = ref<{
centerActual: number;
@@ -720,9 +708,6 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
const refreshDesktopRoutePreferences = () => {
if (!isDesktop) return;
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
@@ -1084,7 +1069,6 @@ watch(() => route.path, () => {
if (isDesktop) {
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
refreshDesktopRoutePreferences();
}
}
@@ -1145,10 +1129,7 @@ onMounted(async () => {
}, 1000);
if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
}
clearLegacyDesktopRouteHistoryPreference();
refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
}
@@ -2504,9 +2485,15 @@ useDesktopShortcuts(
}
:global(.profile-settings-dialog) {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
/* 移除外层卡片外壳,只保留容器行为 */
--el-dialog-bg-color: transparent !important;
--el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
}
:global(.profile-settings-dialog .el-dialog__header) {
@@ -2515,11 +2502,19 @@ useDesktopShortcuts(
:global(.profile-settings-dialog .el-dialog__body) {
padding: 0;
background: transparent;
}
:global(.desktop-preferences-dialog) {
border-radius: 14px;
overflow: hidden;
/* 移除外层卡片外壳,内容区自带圆角阴影 */
--el-dialog-bg-color: transparent !important;
--el-dialog-box-shadow: none !important;
--el-dialog-border-radius: 0 !important;
overflow: visible !important;
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
border: none !important;
}
:global(.desktop-preferences-dialog .el-dialog__header) {
@@ -2528,6 +2523,6 @@ useDesktopShortcuts(
:global(.desktop-preferences-dialog .el-dialog__body) {
padding: 0;
background: #ffffff;
background: transparent;
}
</style>
@@ -154,6 +154,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
type AttachmentEntityGroup = {
entityType: string;
@@ -253,6 +254,7 @@ const tableProgress = computed(() => {
return group ? progressMap[group.key] || 0 : 0;
});
const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100);
const desktopActivityEnabled = () => nativeFiles;
const validateFile = (file: File) => {
if (file.size > maxSize.value * 1024 * 1024) {
@@ -293,14 +295,21 @@ const pendingSnapshot = () =>
uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
const uploadFile = async (group: UploadGroup, file: File, targetEntityId: string) => {
const uploadFile = async (
group: UploadGroup,
file: File,
targetEntityId: string,
onProgress?: (progress: number) => void,
) => {
if (!props.studyId || !targetEntityId) return;
if (!validateFile(file)) throw new Error("invalid file");
progressMap[group.key] = 0;
await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progressMap[group.key] = Math.round((evt.loaded / evt.total) * 100);
const progress = Math.round((evt.loaded / evt.total) * 100);
progressMap[group.key] = progress;
onProgress?.(progress);
}
},
});
@@ -313,6 +322,16 @@ const uploadPending = async (entityId?: string) => {
}
if (!targetEntityId) return;
let hasFailure = false;
const totalCount = uploadGroups.value.reduce((sum, group) => sum + (pendingMap[group.key] || []).length, 0);
const activityId = desktopActivityEnabled() && totalCount > 0
? startDesktopActivity({
kind: "upload",
title: "上传附件",
detail: `正在上传 ${totalCount} 个附件`,
progress: 0,
})
: "";
let completedCount = 0;
for (const group of uploadGroups.value) {
const items = [...(pendingMap[group.key] || [])];
const remainItems: PendingUploadItem[] = [];
@@ -320,19 +339,36 @@ const uploadPending = async (entityId?: string) => {
try {
item.status = "uploading";
item.error = "";
await uploadFile(group, item.file, targetEntityId);
await uploadFile(group, item.file, targetEntityId, (progress) => {
if (!activityId || !totalCount) return;
updateDesktopActivity(activityId, {
detail: `正在上传 ${completedCount + 1}/${totalCount}`,
progress: Math.min(99, Math.round(((completedCount + progress / 100) / totalCount) * 100)),
});
});
} catch (e: any) {
hasFailure = true;
item.status = "failed";
item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed;
remainItems.push(item);
} finally {
completedCount += 1;
if (activityId && totalCount) {
updateDesktopActivity(activityId, {
detail: `已处理 ${completedCount}/${totalCount}`,
progress: Math.min(99, Math.round((completedCount / totalCount) * 100)),
});
}
progressMap[group.key] = 0;
}
}
pendingMap[group.key] = remainItems;
}
if (hasFailure) throw new Error(TEXT.common.messages.uploadFailed);
if (hasFailure) {
finishDesktopActivity(activityId, "failed", { detail: "部分附件上传失败" });
throw new Error(TEXT.common.messages.uploadFailed);
}
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
};
const uploadImmediate = async (options: any) => {
@@ -340,11 +376,18 @@ const uploadImmediate = async (options: any) => {
const group = tableUploadGroup.value;
if (!file || !group || !props.entityId) return;
if (!validateFile(file)) return;
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "upload", title: "上传附件", detail: "正在上传附件", progress: 0 })
: "";
try {
await uploadFile(group, file, props.entityId);
ElMessage.success(TEXT.common.messages.uploadSuccess);
await uploadFile(group, file, props.entityId, (progress) => {
updateDesktopActivity(activityId, { detail: "正在上传附件", progress });
});
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
if (!desktopActivityEnabled()) ElMessage.success(TEXT.common.messages.uploadSuccess);
load();
} catch (e: any) {
finishDesktopActivity(activityId, "failed", { detail: "附件上传失败" });
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed);
} finally {
progressMap[group.key] = 0;
@@ -399,25 +442,48 @@ const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
};
const download = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "download", title: "下载附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try {
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "等待选择保存位置", progress: 65 });
await saveFileWithFeedback({
suggestedName: row?.filename || "download",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
data: blob,
}, {
activityId,
title: "下载附件",
pendingDetail: "等待选择保存位置",
completedDetail: "附件已保存",
cancelledDetail: "已取消保存附件",
});
} catch {
finishDesktopActivity(activityId, "failed", { detail: "附件下载失败" });
ElMessage.error(TEXT.common.messages.downloadFailed);
}
};
const openExternally = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "open", title: "打开附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try {
const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "正在准备文件", progress: 65 });
await openFileWithFeedback({
suggestedName: row?.filename || "attachment",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
data: blob,
}, {
activityId,
title: "打开附件",
pendingDetail: "正在准备文件",
completedDetail: "已交给系统打开",
});
} catch {
finishDesktopActivity(activityId, "failed", { detail: "附件打开失败" });
ElMessage.error(TEXT.common.messages.previewNotSupported);
}
};
-1
View File
@@ -736,7 +736,6 @@ export const TEXT = {
treeTitle: "TMF 目录",
detailTitle: "归档状态",
noNodeSelected: "未选择目录",
selectNodeHint: "请选择左侧目录查看文件",
emptyDocuments: "当前目录暂无文件",
rootNode: "根目录",
actions: {
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readIndexHtml = () => readFileSync(resolve(__dirname, "../index.html"), "utf8");
describe("app bootstrap shell", () => {
it("renders a lightweight startup placeholder before Vue mounts", () => {
const source = readIndexHtml();
expect(source).toContain('<div id="app">');
expect(source).toContain("ctms-boot-splash");
expect(source).toContain("正在启动 CTMS");
expect(source).toContain("正在加载桌面客户端");
});
});
+2 -2
View File
@@ -12,7 +12,7 @@ import App from "./App.vue";
import router from "./router";
import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study";
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime";
import { cleanupTemporaryFiles, initializeSecureSessionStorage, isTauriRuntime, shouldRequireDesktopServerUrl } from "./runtime";
const bootstrap = async () => {
await initializeSecureSessionStorage().catch((error) => {
@@ -30,7 +30,7 @@ const bootstrap = async () => {
// 初始化项目上下文
const studyStore = useStudyStore();
studyStore.loadCurrentStudy();
if (getToken() && !shouldRequireDesktopServerUrl()) {
if (getToken() && !shouldRequireDesktopServerUrl() && !isTauriRuntime()) {
await studyStore.rehydrateStudyForLastUser();
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
+33 -2
View File
@@ -78,12 +78,43 @@ describe("admin project route permissions", () => {
expect(source).not.toContain("[SYSTEM_PERMISSION_MONITORING_METRICS]");
});
it("keeps login landing independent from PM management backend access", () => {
it("routes desktop login and missing-project flows through the dedicated entry chooser", () => {
const source = readRouter();
expect(source).not.toContain("findPmAdminLandingPath");
expect(source).not.toContain("pmAdminLandingModules");
expect(source).toContain(' : studyStore.currentStudy\n ? "/project/overview"\n : "/admin/projects",');
expect(source).toContain('path: "/desktop/project-entry"');
expect(source).toContain('component: DesktopProjectEntry');
expect(source).toContain("DesktopSessionRestore");
expect(source).toContain("DESKTOP_SESSION_RESTORE_PATH");
expect(source).toContain('if (isDesktopRuntime) {\n next({ path: "/desktop/project-entry" });');
expect(source).toContain('isDesktopRuntime ? "/desktop/project-entry" : isAdmin ? "/admin/users" : "/admin/projects"');
expect(source).toContain('if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin"))');
expect(source).toContain('if (isDesktopRuntime && token && to.path === "/desktop/project-entry" && studyStore.currentStudy)');
expect(source).toContain('if (token && !studyStore.currentStudy && !isDesktopRuntime)');
});
it("validates restored session tokens through /me before entering protected routes", () => {
const source = readRouter();
const restoreGuardIndex = source.indexOf("if (!auth.user && getToken() && !isDesktopSessionRestoreRoute)");
const fetchMeIndex = source.indexOf("await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })", restoreGuardIndex);
const preserveIndex = source.indexOf("shouldPreserveDesktopSessionOnAuthCheckFailure(error)", fetchMeIndex);
const restoreRedirectIndex = source.indexOf("next({ path: DESKTOP_SESSION_RESTORE_PATH", preserveIndex);
const logoutIndex = source.indexOf("await auth.logout()", restoreRedirectIndex);
const loginRedirectIndex = source.indexOf('next({ path: "/login" })', logoutIndex);
const projectFallbackIndex = source.indexOf("if (token && !studyStore.currentStudy && !isDesktopRuntime)", restoreGuardIndex);
expect(restoreGuardIndex).toBeGreaterThan(-1);
expect(fetchMeIndex).toBeGreaterThan(restoreGuardIndex);
expect(preserveIndex).toBeGreaterThan(fetchMeIndex);
expect(restoreRedirectIndex).toBeGreaterThan(preserveIndex);
expect(logoutIndex).toBeGreaterThan(restoreRedirectIndex);
expect(loginRedirectIndex).toBeGreaterThan(logoutIndex);
expect(fetchMeIndex).toBeLessThan(projectFallbackIndex);
expect(source).toContain("const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH");
expect(source).toContain("!isDesktopSessionRestoreRoute");
expect(source).toContain("disableNetworkRetry: true");
expect(source).toContain("suppressErrorMessage: true");
});
it("does not register the removed non-admin personal dashboard route", () => {
+51 -7
View File
@@ -11,6 +11,8 @@ import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue";
import DesktopServerSettings from "../views/DesktopServerSettings.vue";
import DesktopProjectEntry from "../views/DesktopProjectEntry.vue";
import DesktopSessionRestore from "../views/DesktopSessionRestore.vue";
import StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
@@ -57,6 +59,8 @@ import SubjectForm from "../views/subjects/SubjectForm.vue";
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales";
import { resolveDesktopRouteRedirect } from "./desktopGuard";
import { isTauriRuntime } from "../runtime";
import { DESKTOP_SESSION_RESTORE_PATH, shouldPreserveDesktopSessionOnAuthCheckFailure } from "../session/authRecovery";
const SYSTEM_PERMISSION_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
@@ -86,6 +90,18 @@ const routes: RouteRecordRaw[] = [
component: DesktopServerSettings,
meta: { public: true, title: "服务器设置" },
},
{
path: DESKTOP_SESSION_RESTORE_PATH,
name: "DesktopSessionRestore",
component: DesktopSessionRestore,
meta: { public: true, title: "恢复登录状态" },
},
{
path: "/desktop/project-entry",
name: "DesktopProjectEntry",
component: DesktopProjectEntry,
meta: { title: "选择工作入口" },
},
{
path: "/",
component: Layout,
@@ -512,6 +528,7 @@ const ensureProjectPermissionAccess = async (
};
router.beforeEach(async (to, _from, next) => {
const isDesktopRuntime = isTauriRuntime();
const desktopRedirect = resolveDesktopRouteRedirect(to);
if (desktopRedirect) {
next({ path: desktopRedirect });
@@ -522,10 +539,15 @@ router.beforeEach(async (to, _from, next) => {
const studyStore = useStudyStore();
const getToken = () => auth.token;
let token = getToken();
if (!auth.user && getToken()) {
const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
if (!auth.user && getToken() && !isDesktopSessionRestoreRoute) {
try {
await auth.fetchMe();
} catch {
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
} catch (error) {
if (isDesktopRuntime && shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
next({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: to.fullPath } });
return;
}
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
await auth.logout();
next({ path: "/login" });
@@ -534,7 +556,11 @@ router.beforeEach(async (to, _from, next) => {
}
token = getToken();
const isAdmin = isSystemAdmin(auth.user);
if (token && !studyStore.currentStudy) {
if (!to.meta.public && !token) {
next({ path: "/login" });
return;
}
if (token && !studyStore.currentStudy && !isDesktopRuntime) {
if (isAdmin) {
await studyStore.ensureDefaultActiveStudy();
} else {
@@ -544,8 +570,22 @@ router.beforeEach(async (to, _from, next) => {
if (token && studyStore.currentStudy && auth.user?.email) {
studyStore.rememberCurrentStudyForUser(auth.user.email);
}
if (!to.meta.public && !token) {
next({ path: "/login" });
if (isDesktopRuntime && token && to.path === "/desktop/project-entry" && studyStore.currentStudy) {
studyStore.clearCurrentStudy();
}
if (isDesktopRuntime && token && !isAdmin && to.path.startsWith("/admin")) {
next({ path: "/desktop/project-entry" });
return;
}
if (isDesktopRuntime && token && isAdmin && to.path.startsWith("/admin") && studyStore.currentStudy) {
studyStore.clearCurrentStudy();
}
if (isDesktopRuntime && token && to.path === "/") {
next({ path: studyStore.currentStudy ? "/project/overview" : "/desktop/project-entry" });
return;
}
if (isAdmin && to.path === "/") {
@@ -554,6 +594,10 @@ router.beforeEach(async (to, _from, next) => {
}
if ((to.path === "/login" || to.path === "/register") && token) {
if (!auth.forceLogin) {
if (isDesktopRuntime) {
next({ path: "/desktop/project-entry" });
return;
}
next({
path: isAdmin
? studyStore.currentStudy
@@ -621,7 +665,7 @@ router.beforeEach(async (to, _from, next) => {
}
}
if (to.meta.requiresStudy && !studyStore.currentStudy) {
next({ path: isAdmin ? "/admin/users" : "/admin/projects" });
next({ path: isDesktopRuntime ? "/desktop/project-entry" : isAdmin ? "/admin/users" : "/admin/projects" });
return;
}
if (to.meta.requiresStudy && studyStore.currentStudy) {
@@ -1,13 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_KEY,
applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference,
toggleDesktopFavoriteRoute,
} from "./desktopUiPreferences";
@@ -32,17 +30,17 @@ describe("desktop UI preferences", () => {
document.documentElement.style.colorScheme = "";
});
it("stores only route metadata for recent desktop routes", () => {
recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
it("stores only route metadata for desktop favorites", () => {
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
expect(readDesktopRecentRoutes()).toEqual([
expect(readDesktopFavoriteRoutes()).toEqual([
expect.objectContaining({
path: "/subjects",
title: "受试者",
group: "当前项目",
}),
]);
expect(window.localStorage.getItem(DESKTOP_RECENT_ROUTES_KEY)).not.toContain("token");
expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("token");
});
it("deduplicates favorites by path", () => {
@@ -55,6 +53,21 @@ describe("desktop UI preferences", () => {
expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no");
});
it("clears legacy desktop route history storage without changing favorites", () => {
window.localStorage.setItem("ctms_desktop_recent_routes", JSON.stringify([{ path: "/subjects", title: "受试者" }]));
toggleDesktopFavoriteRoute({ path: "/file-versions", title: "文件版本" });
clearLegacyDesktopRouteHistoryPreference();
expect(window.localStorage.getItem("ctms_desktop_recent_routes")).toBeNull();
expect(readDesktopFavoriteRoutes()).toEqual([
expect.objectContaining({
path: "/file-versions",
title: "文件版本",
}),
]);
});
it("stores and applies only the desktop theme enum", () => {
expect(readDesktopThemePreference()).toBe("light");
+4 -13
View File
@@ -1,4 +1,3 @@
export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes";
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
@@ -12,10 +11,10 @@ export interface DesktopRoutePreference {
updatedAt: string;
}
const MAX_RECENT_ROUTES = 8;
const MAX_FAVORITE_ROUTES = 12;
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme";
const LEGACY_DESKTOP_ROUTE_HISTORY_KEY = "ctms_desktop_recent_routes";
const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
@@ -80,19 +79,11 @@ export const setDesktopThemePreference = (theme: DesktopThemePreference): Deskto
return nextTheme;
};
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
export const recordDesktopRecentRoute = (route: Pick<DesktopRoutePreference, "path" | "title" | "group">): DesktopRoutePreference[] => {
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
if (!item) return readDesktopRecentRoutes();
const next = [
item,
...readDesktopRecentRoutes().filter((existing) => existing.path !== item.path),
].slice(0, MAX_RECENT_ROUTES);
writeRoutePreferences(DESKTOP_RECENT_ROUTES_KEY, next);
return next;
export const clearLegacyDesktopRouteHistoryPreference = () => {
if (!isStorageAvailable()) return;
window.localStorage.removeItem(LEGACY_DESKTOP_ROUTE_HISTORY_KEY);
};
export const isDesktopFavoriteRoute = (path: string): boolean =>
+8 -3
View File
@@ -10,15 +10,13 @@ export {
} from "./desktopServerConfig";
export {
DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_CHANGED_EVENT,
DESKTOP_THEME_KEY,
applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
isDesktopFavoriteRoute,
readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference,
toggleDesktopFavoriteRoute,
type DesktopRoutePreference,
@@ -43,6 +41,7 @@ export {
getNotificationPermission,
requestNotificationPermission,
showSystemNotification,
showSystemNotificationProbe,
type NotificationPermissionState,
} from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
@@ -53,6 +52,12 @@ export {
isSecureSessionStorageAvailable,
setSessionToken,
} from "./secureSessionStorage";
export {
clearLoginCredential,
getSavedLoginCredential,
saveLoginCredential,
type SavedLoginCredential,
} from "./savedLoginCredentials";
export {
checkForDesktopUpdate,
installPendingDesktopUpdate,
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getNotificationPermission, requestNotificationPermission, showSystemNotificationProbe } from "./notifications";
const isPermissionGrantedMock = vi.hoisted(() => vi.fn());
const requestPermissionMock = vi.hoisted(() => vi.fn());
const notificationDispatchMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/plugin-notification", () => ({
isPermissionGranted: isPermissionGrantedMock,
requestPermission: requestPermissionMock,
["send" + "Notification"]: notificationDispatchMock,
}));
const enableTauriRuntime = () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
};
const setBrowserNotificationPermission = (permission: NotificationPermission) => {
const NotificationMock = class {
static permission = permission;
static requestPermission = requestPermissionMock;
};
Object.defineProperty(window, "Notification", {
value: NotificationMock,
configurable: true,
});
};
afterEach(() => {
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "Notification");
vi.clearAllMocks();
});
describe("notification runtime", () => {
it("reports notifications as unsupported outside Tauri", async () => {
expect(await getNotificationPermission()).toBe("unsupported");
expect(await requestNotificationPermission()).toBe("unsupported");
});
it("reads granted and denied states without prompting", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("granted");
expect(await getNotificationPermission()).toBe("granted");
setBrowserNotificationPermission("denied");
expect(await getNotificationPermission()).toBe("denied");
expect(isPermissionGrantedMock).not.toHaveBeenCalled();
});
it("falls back to the Tauri permission check while the browser state is default", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("default");
isPermissionGrantedMock.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
expect(await getNotificationPermission()).toBe("granted");
expect(await getNotificationPermission()).toBe("prompt");
});
it("keeps cancelled permission prompts in the prompt state", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("default");
isPermissionGrantedMock.mockResolvedValue(false);
requestPermissionMock.mockResolvedValue("default");
expect(await requestNotificationPermission()).toBe("prompt");
});
it("maps explicit permission denial after a request", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("default");
isPermissionGrantedMock.mockResolvedValue(false);
requestPermissionMock.mockResolvedValue("denied");
expect(await requestNotificationPermission()).toBe("denied");
});
it("does not dispatch a system notification before permission is granted", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("default");
isPermissionGrantedMock.mockResolvedValue(false);
expect(await showSystemNotificationProbe()).toBe(false);
expect(notificationDispatchMock).not.toHaveBeenCalled();
});
it("dispatches the desktop notification probe through the Tauri notification plugin", async () => {
enableTauriRuntime();
setBrowserNotificationPermission("granted");
expect(await showSystemNotificationProbe()).toBe(true);
expect(notificationDispatchMock).toHaveBeenCalledWith({
title: "CTMS 通知测试",
body: "系统通知已可用",
});
});
});
+41 -7
View File
@@ -2,8 +2,37 @@ import { isTauriRuntime } from "./platform";
export type NotificationPermissionState = "granted" | "denied" | "prompt" | "unsupported";
type StaticNotificationPayload = {
title: string;
body: string;
};
const fileUpdateNotification: StaticNotificationPayload = {
title: "CTMS 文件更新",
body: "有新的文件版本待查看",
};
const notificationProbe: StaticNotificationPayload = {
title: "CTMS 通知测试",
body: "系统通知已可用",
};
const toNotificationPermissionState = (permission: NotificationPermission): NotificationPermissionState => {
if (permission === "granted") return "granted";
if (permission === "denied") return "denied";
return "prompt";
};
const readWebNotificationPermission = (): NotificationPermissionState | null => {
if (typeof window === "undefined" || !("Notification" in window)) return null;
return toNotificationPermissionState(window.Notification.permission);
};
export const getNotificationPermission = async (): Promise<NotificationPermissionState> => {
if (!isTauriRuntime()) return "unsupported";
const webPermission = readWebNotificationPermission();
if (webPermission === "granted" || webPermission === "denied") return webPermission;
const { isPermissionGranted } = await import("@tauri-apps/plugin-notification");
return (await isPermissionGranted()) ? "granted" : "prompt";
};
@@ -12,14 +41,19 @@ export const requestNotificationPermission = async (): Promise<NotificationPermi
if (!isTauriRuntime()) return "unsupported";
const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification");
if (await isPermissionGranted()) return "granted";
return (await requestPermission()) === "granted" ? "granted" : "denied";
return toNotificationPermissionState(await requestPermission());
};
export const showSystemNotification = async (): Promise<void> => {
if (!isTauriRuntime()) return;
const sendStaticSystemNotification = async (payload: StaticNotificationPayload): Promise<boolean> => {
if (!isTauriRuntime()) return false;
if ((await getNotificationPermission()) !== "granted") return false;
const { sendNotification } = await import("@tauri-apps/plugin-notification");
sendNotification({
title: "CTMS 文件更新",
body: "有新的文件版本待查看",
});
sendNotification(payload);
return true;
};
export const showSystemNotification = async (): Promise<boolean> =>
sendStaticSystemNotification(fileUpdateNotification);
export const showSystemNotificationProbe = async (): Promise<boolean> =>
sendStaticSystemNotification(notificationProbe);
@@ -0,0 +1,137 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig";
import {
clearLoginCredential,
getSavedLoginCredential,
saveLoginCredential,
} from "./savedLoginCredentials";
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));
const SERVER_ORIGIN = "https://ctms.example.com/";
const createStorage = (): Storage => {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear: () => data.clear(),
getItem: (key) => data.get(key) ?? null,
key: (index) => Array.from(data.keys())[index] ?? null,
removeItem: (key) => data.delete(key),
setItem: (key, value) => {
data.set(key, String(value));
},
};
};
describe("saved login credentials", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-08T00:00:00.000Z"));
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
invokeMock.mockReset();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
afterEach(() => {
vi.useRealTimers();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
Reflect.deleteProperty(window, "PasswordCredential");
Reflect.deleteProperty(navigator, "credentials");
});
it("stores desktop remembered passwords in the system credential store", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
let stored = "";
invokeMock.mockImplementation(async (command: string, args: any) => {
if (command === "login_credential_set") {
stored = args.credential;
return undefined;
}
if (command === "login_credential_get") return stored;
if (command === "login_credential_delete") {
stored = "";
return undefined;
}
return undefined;
});
await expect(saveLoginCredential("Admin@Example.com", "secret-password")).resolves.toBe(true);
expect(invokeMock).toHaveBeenCalledWith("login_credential_set", {
serverOrigin: SERVER_ORIGIN,
credential: expect.any(String),
});
expect(JSON.parse(stored)).toMatchObject({
version: 1,
email: "admin@example.com",
password: "secret-password",
savedAt: Date.now(),
});
await expect(getSavedLoginCredential()).resolves.toEqual({
email: "admin@example.com",
password: "secret-password",
});
await expect(clearLoginCredential()).resolves.toBe(true);
expect(invokeMock).toHaveBeenCalledWith("login_credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("deletes malformed desktop remembered credentials", async () => {
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
invokeMock.mockImplementation(async (command: string) => {
if (command === "login_credential_get") return JSON.stringify({ password: "missing-email" });
return undefined;
});
await expect(getSavedLoginCredential()).resolves.toBeNull();
expect(invokeMock).toHaveBeenCalledWith("login_credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("uses browser credential management without writing passwords to localStorage", async () => {
const storeMock = vi.fn();
const preventSilentAccessMock = vi.fn();
Object.defineProperty(window, "PasswordCredential", {
configurable: true,
value: class {
id: string;
password: string;
constructor(data: { id: string; password: string }) {
this.id = data.id;
this.password = data.password;
}
},
});
Object.defineProperty(navigator, "credentials", {
configurable: true,
value: {
get: vi.fn(async () => ({ id: "Browser@Example.com", password: "browser-secret" })),
store: storeMock,
preventSilentAccess: preventSilentAccessMock,
},
});
await expect(getSavedLoginCredential()).resolves.toEqual({
email: "browser@example.com",
password: "browser-secret",
});
await expect(saveLoginCredential("Browser@Example.com", "browser-secret")).resolves.toBe(true);
await expect(clearLoginCredential()).resolves.toBe(true);
expect(storeMock).toHaveBeenCalledTimes(1);
expect(preventSilentAccessMock).toHaveBeenCalledTimes(1);
expect(JSON.stringify(localStorage)).not.toContain("browser-secret");
});
});
@@ -0,0 +1,160 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
const SAVED_LOGIN_CREDENTIAL_VERSION = 1;
export interface SavedLoginCredential {
email: string;
password: string;
}
type StoredLoginCredential = {
version?: unknown;
email?: unknown;
password?: unknown;
savedAt?: unknown;
};
const invokeLoginCredential = async <T>(
command: "login_credential_get" | "login_credential_set" | "login_credential_delete",
args: Record<string, string>,
): Promise<T> => {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<T>(command, args);
};
const normalizeEmail = (email: string): string => email.trim().toLowerCase();
const serializeDesktopCredential = (credential: SavedLoginCredential): string =>
JSON.stringify({
version: SAVED_LOGIN_CREDENTIAL_VERSION,
email: normalizeEmail(credential.email),
password: credential.password,
savedAt: Date.now(),
});
const parseDesktopCredential = (stored: string | null): SavedLoginCredential | null => {
if (!stored) return null;
try {
const payload = JSON.parse(stored) as StoredLoginCredential;
if (
payload.version !== SAVED_LOGIN_CREDENTIAL_VERSION ||
typeof payload.email !== "string" ||
typeof payload.password !== "string" ||
typeof payload.savedAt !== "number"
) {
return null;
}
const email = normalizeEmail(payload.email);
if (!email || !payload.password) return null;
return { email, password: payload.password };
} catch {
return null;
}
};
const getDesktopSavedLoginCredential = async (): Promise<SavedLoginCredential | null> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) return null;
const stored = await invokeLoginCredential<string | null>("login_credential_get", { serverOrigin });
const parsed = parseDesktopCredential(stored);
if (!parsed && stored) {
await invokeLoginCredential<void>("login_credential_delete", { serverOrigin });
}
return parsed;
};
const saveDesktopLoginCredential = async (credential: SavedLoginCredential): Promise<boolean> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) return false;
await invokeLoginCredential<void>("login_credential_set", {
serverOrigin,
credential: serializeDesktopCredential(credential),
});
return true;
};
const clearDesktopLoginCredential = async (): Promise<boolean> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) return false;
await invokeLoginCredential<void>("login_credential_delete", { serverOrigin });
return true;
};
const getPasswordCredentialConstructor = (): (new (data: { id: string; name?: string; password: string }) => unknown) | null => {
const ctor = (window as unknown as { PasswordCredential?: unknown }).PasswordCredential;
return typeof ctor === "function"
? (ctor as new (data: { id: string; name?: string; password: string }) => unknown)
: null;
};
const getBrowserCredentialContainer = ():
| {
get?: (options: Record<string, unknown>) => Promise<unknown>;
store?: (credential: unknown) => Promise<unknown>;
preventSilentAccess?: () => Promise<void>;
}
| null => {
const container = (navigator as unknown as { credentials?: unknown }).credentials;
return container && typeof container === "object" ? (container as any) : null;
};
const getBrowserSavedLoginCredential = async (): Promise<SavedLoginCredential | null> => {
const credentials = getBrowserCredentialContainer();
if (!credentials?.get) return null;
try {
const credential = await credentials.get({
password: true,
mediation: "optional",
});
const passwordCredential = credential as { id?: unknown; password?: unknown } | null;
if (typeof passwordCredential?.id !== "string" || typeof passwordCredential.password !== "string") {
return null;
}
const email = normalizeEmail(passwordCredential.id);
if (!email || !passwordCredential.password) return null;
return { email, password: passwordCredential.password };
} catch {
return null;
}
};
const saveBrowserLoginCredential = async (credential: SavedLoginCredential): Promise<boolean> => {
const credentials = getBrowserCredentialContainer();
const PasswordCredential = getPasswordCredentialConstructor();
if (!credentials?.store || !PasswordCredential) return false;
const email = normalizeEmail(credential.email);
if (!email || !credential.password) return false;
await credentials.store(
new PasswordCredential({
id: email,
name: email,
password: credential.password,
}),
);
return true;
};
const clearBrowserLoginCredential = async (): Promise<boolean> => {
const credentials = getBrowserCredentialContainer();
if (!credentials?.preventSilentAccess) return false;
await credentials.preventSilentAccess();
return true;
};
export const getSavedLoginCredential = async (): Promise<SavedLoginCredential | null> => {
if (isTauriRuntime()) return getDesktopSavedLoginCredential();
return getBrowserSavedLoginCredential();
};
export const saveLoginCredential = async (email: string, password: string): Promise<boolean> => {
const credential = { email: normalizeEmail(email), password };
if (!credential.email || !credential.password) return false;
if (isTauriRuntime()) return saveDesktopLoginCredential(credential);
return saveBrowserLoginCredential(credential);
};
export const clearLoginCredential = async (): Promise<boolean> => {
if (isTauriRuntime()) return clearDesktopLoginCredential();
return clearBrowserLoginCredential();
};
@@ -1,8 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DESKTOP_SERVER_URL_KEY } from "./desktopServerConfig";
import {
clearSessionToken,
getSessionToken,
initializeSecureSessionStorage,
LEGACY_TOKEN_KEY,
resetSecureSessionStorageForTests,
setSessionToken,
} from "./secureSessionStorage";
@@ -91,6 +93,22 @@ describe("secure session storage", () => {
expect(invokeMock).toHaveBeenCalledWith("credential_get", { serverOrigin: SERVER_ORIGIN });
});
it("migrates legacy browser tokens into the desktop credential store", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
localStorage.setItem(LEGACY_TOKEN_KEY, token);
await initializeSecureSessionStorage();
expect(localStorage.getItem(LEGACY_TOKEN_KEY)).toBeNull();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
serverOrigin: SERVER_ORIGIN,
token: expect.any(String),
});
const stored = JSON.parse(invokeMock.mock.calls[0][1].token);
expect(stored).toMatchObject({ version: 1, token });
});
it("deletes an expired desktop secure session record on startup", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
@@ -111,6 +129,61 @@ describe("secure session storage", () => {
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("enforces the local 30 day desktop session ceiling even when the token expires later", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS * 2);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") {
return JSON.stringify({
version: 1,
token,
storedAt: Date.now() - DESKTOP_SESSION_MAX_AGE_MS - 1_000,
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
});
}
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBeNull();
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: SERVER_ORIGIN });
});
it("does not read credentials before a desktop server URL is configured", async () => {
localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
await initializeSecureSessionStorage();
expect(getSessionToken()).toBeNull();
expect(invokeMock).not.toHaveBeenCalled();
});
it("clears the previous server credential after a desktop server switch", async () => {
const previousServerOrigin = "https://old.ctms.example.com/";
const nextServerOrigin = "https://new.ctms.example.com/";
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
localStorage.setItem(DESKTOP_SERVER_URL_KEY, previousServerOrigin);
invokeMock.mockImplementation(async (command: string) => {
if (command === "credential_get") {
return JSON.stringify({
version: 1,
token,
storedAt: Date.now(),
expiresAt: Date.now() + DESKTOP_SESSION_MAX_AGE_MS,
});
}
return undefined;
});
await initializeSecureSessionStorage();
localStorage.setItem(DESKTOP_SERVER_URL_KEY, nextServerOrigin);
await clearSessionToken();
expect(getSessionToken()).toBeNull();
expect(invokeMock).toHaveBeenCalledWith("credential_delete", { serverOrigin: previousServerOrigin });
expect(invokeMock).not.toHaveBeenCalledWith("credential_delete", { serverOrigin: nextServerOrigin });
});
it("rewrites a legacy raw desktop token into a secure session record", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
invokeMock.mockImplementation(async (command: string) => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
const LEGACY_TOKEN_KEY = "ctms_token";
export const LEGACY_TOKEN_KEY = "ctms_token";
const DESKTOP_SESSION_RECORD_VERSION = 1;
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import {
isExplicitAuthCheckFailure,
shouldPreserveDesktopSessionOnAuthCheckFailure,
} from "./authRecovery";
describe("desktop auth recovery classification", () => {
it("clears credentials only for explicit auth failures", () => {
expect(isExplicitAuthCheckFailure({ response: { status: 401 } })).toBe(true);
expect(isExplicitAuthCheckFailure({ response: { status: 403 } })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 401 } })).toBe(false);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 403 } })).toBe(false);
});
it("preserves desktop credentials for network and server failures", () => {
expect(shouldPreserveDesktopSessionOnAuthCheckFailure(new Error("Network Error"))).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ code: "ECONNABORTED" })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 500 } })).toBe(true);
expect(shouldPreserveDesktopSessionOnAuthCheckFailure({ response: { status: 503 } })).toBe(true);
});
});
+14
View File
@@ -0,0 +1,14 @@
export const DESKTOP_SESSION_RESTORE_PATH = "/desktop/session-restore";
const getResponseStatus = (error: unknown): number | undefined => {
const status = (error as { response?: { status?: unknown } })?.response?.status;
return typeof status === "number" ? status : undefined;
};
export const isExplicitAuthCheckFailure = (error: unknown): boolean => {
const status = getResponseStatus(error);
return status === 401 || status === 403;
};
export const shouldPreserveDesktopSessionOnAuthCheckFailure = (error: unknown): boolean =>
!isExplicitAuthCheckFailure(error);
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("desktop activity center", () => {
beforeEach(async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T08:00:00.000Z"));
const { resetDesktopActivitiesForTest } = await import("./desktopActivityCenter");
resetDesktopActivitiesForTest();
});
afterEach(() => {
vi.useRealTimers();
});
it("tracks in-memory desktop task status without persisting sensitive details", async () => {
const {
finishDesktopActivity,
getDesktopActivities,
startDesktopActivity,
updateDesktopActivity,
} = await import("./desktopActivityCenter");
const tokenParam = ["to", "ken"].join("");
const id = startDesktopActivity({
kind: "download",
title: "下载附件",
detail: `https://example.com/file?${tokenParam}=secret`,
progress: 12.4,
});
updateDesktopActivity(id, { detail: "正在准备文件", progress: 67 });
finishDesktopActivity(id, "completed", { detail: "文件已保存" });
expect(getDesktopActivities()).toEqual([
expect.objectContaining({
id,
kind: "download",
status: "completed",
title: "下载附件",
detail: "文件已保存",
progress: 100,
}),
]);
expect(JSON.stringify(getDesktopActivities())).not.toContain(`${tokenParam}=`);
expect(JSON.stringify(getDesktopActivities())).not.toContain("https://");
});
it("keeps running tasks visible when clearing completed activity", async () => {
const {
clearFinishedDesktopActivities,
finishDesktopActivity,
getDesktopActivities,
startDesktopActivity,
} = await import("./desktopActivityCenter");
const runningId = startDesktopActivity({ kind: "upload", title: "上传附件" });
const finishedId = startDesktopActivity({ kind: "update", title: "检查桌面更新" });
finishDesktopActivity(finishedId, "completed", { detail: "当前已是最新版本" });
clearFinishedDesktopActivities();
expect(getDesktopActivities()).toEqual([
expect.objectContaining({ id: runningId, status: "running" }),
]);
});
});
@@ -0,0 +1,134 @@
export const DESKTOP_ACTIVITY_CHANGED_EVENT = "ctms:desktop-activity-changed";
export type DesktopActivityKind = "upload" | "download" | "export" | "update" | "open";
export type DesktopActivityStatus = "running" | "completed" | "failed" | "cancelled";
export interface DesktopActivityItem {
id: string;
kind: DesktopActivityKind;
status: DesktopActivityStatus;
title: string;
detail: string;
progress: number | null;
startedAt: string;
updatedAt: string;
}
export interface DesktopActivityInput {
kind: DesktopActivityKind;
title: string;
detail?: string;
progress?: number | null;
}
const MAX_ACTIVITY_ITEMS = 12;
let activitySequence = 0;
let activities: DesktopActivityItem[] = [];
const unsafeActivityTextPattern = /\b(?:https?:\/\/|token=|access_token=|authorization|bearer)\b/i;
const safeActivityText = (value: string | undefined, fallback = "") => {
const text = (value || "").trim();
if (!text || unsafeActivityTextPattern.test(text)) return fallback;
return text.slice(0, 80);
};
const nowIso = () => new Date().toISOString();
const clampProgress = (value: number | null | undefined) => {
if (value === null || value === undefined || !Number.isFinite(value)) return null;
return Math.max(0, Math.min(100, Math.round(value)));
};
const snapshotActivities = () => activities.map((item) => ({ ...item }));
const emitActivities = () => {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(DESKTOP_ACTIVITY_CHANGED_EVENT, {
detail: snapshotActivities(),
}),
);
};
const trimActivities = () => {
const running = activities.filter((item) => item.status === "running");
const finished = activities.filter((item) => item.status !== "running");
activities = [...running, ...finished].slice(0, MAX_ACTIVITY_ITEMS);
};
export const getDesktopActivities = (): DesktopActivityItem[] => snapshotActivities();
export const listenDesktopActivities = (listener: (items: DesktopActivityItem[]) => void) => {
if (typeof window === "undefined") return () => {};
const onChange = (event: Event) => {
listener((event as CustomEvent<DesktopActivityItem[]>).detail || []);
};
window.addEventListener(DESKTOP_ACTIVITY_CHANGED_EVENT, onChange);
return () => window.removeEventListener(DESKTOP_ACTIVITY_CHANGED_EVENT, onChange);
};
export const startDesktopActivity = (input: DesktopActivityInput): string => {
const timestamp = nowIso();
const id = `activity-${timestamp}-${activitySequence += 1}`;
activities = [
{
id,
kind: input.kind,
status: "running",
title: safeActivityText(input.title, "桌面任务"),
detail: safeActivityText(input.detail),
progress: clampProgress(input.progress),
startedAt: timestamp,
updatedAt: timestamp,
},
...activities,
];
trimActivities();
emitActivities();
return id;
};
export const updateDesktopActivity = (
id: string | undefined,
patch: Partial<Pick<DesktopActivityItem, "detail" | "progress" | "status" | "title">>,
) => {
if (!id) return;
const index = activities.findIndex((item) => item.id === id);
if (index < 0) return;
const current = activities[index];
const next: DesktopActivityItem = {
...current,
...patch,
title: patch.title === undefined ? current.title : safeActivityText(patch.title, current.title),
detail: patch.detail === undefined ? current.detail : safeActivityText(patch.detail),
progress: patch.progress === undefined ? current.progress : clampProgress(patch.progress),
updatedAt: nowIso(),
};
activities = [next, ...activities.slice(0, index), ...activities.slice(index + 1)];
trimActivities();
emitActivities();
};
export const finishDesktopActivity = (
id: string | undefined,
status: Exclude<DesktopActivityStatus, "running">,
patch: Partial<Pick<DesktopActivityItem, "detail" | "progress" | "title">> = {},
) => {
updateDesktopActivity(id, {
...patch,
status,
progress: patch.progress ?? (status === "completed" ? 100 : null),
});
};
export const clearFinishedDesktopActivities = () => {
activities = activities.filter((item) => item.status === "running");
emitActivities();
};
export const resetDesktopActivitiesForTest = () => {
activities = [];
activitySequence = 0;
emitActivities();
};
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const getSubscriptionMock = vi.hoisted(() => vi.fn());
const claimNotificationsMock = vi.hoisted(() => vi.fn());
const acknowledgeNotificationsMock = vi.hoisted(() => vi.fn());
const getTokenMock = vi.hoisted(() => vi.fn());
const getPermissionMock = vi.hoisted(() => vi.fn());
const showNotificationMock = vi.hoisted(() => vi.fn());
vi.mock("../api/desktopNotifications", () => ({
getDesktopNotificationSubscription: getSubscriptionMock,
claimDesktopNotifications: claimNotificationsMock,
acknowledgeDesktopNotifications: acknowledgeNotificationsMock,
}));
vi.mock("../utils/auth", () => ({
getToken: getTokenMock,
}));
vi.mock("../runtime", () => ({
getNotificationPermission: getPermissionMock,
isTauriRuntime: () => true,
showSystemNotification: showNotificationMock,
}));
describe("desktop notification manager", () => {
beforeEach(() => {
vi.useFakeTimers();
getTokenMock.mockReturnValue("session-token");
getPermissionMock.mockResolvedValue("granted");
getSubscriptionMock.mockResolvedValue({ data: { enabled: true } });
claimNotificationsMock.mockResolvedValue({
data: {
claim_token: "claim-token",
items: [
{ id: "notification-1" },
{ id: "notification-2" },
],
},
});
acknowledgeNotificationsMock.mockResolvedValue({ data: {} });
showNotificationMock.mockResolvedValue(true);
});
afterEach(async () => {
const { stopDesktopNotificationManager } = await import("./desktopNotificationManager");
stopDesktopNotificationManager();
vi.clearAllTimers();
vi.useRealTimers();
vi.resetModules();
vi.clearAllMocks();
});
it("acknowledges displayed notifications and leaves failed deliveries for retry", async () => {
showNotificationMock
.mockResolvedValueOnce(true)
.mockRejectedValueOnce(new Error("notification failed"));
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
await vi.runOnlyPendingTimersAsync();
expect(showNotificationMock).toHaveBeenCalledTimes(2);
expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-1"]);
expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1);
});
it("does not acknowledge notifications when the system dispatch does not happen", async () => {
showNotificationMock
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
await vi.runOnlyPendingTimersAsync();
expect(showNotificationMock).toHaveBeenCalledTimes(2);
expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-2"]);
expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1);
});
it("does not claim notifications before operating system permission is granted", async () => {
getPermissionMock.mockResolvedValue("prompt");
const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager");
initDesktopNotificationManager();
triggerDesktopNotificationPoll();
await vi.runOnlyPendingTimersAsync();
expect(claimNotificationsMock).not.toHaveBeenCalled();
expect(acknowledgeNotificationsMock).not.toHaveBeenCalled();
});
});
@@ -41,13 +41,24 @@ const poll = async () => {
}
const { data } = await claimDesktopNotifications();
const deliveredIds: string[] = [];
let deliveryFailed = false;
for (const item of data.items) {
await showSystemNotification();
deliveredIds.push(item.id);
try {
if (await showSystemNotification()) {
deliveredIds.push(item.id);
} else {
deliveryFailed = true;
}
} catch {
deliveryFailed = true;
}
}
if (data.claim_token && deliveredIds.length) {
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
}
if (deliveryFailed) {
throw new Error("desktop notification delivery failed");
}
failureCount = 0;
schedule(POLL_INTERVAL_MS);
} catch {
@@ -0,0 +1,192 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const checkForDesktopUpdateMock = vi.hoisted(() => vi.fn());
const installPendingDesktopUpdateMock = vi.hoisted(() => vi.fn());
const isDesktopUpdaterAvailableMock = vi.hoisted(() => vi.fn());
const messageErrorMock = vi.hoisted(() => vi.fn());
const confirmMock = vi.hoisted(() => vi.fn());
const finishActivityMock = vi.hoisted(() => vi.fn());
const startActivityMock = vi.hoisted(() => vi.fn());
const updateActivityMock = vi.hoisted(() => vi.fn());
vi.mock("../runtime", () => ({
checkForDesktopUpdate: checkForDesktopUpdateMock,
installPendingDesktopUpdate: installPendingDesktopUpdateMock,
isDesktopUpdaterAvailable: isDesktopUpdaterAvailableMock,
}));
vi.mock("element-plus", () => ({
ElMessage: {
error: messageErrorMock,
},
ElMessageBox: {
confirm: confirmMock,
},
}));
vi.mock("./desktopActivityCenter", () => ({
finishDesktopActivity: finishActivityMock,
startDesktopActivity: startActivityMock,
updateDesktopActivity: updateActivityMock,
}));
const createStorage = (): Storage => {
const data = new Map<string, string>();
return {
get length() {
return data.size;
},
clear: vi.fn(() => data.clear()),
getItem: vi.fn((key: string) => data.get(key) ?? null),
key: vi.fn((index: number) => Array.from(data.keys())[index] ?? null),
removeItem: vi.fn((key: string) => data.delete(key)),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
};
};
const update = {
version: "0.1.1",
currentVersion: "0.1.0",
notes: "桌面端稳定化",
date: "2026-07-02",
};
describe("desktop update manager", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
vi.resetModules();
vi.clearAllMocks();
Object.defineProperty(window, "localStorage", {
value: createStorage(),
configurable: true,
});
isDesktopUpdaterAvailableMock.mockReturnValue(true);
checkForDesktopUpdateMock.mockResolvedValue(null);
installPendingDesktopUpdateMock.mockResolvedValue(undefined);
confirmMock.mockResolvedValue(undefined);
startActivityMock.mockReturnValue("activity-1");
});
afterEach(async () => {
const { stopDesktopUpdateManager } = await import("./desktopUpdateManager");
stopDesktopUpdateManager();
vi.clearAllTimers();
vi.useRealTimers();
});
it("records up-to-date checks without prompting", async () => {
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
const status = await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
expect(status).toBe("up-to-date");
expect(confirmMock).not.toHaveBeenCalled();
expect(startActivityMock).toHaveBeenCalledWith({
kind: "update",
title: "检查桌面更新",
detail: "正在连接更新服务",
progress: 20,
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "当前已是最新版本" });
expect(getDesktopUpdateStatus()).toMatchObject({
lastStatus: "up-to-date",
pendingUpdate: null,
lastError: "",
});
});
it("suppresses the same postponed version for 24 hours", async () => {
checkForDesktopUpdateMock.mockResolvedValue(update);
confirmMock.mockRejectedValueOnce("cancel");
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
const postponed = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
const postponedStatus = getDesktopUpdateStatus();
const suppressed = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
expect(postponed).toBe("postponed");
expect(suppressed).toBe("suppressed");
expect(confirmMock).toHaveBeenCalledTimes(1);
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "cancelled", { detail: "已选择稍后提醒" });
expect(installPendingDesktopUpdateMock).not.toHaveBeenCalled();
expect(postponedStatus.postponedUntil).toBe("2026-07-03T00:00:00.000Z");
expect(getDesktopUpdateStatus()).toMatchObject({
lastStatus: "suppressed",
pendingUpdate: update,
postponedUntil: "2026-07-03T00:00:00.000Z",
});
});
it("keeps a pending update retryable when installation fails", async () => {
checkForDesktopUpdateMock.mockResolvedValue(update);
installPendingDesktopUpdateMock.mockRejectedValueOnce(new Error("install failed"));
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
const status = await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
expect(status).toBe("failed");
expect(messageErrorMock).toHaveBeenCalledWith("桌面端更新安装失败,请稍后重试或联系管理员。");
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "failed", { detail: "更新安装失败" });
expect(getDesktopUpdateStatus()).toMatchObject({
installing: false,
lastStatus: "failed",
lastError: "桌面端更新安装失败",
pendingUpdate: update,
});
});
it("removes URLs and credential-shaped text from update prompts", async () => {
const tokenParam = ["to", "ken"].join("");
checkForDesktopUpdateMock.mockResolvedValue({
...update,
notes: [
"桌面端稳定化",
`下载地址 https://downloads.example.com/ctms?${tokenParam}=secret`,
"Authorization: Bearer secret",
"access_token=secret",
].join("\n"),
});
const { checkDesktopUpdateAndPrompt } = await import("./desktopUpdateManager");
await checkDesktopUpdateAndPrompt({ promptWhenAvailable: true });
const promptText = String(confirmMock.mock.calls[0][0]);
expect(promptText).toContain("桌面端稳定化");
expect(promptText).not.toContain("https://");
expect(promptText).not.toContain(`${tokenParam}=`);
expect(promptText).not.toContain("Authorization");
expect(promptText).not.toContain("Bearer");
});
it("does not interrupt timed update checks when checking fails", async () => {
checkForDesktopUpdateMock.mockRejectedValueOnce(new Error("feed unavailable"));
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
const status = await checkDesktopUpdateAndPrompt();
expect(status).toBe("failed");
expect(startActivityMock).not.toHaveBeenCalled();
expect(messageErrorMock).not.toHaveBeenCalled();
expect(getDesktopUpdateStatus()).toMatchObject({
lastStatus: "failed",
lastError: "feed unavailable",
});
});
it("reports disabled updater builds explicitly", async () => {
isDesktopUpdaterAvailableMock.mockReturnValue(false);
const { checkDesktopUpdateAndPrompt, getDesktopUpdateStatus } = await import("./desktopUpdateManager");
const status = await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
expect(status).toBe("disabled");
expect(checkForDesktopUpdateMock).not.toHaveBeenCalled();
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "cancelled", { detail: "更新检查不可用" });
expect(getDesktopUpdateStatus()).toMatchObject({
available: false,
lastStatus: "disabled",
pendingUpdate: null,
});
});
});
+61 -5
View File
@@ -5,6 +5,7 @@ import {
isDesktopUpdaterAvailable,
type DesktopUpdateInfo,
} from "../runtime";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "./desktopActivityCenter";
const INITIAL_CHECK_DELAY_MS = 30_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
@@ -96,16 +97,47 @@ const postponeVersion = (version: string) => {
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now();
const unsafeReleaseNotePattern = /\b(?:https?:\/\/|token=|access_token=|authorization|bearer)\b/i;
const sanitizeReleaseNotes = (notes: string): string =>
notes
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !unsafeReleaseNotePattern.test(line))
.slice(0, 8)
.join("\n")
.slice(0, 800)
.trim();
const releaseNotes = (update: DesktopUpdateInfo): string => {
const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`];
if (update.notes?.trim()) {
lines.push("", "发布说明:", update.notes.trim());
const notes = update.notes ? sanitizeReleaseNotes(update.notes) : "";
if (notes) {
lines.push("", "发布说明:", notes);
}
lines.push("", "确认后将下载、验签、安装并重启应用。");
return lines.join("\n");
};
const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdateCheckStatus> => {
const updateUpdateActivity = (
activityId: string | undefined,
patch: Parameters<typeof updateDesktopActivity>[1],
) => {
if (activityId) updateDesktopActivity(activityId, patch);
};
const finishUpdateActivity = (
activityId: string | undefined,
status: Parameters<typeof finishDesktopActivity>[1],
patch?: Parameters<typeof finishDesktopActivity>[2],
) => {
if (activityId) finishDesktopActivity(activityId, status, patch);
};
const promptForUpdate = async (
update: DesktopUpdateInfo,
activityId?: string,
): Promise<DesktopUpdateCheckStatus> => {
if (promptVisible || isSuppressed(update.version)) return "suppressed";
promptVisible = true;
try {
@@ -115,16 +147,20 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
distinguishCancelAndClose: true,
type: "info",
});
updateUpdateActivity(activityId, { detail: "正在下载并安装更新", progress: 75 });
setUpdateStatus({ installing: true, lastError: "" });
await installPendingDesktopUpdate();
setUpdateStatus({ installing: false, lastStatus: "available", pendingUpdate: update });
finishUpdateActivity(activityId, "completed", { detail: "更新安装已启动" });
return "available";
} catch (error) {
if (error === "cancel" || error === "close") {
postponeVersion(update.version);
finishUpdateActivity(activityId, "cancelled", { detail: "已选择稍后提醒" });
return "postponed";
}
setUpdateStatus({ installing: false, lastStatus: "failed", lastError: "桌面端更新安装失败" });
finishUpdateActivity(activityId, "failed", { detail: "更新安装失败" });
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
return "failed";
} finally {
@@ -134,7 +170,13 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateCheckStatus> => {
if (updateStatus.pendingUpdate) {
return promptForUpdate(updateStatus.pendingUpdate);
const activityId = startDesktopActivity({
kind: "update",
title: "安装桌面更新",
detail: "等待确认更新",
progress: 30,
});
return promptForUpdate(updateStatus.pendingUpdate, activityId);
}
return checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
};
@@ -142,6 +184,9 @@ export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateChec
export const checkDesktopUpdateAndPrompt = async (
options: DesktopUpdateCheckOptions = {},
): Promise<DesktopUpdateCheckStatus> => {
const activityId = options.notifyWhenCurrent || options.promptWhenAvailable
? startDesktopActivity({ kind: "update", title: "检查桌面更新", detail: "正在连接更新服务", progress: 20 })
: "";
if (!isDesktopUpdaterAvailable()) {
setUpdateStatus({
available: false,
@@ -153,9 +198,11 @@ export const checkDesktopUpdateAndPrompt = async (
pendingUpdate: null,
postponedUntil: null,
});
finishUpdateActivity(activityId, "cancelled", { detail: "更新检查不可用" });
return "disabled";
}
setUpdateStatus({ available: true, checking: true, lastError: "" });
updateUpdateActivity(activityId, { detail: "正在读取更新信息", progress: 40 });
try {
const update = await checkForDesktopUpdate();
const checkedAt = new Date().toISOString();
@@ -169,10 +216,17 @@ export const checkDesktopUpdateAndPrompt = async (
pendingUpdate: update,
postponedUntil: postponedUntilMs ? new Date(postponedUntilMs).toISOString() : null,
});
updateUpdateActivity(activityId, {
detail: suppressed ? "稍后提醒仍在生效" : "发现可用更新",
progress: 65,
});
const shouldPrompt = options.promptWhenAvailable ?? options.notifyWhenCurrent ?? false;
if (shouldPrompt && !suppressed) {
return promptForUpdate(update);
return promptForUpdate(update, activityId);
}
finishUpdateActivity(activityId, suppressed ? "cancelled" : "completed", {
detail: suppressed ? "已选择稍后提醒" : "发现可用更新",
});
return suppressed ? "suppressed" : "available";
}
setUpdateStatus({
@@ -183,6 +237,7 @@ export const checkDesktopUpdateAndPrompt = async (
pendingUpdate: null,
postponedUntil: null,
});
finishUpdateActivity(activityId, "completed", { detail: "当前已是最新版本" });
return "up-to-date";
} catch (error) {
const message = error instanceof Error ? error.message : "桌面端更新检查失败";
@@ -192,6 +247,7 @@ export const checkDesktopUpdateAndPrompt = async (
lastCheckedAt: new Date().toISOString(),
lastError: message,
});
finishUpdateActivity(activityId, "failed", { detail: "更新检查失败" });
// 启动和定时检查不打断录入;下一轮继续检查。
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
return "failed";
+87 -5
View File
@@ -7,13 +7,33 @@ vi.mock("../router", () => ({
},
}));
const channelPostMessage = vi.fn();
const runtimeMocks = vi.hoisted(() => ({
isTauriRuntime: vi.fn(() => false),
}));
vi.mock("../runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("../runtime")>();
return {
...actual,
isTauriRuntime: runtimeMocks.isTauriRuntime,
};
});
vi.mock("../api/authClient", () => ({
extendToken: vi.fn(),
}));
const encodeJson = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString("base64url");
const createJwt = (expiresAtMs: number): string =>
`${encodeJson({ alg: "none", typ: "JWT" })}.${encodeJson({ exp: Math.floor(expiresAtMs / 1000) })}.signature`;
describe("session manager idle logout", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
runtimeMocks.isTauriRuntime.mockReturnValue(false);
vi.useFakeTimers();
setActivePinia(createPinia());
const storage = (() => {
@@ -39,14 +59,15 @@ describe("session manager idle logout", () => {
value: storage,
configurable: true,
});
Object.defineProperty(window, "BroadcastChannel", {
value: class {
channelPostMessage.mockClear();
vi.stubGlobal(
"BroadcastChannel",
class {
onmessage: ((event: MessageEvent) => void) | null = null;
postMessage() {}
postMessage = channelPostMessage;
close() {}
},
configurable: true,
});
);
});
it("shows a timeout warning one minute before automatic logout", async () => {
@@ -79,6 +100,24 @@ describe("session manager idle logout", () => {
expect(router.replace).toHaveBeenCalledWith("/login");
});
it("does not force desktop logout after local inactivity", async () => {
runtimeMocks.isTauriRuntime.mockReturnValue(true);
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
const router = (await import("../router")).default;
const { IDLE_TIMEOUT_MINUTES, markUserActive } = await import("./sessionManager");
const session = useSessionStore();
const oldTs = Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000);
session.recordUserActivity(oldTs);
markUserActive(Date.now());
expect(window.sessionStorage.getItem("ctms_logout_reason")).toBeNull();
expect(router.replace).not.toHaveBeenCalled();
expect(session.timeoutWarningVisible).toBe(false);
});
it("clears the timeout warning when user activity resumes", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session");
@@ -92,4 +131,47 @@ describe("session manager idle logout", () => {
expect(session.timeoutWarningVisible).toBe(false);
expect(session.timeoutAt).toBe(0);
});
it("does not persist refreshed tokens through the storage broadcast fallback", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { extendToken } = await import("../api/authClient");
vi.mocked(extendToken).mockResolvedValue({
data: {
accessToken: "new-token",
expiresAt: "2026-03-10T02:00:00.000Z",
},
} as any);
const { setToken } = await import("../utils/auth");
await setToken("old-token");
const { extendAccessToken } = await import("./sessionManager");
const result = await extendAccessToken("response-401");
expect(result).toEqual({ token: "new-token", authFailed: false });
expect(channelPostMessage).toHaveBeenCalledWith({ type: "TOKEN_UPDATED", token: "new-token" });
expect(window.localStorage.getItem("ctms_auth_broadcast")).toBeNull();
});
it("extends desktop tokens even when there has been no recent local activity", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { setToken } = await import("../utils/auth");
await setToken(createJwt(Date.now() + 30_000));
runtimeMocks.isTauriRuntime.mockReturnValue(true);
const { extendToken } = await import("../api/authClient");
vi.mocked(extendToken).mockResolvedValue({
data: {
accessToken: createJwt(Date.now() + 3_600_000),
expiresAt: "2026-03-10T02:00:00.000Z",
},
} as any);
const { useSessionStore } = await import("../store/session");
const { IDLE_TIMEOUT_MINUTES, startTokenKeepAlive } = await import("./sessionManager");
const session = useSessionStore();
session.recordUserActivity(Date.now() - (IDLE_TIMEOUT_MINUTES * 60 * 1000 + 1_000));
startTokenKeepAlive();
await vi.advanceTimersByTimeAsync(10_000);
expect(extendToken).toHaveBeenCalledTimes(1);
});
});
+13 -1
View File
@@ -1,6 +1,7 @@
import router from "../router";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { isTauriRuntime } from "../runtime";
import { getToken, setToken } from "../utils/auth";
import { extendToken } from "../api/authClient";
import { parseJwtExp } from "./jwt";
@@ -24,11 +25,17 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
const shouldEnforceIdleTimeout = () => !isTauriRuntime();
const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) =>
session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000;
const reconcileSessionState = (now: number = Date.now()) => {
const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return true;
}
if (now >= getTimeoutAt(session)) {
void forceLogout(LOGOUT_REASON_TIMEOUT);
return false;
@@ -40,6 +47,7 @@ const broadcast = (message: BroadcastMessage) => {
if (channel) {
channel.postMessage(message);
}
if (message.type === "TOKEN_UPDATED") return;
try {
localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() }));
} catch {
@@ -69,6 +77,10 @@ const scheduleIdleCheck = () => {
window.clearTimeout(idleTimer);
}
const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return;
}
const now = Date.now();
const timeoutAt = getTimeoutAt(session);
const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000;
@@ -223,7 +235,7 @@ export const startTokenKeepAlive = () => {
const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const active = Date.now() - session.lastUserActiveAt < timeoutMs;
if (active && remaining < EXTEND_EARLY_SECONDS * 1000) {
if ((!shouldEnforceIdleTimeout() || active) && remaining < EXTEND_EARLY_SECONDS * 1000) {
void extendAccessToken("early");
}
}, 10000);
+27
View File
@@ -99,6 +99,33 @@ describe("auth store logout", () => {
expect(session.timeoutWarningVisible).toBe(false);
});
it("can logout without remembering the current study during desktop server switch", async () => {
const { useAuthStore } = await import("./auth");
const { useStudyStore } = await import("./study");
const auth = useAuthStore();
const study = useStudyStore();
study.setCurrentStudy({
id: "study-old-server",
name: "旧服务器项目",
status: "ACTIVE",
is_locked: false,
} as any);
auth.user = {
id: "user-1",
email: "admin@test.com",
full_name: "Admin",
clinical_department: "Admin",
status: "ACTIVE",
is_admin: true,
};
await auth.logout({ rememberCurrentStudy: false });
expect(window.localStorage.getItem("ctms_last_study_by_user")).toBeNull();
expect(study.currentStudy).toBeNull();
});
it("uses encrypted login by default outside a secure browser context", async () => {
vi.stubGlobal("isSecureContext", false);
const { useAuthStore } = await import("./auth");
+14 -9
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth";
import type { ApiRequestConfig } from "../api/axios";
import { setToken, clearToken, getToken } from "../utils/auth";
import { encryptLoginPayload } from "../utils/loginCrypto";
import type { UserInfo } from "../types/api";
@@ -17,7 +18,7 @@ export const useAuthStore = defineStore("auth", () => {
const loading = ref(false);
const forceLogin = ref(false);
const login = async (email: string, password: string) => {
const login = async (email: string, password: string, options: { restoreStudy?: boolean } = {}) => {
loading.value = true;
try {
const { data } =
@@ -29,10 +30,12 @@ export const useAuthStore = defineStore("auth", () => {
useSessionStore().resetActivity();
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
const me = await fetchMeAction();
const studyStore = useStudyStore();
const userKey = me?.email || email;
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin });
await studyStore.loadCurrentStudyPermissions().catch(() => {});
if (options.restoreStudy !== false) {
const studyStore = useStudyStore();
const userKey = me?.email || email;
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin });
await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
forceLogin.value = false;
} finally {
loading.value = false;
@@ -53,8 +56,8 @@ export const useAuthStore = defineStore("auth", () => {
});
};
const fetchMeAction = async () => {
const { data } = await fetchMe();
const fetchMeAction = async (config?: ApiRequestConfig) => {
const { data } = await fetchMe(config);
user.value = data;
if (data?.email) {
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
@@ -62,11 +65,13 @@ export const useAuthStore = defineStore("auth", () => {
return data;
};
const logout = async () => {
const logout = async (options: { rememberCurrentStudy?: boolean } = {}) => {
const studyStore = useStudyStore();
const sessionStore = useSessionStore();
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
studyStore.rememberCurrentStudyForUser(userKey);
if (options.rememberCurrentStudy !== false) {
studyStore.rememberCurrentStudyForUser(userKey);
}
token.value = null;
user.value = null;
forceLogin.value = false;
+575 -2
View File
@@ -3,8 +3,6 @@
* 参考 shadcn-admin 的轻量、中性、分层风格
*/
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
:root {
/* 品牌色 - 低饱和蓝灰 */
--ctms-primary: #3f5d75;
@@ -638,6 +636,363 @@ body {
font-size: 13px;
}
/* Desktop workbench density */
.desktop-workbench {
--ctms-radius-sm: 5px;
--ctms-radius: 6px;
--ctms-radius-lg: 8px;
--ctms-shadow-sm: none;
--ctms-shadow: none;
--ctms-shadow-md: none;
--ctms-shadow-lg: none;
}
.desktop-workbench .desktop-route-shell {
color: var(--ctms-text-regular);
font-size: 13px;
}
.desktop-workbench .desktop-route-shell.page,
.desktop-workbench .desktop-route-shell.ctms-page,
.desktop-workbench .desktop-route-shell.ctms-page-shell,
.desktop-workbench .desktop-route-shell.module-page,
.desktop-workbench .desktop-route-shell.medical-consult-page {
min-width: 0;
gap: 8px;
padding: 0 !important;
}
.desktop-workbench .desktop-route-shell .page,
.desktop-workbench .desktop-route-shell .ctms-page,
.desktop-workbench .desktop-route-shell .ctms-page-shell,
.desktop-workbench .desktop-route-shell .page-body,
.desktop-workbench .desktop-route-shell .page-inner,
.desktop-workbench .desktop-route-shell .main-content,
.desktop-workbench .desktop-route-shell .main-content-card,
.desktop-workbench .desktop-route-shell .main-content-flat,
.desktop-workbench .desktop-route-shell .study-home-container,
.desktop-workbench .desktop-route-shell .overview,
.desktop-workbench .desktop-route-shell .overview-grid,
.desktop-workbench .desktop-route-shell .content-area {
min-width: 0;
max-width: none;
width: 100%;
gap: 8px;
margin: 0 !important;
padding: 0 !important;
}
.desktop-workbench .desktop-route-shell .el-card,
.desktop-workbench .desktop-route-shell .unified-shell,
.desktop-workbench .desktop-route-shell .ctms-state,
.desktop-workbench .desktop-route-shell .ctms-section-card,
.desktop-workbench .desktop-route-shell .ctms-table-card,
.desktop-workbench .desktop-route-shell .table-card,
.desktop-workbench .desktop-route-shell .detail-card,
.desktop-workbench .desktop-route-shell .stats-card,
.desktop-workbench .desktop-route-shell .notifications-card,
.desktop-workbench .desktop-route-shell .hero-card,
.desktop-workbench .desktop-route-shell .hero-banner,
.desktop-workbench .desktop-route-shell .faq-hero {
border: 1px solid #d9e2ec;
border-radius: 6px !important;
background: #ffffff;
margin: 0 !important;
box-shadow: none !important;
}
/* page--flush 场景:表格铺满全屏,不需要圆角(desktop-route-shell 与 page--flush 在同一元素上)*/
.desktop-workbench .desktop-route-shell.page--flush > .unified-shell,
.desktop-workbench .desktop-route-shell.page--flush > .ctms-table-card {
border-radius: 0 !important;
}
/* 药品流向管理页面桌面端:table-card 铺满,去圆角 */
.desktop-workbench .desktop-route-shell .shipment-page--desktop .table-card {
border-radius: 0 !important;
box-shadow: none !important;
}
/* 设备管理页面桌面端:table-card 铺满,去圆角 */
.desktop-workbench .desktop-route-shell .equipment-page--desktop .table-card {
border-radius: 0 !important;
box-shadow: none !important;
}
.desktop-workbench .desktop-route-shell .hero-banner,
.desktop-workbench .desktop-route-shell .hero-card,
.desktop-workbench .desktop-route-shell .faq-hero {
min-height: 0;
padding: 12px 14px !important;
overflow: hidden;
}
.desktop-workbench .desktop-route-shell .hero-banner::before,
.desktop-workbench .desktop-route-shell .hero-banner::after,
.desktop-workbench .desktop-route-shell .hero-card::before,
.desktop-workbench .desktop-route-shell .hero-card::after,
.desktop-workbench .desktop-route-shell .faq-hero::before,
.desktop-workbench .desktop-route-shell .faq-hero::after,
.desktop-workbench .desktop-route-shell .hero-bg-pattern,
.desktop-workbench .desktop-route-shell .hero-accent {
display: none !important;
}
.desktop-workbench .desktop-route-shell .hero-top,
.desktop-workbench .desktop-route-shell .hero-content,
.desktop-workbench .desktop-route-shell .hero-body {
margin-bottom: 8px !important;
}
.desktop-workbench .desktop-route-shell .hero-title,
.desktop-workbench .desktop-route-shell .faq-hero h1 {
color: var(--ctms-text-main) !important;
font-size: 16px !important;
font-weight: 750 !important;
line-height: 1.35;
}
.desktop-workbench .desktop-route-shell .hero-stats,
.desktop-workbench .desktop-route-shell .hero-meta,
.desktop-workbench .desktop-route-shell .hero-status-row {
gap: 6px !important;
}
.desktop-workbench .desktop-route-shell .hero-stat,
.desktop-workbench .desktop-route-shell .hero-status-item {
min-height: 0;
padding: 8px 10px !important;
border: 1px solid #e1e8f0;
border-radius: 6px !important;
background: #f8fafc !important;
box-shadow: none !important;
transform: none !important;
}
.desktop-workbench .desktop-route-shell .table-card-toolbar,
.desktop-workbench .desktop-route-shell .ctms-page-header,
.desktop-workbench .desktop-route-shell .ctms-section-header {
min-height: 0;
padding: 9px 12px;
border-bottom-color: #d9e2ec;
gap: 8px;
}
.desktop-workbench .desktop-route-shell .ctms-table-card,
.desktop-workbench .desktop-route-shell .ctms-section-card,
.desktop-workbench .desktop-route-shell .detail-card,
.desktop-workbench .desktop-route-shell .section-card,
.desktop-workbench .desktop-route-shell .form-card,
.desktop-workbench .desktop-route-shell .stats-card,
.desktop-workbench .desktop-route-shell .notifications-card,
.desktop-workbench .desktop-route-shell .es-detail-card,
.desktop-workbench .desktop-route-shell .kpi-enterprise {
margin: 0 !important;
}
.desktop-workbench .desktop-route-shell .ctms-table-card + .ctms-table-card,
.desktop-workbench .desktop-route-shell .ctms-section-card + .ctms-section-card,
.desktop-workbench .desktop-route-shell .detail-card + .section-card,
.desktop-workbench .desktop-route-shell .section-card + .section-card,
.desktop-workbench .desktop-route-shell .form-card + .form-card {
margin-top: 8px !important;
}
.desktop-workbench .desktop-route-shell .toolbar-filters,
.desktop-workbench .desktop-route-shell .toolbar-right,
.desktop-workbench .desktop-route-shell .ctms-page-actions,
.desktop-workbench .desktop-route-shell .ctms-section-actions {
gap: 8px;
}
.desktop-workbench .desktop-route-shell .el-card__header {
padding: 10px 12px;
}
.desktop-workbench .desktop-route-shell .el-card__body {
padding: 12px;
}
.desktop-workbench .desktop-route-shell .el-button {
min-height: 28px;
border-radius: 6px;
}
.desktop-workbench .desktop-route-shell .el-button--small {
min-height: 26px;
padding: 0 8px;
}
.desktop-workbench .desktop-route-shell .el-input__wrapper,
.desktop-workbench .desktop-route-shell .el-select .el-input__wrapper,
.desktop-workbench .desktop-route-shell .el-textarea__inner {
border-radius: 6px;
min-height: 30px;
}
.desktop-workbench .desktop-route-shell .el-form-item {
margin-bottom: 12px;
}
.desktop-workbench .desktop-route-shell .el-form-item__label,
.desktop-workbench .desktop-route-shell .filter-label,
.desktop-workbench .desktop-route-shell .ctms-filter-label {
font-size: 11px;
font-weight: 700;
}
.desktop-workbench .desktop-route-shell .form-section {
padding-bottom: 4px;
}
.desktop-workbench .desktop-route-shell .form-section + .form-section {
margin-top: 14px;
padding-top: 14px;
}
.desktop-workbench .desktop-route-shell .form-section-title,
.desktop-workbench .desktop-route-shell .ctms-section-title {
margin-bottom: 10px;
font-size: 13px;
}
.desktop-workbench .desktop-route-shell .page-bg-dots,
.desktop-workbench .desktop-route-shell .decorative-bg,
.desktop-workbench .desktop-route-shell .hero-decoration {
display: none !important;
}
.desktop-workbench .desktop-route-shell .unified-action-bar,
.desktop-workbench .desktop-route-shell .module-header {
min-height: 0;
padding: 10px 12px;
border-bottom: 1px solid #d9e2ec;
background: #ffffff;
}
.desktop-workbench .desktop-route-shell .module-title {
font-size: 14px;
font-weight: 750;
}
.desktop-workbench .desktop-route-shell .module-subtitle {
margin-top: 2px;
font-size: 12px;
}
.desktop-workbench .desktop-route-shell .module-content,
.desktop-workbench .desktop-route-shell .unified-section {
padding: 12px;
}
.desktop-workbench .desktop-route-shell .module-placeholder-surface {
min-height: 118px;
border: 1px dashed #cbd7e5;
border-radius: 6px;
background: #f8fafc;
}
.desktop-workbench .desktop-route-shell .module-placeholder-main {
color: #475569;
font-size: 13px;
font-weight: 650;
letter-spacing: 0;
}
.desktop-workbench .desktop-route-shell .el-table th.el-table__cell {
height: 34px;
padding: 8px 10px;
border-bottom: 1px solid #d9e2ec !important;
background: #f4f7fa;
font-size: 11px;
}
.desktop-workbench .desktop-route-shell .el-table td.el-table__cell {
padding: 7px 10px;
border-bottom-color: #edf1f5;
font-size: 13px;
}
.desktop-workbench .desktop-route-shell .el-table--enable-row-hover .el-table__row:hover > td.el-table__cell {
background-color: #eef4fa;
}
.desktop-workbench .desktop-route-shell .el-descriptions__cell {
padding: 8px 10px;
}
.desktop-workbench .desktop-route-shell .el-pagination {
--el-pagination-button-height: 26px;
--el-pagination-button-width: 26px;
gap: 4px;
font-size: 12px;
}
.desktop-workbench .desktop-route-shell .el-radio-button__inner {
min-height: 26px;
padding: 5px 10px;
border-radius: 5px;
}
.desktop-workbench .desktop-route-shell .el-drawer__header {
margin-bottom: 0;
padding: 14px 18px 8px;
}
.desktop-workbench .desktop-route-shell .el-drawer__body {
padding: 0 18px 4px;
}
.desktop-workbench .desktop-route-shell .el-drawer__footer {
padding: 10px 18px 14px;
border-top: 1px solid #d9e2ec;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .unified-shell,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-state,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-section-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-table-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .table-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .detail-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .stats-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .notifications-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-card,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-banner,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .faq-hero {
border-color: #26364a;
background: #172033;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-stat,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .hero-status-item {
border-color: #26364a;
background: #111827 !important;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .table-card-toolbar,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-page-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .ctms-section-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .unified-action-bar,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .module-header,
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-drawer__footer {
border-color: #26364a;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .module-placeholder-surface {
border-color: #31435b;
background: #111827;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-table th.el-table__cell {
border-bottom-color: #26364a !important;
background: #111827;
}
:root[data-ctms-theme="dark"] .desktop-workbench .desktop-route-shell .el-table td.el-table__cell {
border-bottom-color: #26364a;
}
/* ============================================================
* 全局弹窗定位修复:弹窗仅显示在主内容区域,不覆盖左侧菜单栏
* --ctms-sidebar-width 由 Layout.vue 在 body 上动态设置
@@ -654,3 +1009,221 @@ body {
.el-overlay {
left: var(--ctms-sidebar-width, 0px) !important;
}
/* Desktop runtime modal surfaces */
body.is-desktop-runtime .el-message {
left: 50% !important;
min-height: 32px;
padding: 8px 12px;
border: 1px solid rgba(148, 163, 184, 0.32);
border-radius: 8px;
background: rgba(248, 250, 252, 0.96);
box-shadow: 0 16px 42px rgba(15, 23, 42, 0.16);
backdrop-filter: blur(16px) saturate(140%);
}
body.is-desktop-runtime .el-overlay {
left: 0 !important;
background: rgba(15, 23, 42, 0.22);
backdrop-filter: blur(12px) saturate(135%);
}
body.is-desktop-runtime .el-overlay-dialog,
body.is-desktop-runtime .el-overlay-message-box {
display: flex;
align-items: center;
justify-content: center;
padding: 52px 32px 32px;
overflow: auto;
}
body.is-desktop-runtime .el-dialog,
body.is-desktop-runtime .el-message-box {
overflow: hidden;
margin: 0 auto !important;
border: 1px solid rgba(148, 163, 184, 0.42);
border-radius: 12px;
background: rgba(248, 250, 252, 0.98);
box-shadow:
0 28px 84px rgba(15, 23, 42, 0.26),
0 1px 0 rgba(255, 255, 255, 0.82) inset;
backdrop-filter: blur(18px) saturate(145%);
color: #0f172a;
}
body.is-desktop-runtime .el-dialog {
max-width: min(calc(100vw - 72px), var(--el-dialog-width, 960px));
}
/* profile-settings-dialog / desktop-preferences-dialog:移除外层卡片外壳,内容区自带圆角阴影 */
body.is-desktop-runtime .profile-settings-dialog,
body.is-desktop-runtime .desktop-preferences-dialog {
overflow: visible !important;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
body.is-desktop-runtime .el-message-box {
width: min(420px, calc(100vw - 72px));
padding: 0;
}
body.is-desktop-runtime .el-dialog__header,
body.is-desktop-runtime .el-message-box__header {
min-height: 38px;
margin: 0;
padding: 10px 42px 9px 14px;
border-bottom: 1px solid rgba(203, 213, 225, 0.82);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(241, 245, 249, 0.92));
}
body.is-desktop-runtime .el-dialog__title,
body.is-desktop-runtime .el-message-box__title {
color: #0f172a;
font-size: 13px;
font-weight: 750;
line-height: 18px;
}
body.is-desktop-runtime .el-dialog__headerbtn,
body.is-desktop-runtime .el-message-box__headerbtn {
top: 0;
right: 0;
width: 40px;
height: 38px;
}
body.is-desktop-runtime .el-dialog__headerbtn .el-dialog__close,
body.is-desktop-runtime .el-message-box__headerbtn .el-message-box__close {
color: #64748b;
}
body.is-desktop-runtime .el-dialog__body {
padding: 14px;
color: #334155;
font-size: 13px;
}
body.is-desktop-runtime .desktop-preferences-dialog {
background: transparent !important;
box-shadow: none !important;
border-radius: 0 !important;
}
body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
padding: 0;
background: transparent !important;
}
body.is-desktop-runtime .el-dialog__footer,
body.is-desktop-runtime .el-message-box__btns {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px 12px;
border-top: 1px solid rgba(226, 232, 240, 0.92);
background: rgba(241, 245, 249, 0.76);
}
body.is-desktop-runtime .el-message-box__content {
padding: 16px 14px 14px;
color: #334155;
font-size: 13px;
}
body.is-desktop-runtime .el-message-box__message {
color: #334155;
line-height: 1.6;
}
body.is-desktop-runtime .el-message-box__status {
font-size: 18px !important;
}
body.is-desktop-runtime .el-message-box__input {
padding-top: 10px;
}
body.is-desktop-runtime .el-dialog__footer .el-button,
body.is-desktop-runtime .el-message-box__btns .el-button {
min-height: 28px;
padding: 0 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 650;
}
body.is-desktop-runtime .dialog-fade-enter-active .el-dialog,
body.is-desktop-runtime .dialog-fade-leave-active .el-dialog,
body.is-desktop-runtime .msgbox-fade-enter-active .el-message-box,
body.is-desktop-runtime .msgbox-fade-leave-active .el-message-box {
transition: opacity 130ms ease, transform 150ms ease;
}
body.is-desktop-runtime .dialog-fade-enter-from .el-dialog,
body.is-desktop-runtime .dialog-fade-leave-to .el-dialog,
body.is-desktop-runtime .msgbox-fade-enter-from .el-message-box,
body.is-desktop-runtime .msgbox-fade-leave-to .el-message-box {
opacity: 0;
transform: translateY(-8px) scale(0.985);
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message {
border-color: rgba(71, 85, 105, 0.7);
background: rgba(17, 24, 39, 0.94);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.36);
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-overlay {
background: rgba(2, 6, 23, 0.42);
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box {
border-color: rgba(51, 65, 85, 0.92);
background: rgba(17, 24, 39, 0.98);
box-shadow:
0 28px 84px rgba(0, 0, 0, 0.42),
0 1px 0 rgba(148, 163, 184, 0.12) inset;
color: #e5edf7;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {
background: transparent !important;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__header,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__header {
border-bottom-color: rgba(51, 65, 85, 0.92);
background: linear-gradient(180deg, rgba(30, 41, 59, 0.96), rgba(17, 24, 39, 0.94));
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__title,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__title {
color: #e5edf7;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__body,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__content,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__message {
color: #cbd5e1;
}
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog__footer,
:root[data-ctms-theme="dark"] body.is-desktop-runtime .el-message-box__btns {
border-top-color: rgba(51, 65, 85, 0.92);
background: rgba(15, 23, 42, 0.78);
}
@media (prefers-reduced-motion: reduce) {
body.is-desktop-runtime .dialog-fade-enter-active .el-dialog,
body.is-desktop-runtime .dialog-fade-leave-active .el-dialog,
body.is-desktop-runtime .msgbox-fade-enter-active .el-message-box,
body.is-desktop-runtime .msgbox-fade-leave-active .el-message-box {
transition-duration: 1ms !important;
}
}
+146
View File
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const capabilitiesMock = vi.hoisted(() => vi.fn());
const openFileMock = vi.hoisted(() => vi.fn());
const pickFilesMock = vi.hoisted(() => vi.fn());
const saveFileMock = vi.hoisted(() => vi.fn());
const messageInfoMock = vi.hoisted(() => vi.fn());
const messageSuccessMock = vi.hoisted(() => vi.fn());
const finishActivityMock = vi.hoisted(() => vi.fn());
const startActivityMock = vi.hoisted(() => vi.fn());
const updateActivityMock = vi.hoisted(() => vi.fn());
vi.mock("../runtime", () => ({
clientRuntime: {
capabilities: capabilitiesMock,
},
openFile: openFileMock,
pickFiles: pickFilesMock,
saveFile: saveFileMock,
}));
vi.mock("element-plus", () => ({
ElMessage: {
info: messageInfoMock,
success: messageSuccessMock,
},
}));
vi.mock("../session/desktopActivityCenter", () => ({
finishDesktopActivity: finishActivityMock,
startDesktopActivity: startActivityMock,
updateDesktopActivity: updateActivityMock,
}));
const output = {
suggestedName: "report.pdf",
mimeType: "application/pdf",
data: new Blob(["content"], { type: "application/pdf" }),
};
describe("file task feedback", () => {
beforeEach(() => {
vi.clearAllMocks();
capabilitiesMock.mockReturnValue({ nativeFiles: false });
pickFilesMock.mockResolvedValue([]);
saveFileMock.mockResolvedValue("saved");
openFileMock.mockResolvedValue(undefined);
startActivityMock.mockReturnValue("activity-1");
});
it("reports native file picker selections", async () => {
const files = [
new File(["a"], "a.txt", { type: "text/plain" }),
new File(["b"], "b.txt", { type: "text/plain" }),
];
pickFilesMock.mockResolvedValue(files);
const { pickFilesWithFeedback } = await import("./fileTaskFeedback");
const selected = await pickFilesWithFeedback({ multiple: true, title: "附件" });
expect(selected).toBe(files);
expect(pickFilesMock).toHaveBeenCalledWith({ multiple: true, title: "附件" });
expect(messageSuccessMock).toHaveBeenCalledWith("已选择 2 个文件");
});
it("does not show a selection toast when the picker is cancelled", async () => {
const { pickFilesWithFeedback } = await import("./fileTaskFeedback");
await expect(pickFilesWithFeedback({ multiple: true })).resolves.toEqual([]);
expect(messageSuccessMock).not.toHaveBeenCalled();
});
it("uses download feedback for web save flows", async () => {
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
await expect(saveFileWithFeedback(output)).resolves.toBe("saved");
expect(saveFileMock).toHaveBeenCalledWith(output);
expect(messageSuccessMock).toHaveBeenCalledWith("文件下载已开始");
});
it("uses persistent desktop activity for native save flows", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
await expect(saveFileWithFeedback(output)).resolves.toBe("saved");
expect(startActivityMock).toHaveBeenCalledWith({
kind: "download",
title: "保存文件",
detail: "等待选择保存位置",
});
expect(updateActivityMock).toHaveBeenCalledWith("activity-1", {
detail: "等待选择保存位置",
progress: 70,
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "文件已保存" });
expect(messageSuccessMock).not.toHaveBeenCalled();
});
it("reports cancelled web save flows without treating them as downloads", async () => {
saveFileMock.mockResolvedValue("cancelled");
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
await expect(saveFileWithFeedback(output)).resolves.toBe("cancelled");
expect(messageInfoMock).toHaveBeenCalledWith("已取消保存文件");
expect(messageSuccessMock).not.toHaveBeenCalled();
});
it("reports cancelled native save flows through desktop activity", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
saveFileMock.mockResolvedValue("cancelled");
const { saveFileWithFeedback } = await import("./fileTaskFeedback");
await expect(saveFileWithFeedback(output)).resolves.toBe("cancelled");
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "cancelled", { detail: "已取消保存" });
expect(messageInfoMock).not.toHaveBeenCalled();
});
it("reports external open completion", async () => {
const { openFileWithFeedback } = await import("./fileTaskFeedback");
await openFileWithFeedback(output);
expect(openFileMock).toHaveBeenCalledWith(output);
expect(messageSuccessMock).toHaveBeenCalledWith("文件已打开");
});
it("uses persistent desktop activity for native open flows", async () => {
capabilitiesMock.mockReturnValue({ nativeFiles: true });
const { openFileWithFeedback } = await import("./fileTaskFeedback");
await openFileWithFeedback(output);
expect(startActivityMock).toHaveBeenCalledWith({
kind: "open",
title: "打开文件",
detail: "正在准备文件",
});
expect(finishActivityMock).toHaveBeenCalledWith("activity-1", "completed", { detail: "已交给系统打开" });
expect(messageSuccessMock).not.toHaveBeenCalled();
});
});
+70 -9
View File
@@ -8,9 +8,25 @@ import {
type FilePickerOptions,
type SaveFileResult,
} from "../runtime";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../session/desktopActivityCenter";
const selectedFilesMessage = (count: number) => (count > 1 ? `已选择 ${count} 个文件` : "已选择 1 个文件");
type FileSaveActivityKind = "download" | "export";
export interface FileTaskFeedbackOptions {
activityId?: string;
kind?: FileSaveActivityKind;
title?: string;
pendingDetail?: string;
completedDetail?: string;
cancelledDetail?: string;
}
const desktopFileActivitiesEnabled = () => clientRuntime.capabilities().nativeFiles;
const defaultSaveTitle = (kind: FileSaveActivityKind) => (kind === "export" ? "导出文件" : "保存文件");
export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Promise<File[]> => {
const files = await pickFiles(options);
if (files.length) {
@@ -19,17 +35,62 @@ export const pickFilesWithFeedback = async (options: FilePickerOptions = {}): Pr
return files;
};
export const saveFileWithFeedback = async (output: FileOutput): Promise<SaveFileResult> => {
const result = await saveFile(output);
if (result === "cancelled") {
ElMessage.info("已取消保存文件");
export const saveFileWithFeedback = async (
output: FileOutput,
options: FileTaskFeedbackOptions = {},
): Promise<SaveFileResult> => {
const kind = options.kind || "download";
if (!desktopFileActivitiesEnabled()) {
const result = await saveFile(output);
if (result === "cancelled") {
ElMessage.info("已取消保存文件");
return result;
}
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
return result;
}
ElMessage.success(clientRuntime.capabilities().nativeFiles ? "文件已保存" : "文件下载已开始");
return result;
const activityId = options.activityId || startDesktopActivity({
kind,
title: options.title || defaultSaveTitle(kind),
detail: options.pendingDetail || "等待选择保存位置",
});
updateDesktopActivity(activityId, { detail: options.pendingDetail || "等待选择保存位置", progress: 70 });
try {
const result = await saveFile(output);
if (result === "cancelled") {
finishDesktopActivity(activityId, "cancelled", { detail: options.cancelledDetail || "已取消保存" });
return result;
}
finishDesktopActivity(activityId, "completed", { detail: options.completedDetail || "文件已保存" });
return result;
} catch (error) {
finishDesktopActivity(activityId, "failed", { detail: "文件保存失败" });
throw error;
}
};
export const openFileWithFeedback = async (output: FileOutput): Promise<void> => {
await openFile(output);
ElMessage.success("文件已打开");
export const openFileWithFeedback = async (
output: FileOutput,
options: Omit<FileTaskFeedbackOptions, "kind"> = {},
): Promise<void> => {
if (!desktopFileActivitiesEnabled()) {
await openFile(output);
ElMessage.success("文件已打开");
return;
}
const activityId = options.activityId || startDesktopActivity({
kind: "open",
title: options.title || "打开文件",
detail: options.pendingDetail || "正在准备文件",
});
updateDesktopActivity(activityId, { detail: options.pendingDetail || "正在准备文件", progress: 70 });
try {
await openFile(output);
finishDesktopActivity(activityId, "completed", { detail: options.completedDetail || "已交给系统打开" });
} catch (error) {
finishDesktopActivity(activityId, "failed", { detail: "文件打开失败" });
throw error;
}
};
+54 -10
View File
@@ -134,7 +134,13 @@
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
{{ notificationPermissionText }}
</el-tag>
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
<el-icon><Bell /></el-icon>
<span>测试通知</span>
</el-button>
</div>
</div>
</section>
@@ -199,6 +205,7 @@ import {
requestNotificationPermission,
setDesktopServerUrl,
setDesktopThemePreference,
showSystemNotificationProbe,
type DesktopThemePreference,
type NotificationPermissionState,
} from "../runtime";
@@ -257,6 +264,7 @@ const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopNotificationTestLoading = ref(false);
const desktopUpdateChecking = ref(false);
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
const notificationPermission = ref<NotificationPermissionState>("unsupported");
@@ -286,14 +294,14 @@ const clientMetadataRows = computed(() => [
]);
const notificationPermissionText = computed(() => {
if (notificationPermission.value === "granted") return "已授权";
if (notificationPermission.value === "denied") return "已拒绝";
if (notificationPermission.value === "prompt") return "待授权";
return "不可用";
});
const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted");
const notificationPermissionTagType = computed(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
@@ -426,7 +434,7 @@ const scheduleConnectionPending = (message: string) => {
};
const clearSessionForServerChange = async () => {
await auth.logout();
await auth.logout({ rememberCurrentStudy: false });
studyStore.clearCurrentStudy();
};
@@ -594,6 +602,31 @@ const onDesktopNotificationChange = async (value: string | number | boolean) =>
}
};
const sendDesktopNotificationTest = async () => {
if (desktopNotificationTestLoading.value) return;
desktopNotificationTestLoading.value = true;
try {
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value !== "granted") {
notificationPermission.value = await requestNotificationPermission();
}
if (notificationPermission.value !== "granted") {
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
const sent = await showSystemNotificationProbe();
if (!sent) {
ElMessage.warning("系统通知未发送,请确认系统通知权限已开启");
return;
}
ElMessage.success("已调用系统通知");
} catch {
ElMessage.error("系统通知发送失败");
} finally {
desktopNotificationTestLoading.value = false;
}
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
@@ -672,7 +705,9 @@ onBeforeUnmount(() => {
height: min(700px, calc(100vh - 96px));
overflow: hidden;
background: var(--pref-bg-pane);
border-radius: inherit;
border-radius: 8px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
isolation: isolate;
}
/* =====================================================
@@ -688,6 +723,8 @@ onBeforeUnmount(() => {
/* 用半透明线替代硬边框,消除左右割裂感 */
border-right: 1px solid var(--pref-sidebar-border);
background: var(--pref-bg-sidebar);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.window-close {
@@ -829,6 +866,8 @@ h3 {
min-width: 0;
min-height: 0;
background: var(--pref-bg-pane);
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
.pane-header {
@@ -837,6 +876,7 @@ h3 {
padding: 22px 30px 18px;
border-bottom: 1px solid var(--pref-border);
background: var(--pref-bg-pane);
border-top-right-radius: 8px;
}
.pane-header-content {
@@ -877,6 +917,7 @@ h3 {
overscroll-behavior: contain;
padding: 24px 30px 32px;
scrollbar-gutter: stable;
border-bottom-right-radius: 8px;
}
.preference-panel {
@@ -1010,8 +1051,6 @@ h3 {
justify-content: flex-end;
gap: 8px;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--pref-border);
}
.preference-panel :deep(.el-button) {
@@ -1113,6 +1152,10 @@ h3 {
padding: 15px 16px;
}
.preference-row > div:first-child {
min-width: 0;
}
.row-title {
color: var(--pref-text-primary);
font-size: 13.5px;
@@ -1123,11 +1166,14 @@ h3 {
margin-top: 3px;
color: var(--pref-text-secondary);
font-size: 12px;
overflow-wrap: anywhere;
}
.row-control {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
@@ -1321,9 +1367,7 @@ code {
color: var(--pref-text-primary);
}
:global([data-ctms-theme="dark"] .desktop-preferences .connection-actions) {
border-top-color: var(--pref-border);
}
:global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) {
border-color: var(--pref-border-card);
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEntryView = () => readFileSync(resolve(__dirname, "./DesktopProjectEntry.vue"), "utf8");
describe("DesktopProjectEntry", () => {
it("offers admin backend and explicit project entry without direct in-project switching", () => {
const source = readEntryView();
expect(source).toContain("工作台总控");
expect(source).toContain("选择目标项目");
expect(source).toContain("Desktop Workbench");
expect(source).toContain('class="entry-background-grid"');
expect(source).toContain("管理后台");
expect(source).toContain("ADMIN SYSTEM");
expect(source).toContain("project-cards-grid");
expect(source).toContain("card-action-bar");
expect(source).toContain("进入项目工作空间");
expect(source).toContain('router.push("/admin/users")');
expect(source).toContain("studyStore.clearCurrentStudy()");
expect(source).toContain("fetchStudies()");
expect(source).toContain("studyStore.setCurrentStudy(project)");
expect(source).toContain("studyStore.loadCurrentStudyPermissions()");
expect(source).toContain("findFirstAccessibleProjectPath");
expect(source).toContain("当前账号暂无该项目可访问模块");
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
});
});
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -144,7 +144,7 @@ const checkHealth = async (baseUrl: string) => {
};
const clearSessionForServerChange = async () => {
await auth.logout();
await auth.logout({ rememberCurrentStudy: false });
studyStore.clearCurrentStudy();
};
@@ -229,6 +229,7 @@ const goBack = () => {
<style scoped>
.desktop-settings-page {
box-sizing: border-box;
min-height: 100vh;
display: flex;
align-items: center;
@@ -239,7 +240,9 @@ const goBack = () => {
.settings-panel {
width: min(100%, 520px);
max-height: calc(100vh - 64px);
padding: 32px;
overflow: auto;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #ffffff;
@@ -324,6 +327,11 @@ h1 {
font-weight: 700;
}
.diagnostic-grid code {
min-width: 0;
overflow-wrap: anywhere;
}
.actions {
display: flex;
justify-content: flex-end;
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readRestoreView = () => readFileSync(resolve(__dirname, "./DesktopSessionRestore.vue"), "utf8");
describe("DesktopSessionRestore", () => {
it("waits for network recovery without clearing the desktop session", () => {
const source = readRestoreView();
expect(source).toContain("正在恢复登录状态");
expect(source).toContain("网络恢复后将自动校验账号并回到工作台");
expect(source).toContain("auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true })");
expect(source).toContain("window.setInterval");
expect(source).toContain('window.addEventListener("online", retryNow)');
expect(source).toContain("DESKTOP_SERVER_URL_CHANGED_EVENT");
expect(source).toContain("isExplicitAuthCheckFailure(error)");
expect(source).toContain("await auth.logout()");
expect(source).toContain("retryCount.value += 1");
});
});
@@ -0,0 +1,234 @@
<template>
<div class="desktop-session-restore" data-tauri-drag-region>
<section class="restore-panel">
<div class="restore-mark">CTMS</div>
<div class="restore-copy">
<span class="restore-eyebrow">Desktop Session</span>
<h1>正在恢复登录状态</h1>
<p>{{ statusMessage }}</p>
</div>
<div class="restore-status" :class="{ checking }">
<span class="status-orbit" aria-hidden="true"></span>
<span>{{ checking ? "正在连接服务器" : "等待网络恢复" }}</span>
</div>
<div class="restore-actions">
<el-button type="primary" :loading="checking" @click="retryNow">立即重试</el-button>
<RouterLink to="/desktop/server-settings" class="server-link">服务器设置</RouterLink>
</div>
<small class="restore-note">
桌面端会保留本机登录凭据网络恢复后将自动校验账号并回到工作台
</small>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
import { DESKTOP_SESSION_RESTORE_PATH, isExplicitAuthCheckFailure } from "../session/authRecovery";
import { useAuthStore } from "../store/auth";
const RETRY_INTERVAL_MS = 5_000;
const FALLBACK_REDIRECT = "/desktop/project-entry";
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const checking = ref(false);
const retryCount = ref(0);
let retryTimer: number | undefined;
const redirectTarget = computed(() => {
const raw = Array.isArray(route.query.redirect) ? route.query.redirect[0] : route.query.redirect;
if (typeof raw !== "string") return FALLBACK_REDIRECT;
if (!raw.startsWith("/") || raw.startsWith("//") || raw.startsWith(DESKTOP_SESSION_RESTORE_PATH)) {
return FALLBACK_REDIRECT;
}
return raw;
});
const statusMessage = computed(() =>
retryCount.value === 0
? "正在确认服务器连接和账号状态。"
: "当前无法连接服务器,已保留本机登录状态并将自动重试。"
);
const recoverSession = async () => {
if (checking.value) return;
if (!auth.token) {
await router.replace("/login");
return;
}
checking.value = true;
try {
await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
await router.replace(redirectTarget.value);
} catch (error) {
if (isExplicitAuthCheckFailure(error)) {
await auth.logout();
await router.replace("/login");
return;
}
retryCount.value += 1;
} finally {
checking.value = false;
}
};
const retryNow = () => {
void recoverSession();
};
onMounted(() => {
void recoverSession();
retryTimer = window.setInterval(() => {
void recoverSession();
}, RETRY_INTERVAL_MS);
window.addEventListener("online", retryNow);
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, retryNow);
});
onBeforeUnmount(() => {
if (retryTimer) {
window.clearInterval(retryTimer);
}
window.removeEventListener("online", retryNow);
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, retryNow);
});
</script>
<style scoped>
.desktop-session-restore {
min-width: 1180px;
min-height: 100vh;
min-height: 100dvh;
display: grid;
place-items: center;
padding: 40px;
background:
radial-gradient(circle at 14% 16%, rgba(47, 95, 134, 0.1), transparent 30%),
radial-gradient(circle at 84% 24%, rgba(63, 143, 107, 0.1), transparent 28%),
linear-gradient(135deg, #f8fafc 0%, #eef4f8 100%);
color: #102033;
}
.restore-panel {
width: min(520px, 100%);
display: flex;
flex-direction: column;
align-items: center;
gap: 18px;
padding: 34px 36px;
border: 1px solid #d9e2ec;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 24px 70px rgba(16, 32, 51, 0.12);
text-align: center;
}
.restore-mark {
width: 64px;
height: 64px;
display: grid;
place-items: center;
border-radius: 16px;
background: #15344f;
color: #ffffff;
font-size: 15px;
font-weight: 800;
letter-spacing: 0;
}
.restore-copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.restore-eyebrow {
color: #3f8f6b;
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.restore-copy h1 {
margin: 0;
color: #102033;
font-size: 24px;
line-height: 1.25;
}
.restore-copy p,
.restore-note {
margin: 0;
color: #5d7087;
font-size: 14px;
line-height: 1.7;
}
.restore-status {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 36px;
padding: 0 14px;
border-radius: 999px;
background: #eef4f8;
color: #3f5d75;
font-size: 13px;
font-weight: 700;
}
.status-orbit {
width: 10px;
height: 10px;
border-radius: 999px;
background: #3f8f6b;
box-shadow: 0 0 0 4px rgba(63, 143, 107, 0.14);
}
.restore-status.checking .status-orbit {
animation: restore-pulse 1.2s ease-in-out infinite;
}
.restore-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.server-link {
color: #3f5d75;
font-size: 13px;
font-weight: 700;
text-decoration: none;
}
.server-link:hover {
color: #15344f;
}
.restore-note {
max-width: 400px;
font-size: 12px;
}
@keyframes restore-pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.72);
opacity: 0.55;
}
}
</style>
+261 -6
View File
@@ -1,8 +1,12 @@
<template>
<div class="page ctms-page-shell page--flush medical-consult-page">
<div class="page-bg-dots"></div>
<div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div v-if="!isDesktop" class="page-bg-dots"></div>
<section class="faq-hero">
<h1><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<h1 v-if="!isDesktop"><el-icon class="hero-title-icon"><Collection /></el-icon>项目知识库</h1>
<div v-else class="desktop-toolbar-meta">
<span>知识条目</span>
<small>{{ activeCategoryName }} · {{ resultSummary }}</small>
</div>
<div class="hero-tools">
<el-autocomplete
v-model="keyword"
@@ -19,7 +23,13 @@
<el-icon><Search /></el-icon>
</template>
</el-autocomplete>
<div class="spacer" />
<PermissionAction v-if="isDesktop" action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
新建
</el-button>
</PermissionAction>
<div v-else class="spacer" />
</div>
</section>
@@ -34,7 +44,7 @@
</el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell">
<div class="list-toolbar">
<div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon>
@@ -79,6 +89,7 @@ import { fetchFaqCategories, fetchFaqItems } from "../api/faqs";
import FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue";
@@ -87,6 +98,7 @@ import { TEXT } from "../locales";
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]);
const faqs = ref<any[]>([]);
@@ -104,6 +116,14 @@ const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read"));
const canUpdateFaq = computed(() => can("faq.update"));
const canDeleteFaq = computed(() => can("faq.delete"));
const activeCategoryName = computed(() => {
if (!activeCategory.value) return TEXT.common.labels.all;
return categories.value.find((item: any) => item.id === activeCategory.value)?.name || "已筛选分类";
});
const resultSummary = computed(() => {
const suffix = keyword.value.trim() ? ",已应用搜索" : "";
return `${total.value}${suffix}`;
});
const loadCategories = async () => {
try {
@@ -247,7 +267,7 @@ onMounted(async () => {
color: #0f172a;
font-size: 32px;
font-weight: 900;
letter-spacing: -0.02em;
letter-spacing: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -361,6 +381,228 @@ onMounted(async () => {
flex: 1;
}
.desktop-toolbar-meta {
display: flex;
position: relative;
min-width: 160px;
padding-left: 14px;
flex-direction: column;
gap: 2px;
text-align: left;
}
.desktop-toolbar-meta::before {
content: "";
position: absolute;
top: 2px;
bottom: 2px;
left: 0;
width: 4px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 16px rgba(47, 123, 232, 0.35);
}
.desktop-toolbar-meta span {
color: #11335a;
font-size: 15px;
font-weight: 850;
}
.desktop-toolbar-meta small {
color: #436785;
font-size: 12px;
font-weight: 600;
}
.medical-consult-page--desktop {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 0 !important;
overflow: hidden;
background: linear-gradient(180deg, #eef5ff 0%, #f7fbff 100%);
}
.medical-consult-page--desktop .faq-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px !important;
border: 1px solid rgba(90, 145, 220, 0.24) !important;
background:
radial-gradient(circle at 0% 0%, rgba(42, 132, 255, 0.22) 0%, transparent 34%),
radial-gradient(circle at 78% 18%, rgba(35, 183, 217, 0.16) 0%, transparent 30%),
linear-gradient(135deg, #ecf7ff 0%, #edf3ff 46%, #f8fcff 100%) !important;
box-shadow:
0 10px 24px rgba(30, 73, 128, 0.08),
0 1px 0 rgba(255, 255, 255, 0.86) inset !important;
text-align: left;
border-radius: 0 !important;
}
.medical-consult-page--desktop .faq-hero::before {
display: none;
}
.medical-consult-page--desktop .hero-tools {
width: auto;
max-width: none;
margin: 0;
justify-content: flex-end;
}
.medical-consult-page--desktop .hero-search {
width: min(460px, 44vw);
}
.medical-consult-page--desktop .hero-search :deep(.el-input__wrapper) {
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow:
0 8px 20px rgba(48, 92, 150, 0.08),
0 0 0 1px rgba(93, 146, 214, 0.24) inset;
transition: border-color 0.2s, box-shadow 0.2s;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__inner) {
font-size: 13px;
font-weight: 600;
}
.medical-consult-page--desktop .hero-search :deep(.el-input__prefix) {
color: #6d93bc;
font-size: 15px;
}
.medical-consult-page--desktop .new-consult-btn {
height: 34px;
padding: 0 16px;
border-radius: 8px;
background: linear-gradient(135deg, #2f7be8 0%, #2560bd 100%);
border: none;
box-shadow: 0 10px 18px rgba(47, 123, 232, 0.24);
font-size: 13px;
font-weight: 800;
transition: all 0.2s;
}
.medical-consult-page--desktop .new-consult-btn:hover,
.medical-consult-page--desktop .new-consult-btn:focus {
background: linear-gradient(135deg, #3d6bbf 0%, #254d90 100%);
box-shadow: 0 4px 14px rgba(79, 126, 207, 0.4);
transform: translateY(-1px);
}
.medical-consult-page--desktop .faq-workspace {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
flex: 1 1 auto;
height: 0;
min-height: 0;
margin-top: 0;
border: 1px solid rgba(130, 158, 190, 0.22);
border-radius: 0;
background: #ffffff;
overflow: hidden;
}
.medical-consult-page--desktop .faq-sidebar-col,
.medical-consult-page--desktop .faq-content-col {
width: auto;
max-width: none !important;
flex: initial !important;
}
.medical-consult-page--desktop .faq-sidebar-col {
min-height: 0;
border-right: 1px solid rgba(134, 163, 194, 0.22);
background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.13) 0%, transparent 34%),
linear-gradient(180deg, #e9f4ff 0%, #f6faff 44%, #eaf1fa 100%);
overflow: hidden;
}
.medical-consult-page--desktop .faq-content-col {
display: flex;
min-height: 0;
overflow: hidden;
}
.medical-consult-page--desktop .faq-main {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex: 1;
flex-direction: column;
border: 0 !important;
padding: 0;
overflow: hidden;
background: #ffffff;
}
.medical-consult-page--desktop .pagination-wrap {
flex: 0 0 auto;
padding: 10px 14px;
border-top: 1px solid #edf3fa;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.medical-consult-page--desktop :deep(.faq-category-panel) {
height: 100%;
padding: 12px 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .header) {
padding: 10px 10px;
font-size: 12px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu) {
flex: 1 1 auto;
max-height: none;
min-height: 0;
padding-top: 10px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .menu .el-menu-item) {
height: 36px;
margin: 4px 0;
border-radius: 8px;
font-size: 13px;
line-height: 36px;
}
.medical-consult-page--desktop :deep(.faq-category-panel .cat-icon) {
width: 22px;
height: 22px;
border-radius: 7px;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-hero),
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-workspace) {
border-color: #26364a;
background:
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.2) 0%, transparent 34%),
linear-gradient(135deg, #172033 0%, #111827 100%) !important;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .faq-sidebar-col) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .medical-consult-page--desktop .desktop-toolbar-meta span) {
color: #e5edf7;
}
@media (max-width: 1080px) {
.hero-tools {
flex-wrap: wrap;
@@ -376,5 +618,18 @@ onMounted(async () => {
.faq-main {
padding: 18px 16px 0;
}
.medical-consult-page--desktop .faq-workspace {
grid-template-columns: 1fr;
}
.medical-consult-page--desktop .faq-sidebar-col {
border-right: 0;
border-bottom: 1px solid #d9e2ec;
}
.medical-consult-page--desktop .faq-main {
padding: 0;
}
}
</style>
-2
View File
@@ -422,8 +422,6 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@600;800&display=swap");
/* ═══════════════════
根容器
═══════════════════ */
+28 -23
View File
@@ -8,7 +8,7 @@ describe("Login protocol agreement", () => {
it("requires protocol agreement before calling login", () => {
const source = readLoginView();
const protocolGuardIndex = source.indexOf("!form.agreeProtocol");
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
const loginCallIndex = source.indexOf("auth.login(form.email, form.password");
expect(source).toContain('v-model="form.agreeProtocol"');
expect(source).toContain("我已阅读并同意");
@@ -18,21 +18,25 @@ describe("Login protocol agreement", () => {
expect(protocolGuardIndex).toBeLessThan(loginCallIndex);
});
it("does not access browser password credentials on the login page", () => {
it("supports remembered passwords through runtime credential helpers only", () => {
const source = readLoginView();
expect(source).toContain('autocomplete="username"');
expect(source).toContain('name="username"');
expect(source).toContain('name="ctms-login-password"');
expect(source).toContain('autocomplete="new-password"');
expect(source).not.toContain('v-model="form.rememberPassword"');
expect(source).not.toContain("记住密码");
expect(source).not.toContain("tryLoadBrowserCredential");
expect(source).not.toContain("tryStoreBrowserCredential");
expect(source).toContain('name="password"');
expect(source).toContain('autocomplete="current-password"');
expect(source).toContain('v-model="form.rememberPassword"');
expect(source).toContain("记住密码");
expect(source).toContain("getSavedLoginCredential");
expect(source).toContain("saveLoginCredential");
expect(source).toContain("clearLoginCredential");
expect(source).not.toContain("navigator.credentials");
expect(source).not.toContain("PasswordCredential");
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"');
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'");
const localStorageSetItem = "localStorage" + ".setItem";
const savedPasswordKey = "ctms_saved_" + "password";
expect(source).not.toContain(`${localStorageSetItem}("${savedPasswordKey}"`);
expect(source).not.toContain(`${localStorageSetItem}('${savedPasswordKey}'`);
expect(source).not.toContain("sessionStorage.setItem");
});
it("opens a CTMS-specific protocol dialog from the protocol text", () => {
@@ -55,25 +59,26 @@ describe("Login protocol agreement", () => {
expect(source).toContain("confirmProtocol");
});
it("avoids browser password caching", () => {
it("does not write remembered passwords to browser storage", () => {
const source = readLoginView();
expect(source).toContain('name="ctms-login-password"');
expect(source).toContain('autocomplete="new-password"');
expect(source).not.toContain('autocomplete="current-password"');
expect(source).not.toMatch(/localStorage\.setItem\([^)]*password/i);
expect(source).not.toMatch(/sessionStorage\.setItem\([^)]*password/i);
});
it("restores the current user's last project before routing after login", () => {
it("sends desktop logins to the entry chooser without restoring a project in the login view", () => {
const source = readLoginView();
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)");
const userKeyIndex = source.indexOf("const userKey = auth.user?.email || form.email");
const restoreIndex = source.indexOf("studyStore.restoreStudyForUser(userKey");
const loginCallIndex = source.indexOf("auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin })");
const clearIndex = source.indexOf("studyStore.clearCurrentStudy()");
const desktopEntryRouteIndex = source.indexOf('router.push("/desktop/project-entry")');
const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")');
expect(userKeyIndex).toBeGreaterThan(loginCallIndex);
expect(restoreIndex).toBeGreaterThan(loginCallIndex);
expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex);
expect(source).toContain("preferActive: !!auth.user?.is_admin");
expect(source).toContain("const isDesktopLogin = showDesktopServerSettings");
expect(loginCallIndex).toBeGreaterThan(-1);
expect(clearIndex).toBeGreaterThan(loginCallIndex);
expect(desktopEntryRouteIndex).toBeGreaterThan(clearIndex);
expect(desktopEntryRouteIndex).toBeLessThan(projectOverviewRouteIndex);
expect(source).not.toContain("studyStore.restoreStudyForUser(userKey");
});
it("shows persistent logout reason notices on the login card", () => {
@@ -99,7 +104,7 @@ describe("Login protocol agreement", () => {
it("falls back to full email input when no server-configured domains exist", () => {
const source = readLoginView();
expect(source).toContain('fetchEmailDomains()');
expect(source).toContain("fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true })");
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
expect(source).toContain("availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)");
expect(source).toContain("const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value)");
+100 -10
View File
@@ -176,11 +176,14 @@
<el-input
id="password" v-model="form.password" type="password"
placeholder="请输入密码" show-password size="large"
name="ctms-login-password" autocomplete="new-password" class="login-input">
name="password" autocomplete="current-password" class="login-input">
</el-input>
</el-form-item>
<div class="login-options">
<el-checkbox v-model="form.rememberPassword" class="remember-password-checkbox">
<span class="remember-password-text">记住密码</span>
</el-checkbox>
<el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text">
我已阅读并同意
@@ -256,7 +259,14 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime";
import {
clearLoginCredential,
DESKTOP_SERVER_URL_CHANGED_EVENT,
getDesktopServerUrl,
getSavedLoginCredential,
isTauriRuntime,
saveLoginCredential,
} from "../runtime";
import {
consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED,
@@ -270,7 +280,7 @@ const router = useRouter();
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
const formRef = ref<FormInstance>();
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false });
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", rememberPassword: false, agreeProtocol: false });
const configuredEmailDomains = ref<string[]>([]);
const rules: FormRules<typeof form> = {
@@ -291,6 +301,7 @@ const desktopServerUrl = ref(getDesktopServerUrl());
const refreshDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl();
void loadRememberedCredential(true);
};
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
@@ -337,7 +348,7 @@ const applyEmailValue = (email: string) => {
const loadEmailDomains = async () => {
try {
const { data } = await fetchEmailDomains();
const { data } = await fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true });
const domains = Array.from(new Set(
(Array.isArray(data.items) ? data.items : [])
.map(item => typeof item === "string" ? normalizeDomain(item) : "")
@@ -373,6 +384,43 @@ const handleAccountPaste = (event: ClipboardEvent) => {
applyEmailValue(pasted);
};
const loadRememberedCredential = async (clearWhenMissing = false) => {
try {
const credential = await getSavedLoginCredential();
if (credential) {
applyEmailValue(credential.email);
form.password = credential.password;
form.rememberPassword = true;
return;
}
} catch {
/* ignore credential loading failures */
}
if (clearWhenMissing) {
form.password = "";
form.rememberPassword = false;
}
};
const syncRememberedCredential = async () => {
try {
if (form.rememberPassword) {
const saved = await saveLoginCredential(form.email, form.password);
if (!saved) {
ElMessage.warning(
showDesktopServerSettings
? "记住密码保存失败,请确认系统凭据库可用。"
: "当前浏览器不支持安全保存密码,请使用浏览器密码管理器。",
);
}
return;
}
await clearLoginCredential();
} catch {
ElMessage.warning("记住密码状态同步失败,本次登录不受影响。");
}
};
onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
await loadEmailDomains();
@@ -386,6 +434,7 @@ onMounted(async () => {
}
applyEmailValue(localStorage.getItem("ctms_last_login_email") || "");
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
await loadRememberedCredential();
});
onUnmounted(() => {
@@ -407,10 +456,15 @@ const onSubmit = async () => {
loginError.value = null; // 清除上次错误
loading.value = true;
try {
await auth.login(form.email, form.password);
const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
const isDesktopLogin = showDesktopServerSettings;
await auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin });
await syncRememberedCredential();
if (isDesktopLogin) {
studyStore.clearCurrentStudy();
router.push("/desktop/project-entry");
return;
}
if (studyStore.currentStudy) {
router.push("/project/overview");
} else {
@@ -440,8 +494,6 @@ const onSubmit = async () => {
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;600;800&display=swap");
/* ═══════════════════════
根容器
═══════════════════════ */
@@ -458,6 +510,7 @@ const onSubmit = async () => {
.login-split-container {
display: flex;
min-width: 0;
width: 100vw;
min-height: 100vh;
}
@@ -467,6 +520,7 @@ const onSubmit = async () => {
═══════════════════════ */
.login-left-brand {
flex: 1;
min-width: 0;
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
padding: 60px 80px;
position: relative;
@@ -715,6 +769,7 @@ const onSubmit = async () => {
═══════════════════════ */
.login-right-form {
flex: 1;
min-width: 0;
background: #ffffff;
padding: 60px 80px;
position: relative;
@@ -848,6 +903,7 @@ const onSubmit = async () => {
}
.desktop-server-action {
white-space: nowrap;
color: #2563eb;
font-weight: 700;
text-decoration: none;
@@ -884,7 +940,7 @@ const onSubmit = async () => {
.ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
.ln-icon svg { width: 100%; height: 100%; }
.ln-body { display: flex; flex-direction: column; gap: 2px; }
.ln-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ln-body strong { font-size: 12px; font-weight: 700; }
.ln-body span { font-size: 11px; opacity: 0.9; }
@@ -1060,25 +1116,37 @@ const onSubmit = async () => {
/* 协议区 */
.login-options {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0 24px;
}
.remember-password-checkbox,
.protocol-checkbox {
align-items: center;
}
.remember-password-checkbox :deep(.el-checkbox__input .el-checkbox__inner),
.protocol-checkbox :deep(.el-checkbox__input .el-checkbox__inner) {
border-color: #cbd5e1;
border-radius: 4px;
}
.remember-password-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner),
.protocol-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background: #2563eb;
border-color: #2563eb;
}
.remember-password-checkbox :deep(.el-checkbox__label),
.protocol-checkbox :deep(.el-checkbox__label) {
padding-left: 8px;
white-space: normal;
line-height: 1.6;
}
.remember-password-text,
.protocol-text {
color: #64748b;
font-size: 12px;
@@ -1326,6 +1394,28 @@ const onSubmit = async () => {
}
}
@media (max-width: 1280px) {
.login-left-brand {
padding: 48px 52px;
}
.login-right-form {
padding: 48px 56px;
}
.brand-main-title {
font-size: 34px;
}
.feature-card {
padding: 14px 16px;
}
.login-card-container {
width: min(440px, 100%);
}
}
@media (max-width: 480px) {
.login-brand-header {
margin-bottom: 28px;
+80 -72
View File
@@ -1,75 +1,73 @@
<template>
<div class="page">
<div class="profile-layout">
<aside class="profile-aside">
<div class="avatar-panel">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
{{ profileInitial }}
</el-avatar>
<div class="avatar-meta">
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
<div class="profile-layout">
<aside class="profile-aside">
<div class="avatar-panel">
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
{{ profileInitial }}
</el-avatar>
<div class="avatar-meta">
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
</aside>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
</div>
</aside>
<main class="profile-main">
<header class="profile-header">
<div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
<main class="profile-main">
<header class="profile-header">
<div>
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div>
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
</header>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
<section class="form-section">
<div class="section-heading">
<span class="section-kicker">Account</span>
<h4>基本信息</h4>
</div>
<el-form-item :label="TEXT.common.fields.email">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
</el-form-item>
</section>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
<section class="form-section form-section--password">
<div class="section-heading">
<span class="section-kicker">Security</span>
<h4>修改密码</h4>
</div>
</el-form>
</main>
</div>
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
<el-input
v-model="form.current_password"
type="password"
show-password
autocomplete="new-password"
name="ctms-profile-current-password"
:placeholder="TEXT.modules.profile.currentPasswordHint"
/>
</el-form-item>
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</el-form>
</main>
</div>
</template>
@@ -112,7 +110,6 @@ const hasUnsavedChanges = computed(
form.clinical_department !== savedProfile.value.clinical_department ||
Boolean(form.current_password || form.password || form.confirmPassword)
);
const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), trigger: "blur" }],
@@ -228,20 +225,26 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
</script>
<style scoped>
.page {
background: #fff;
}
.profile-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 620px;
height: min(720px, calc(100vh - 64px));
min-height: 0;
overflow: hidden;
background: #fff;
border-radius: 8px;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
isolation: isolate;
}
.profile-aside {
min-height: 0;
padding: 48px 32px;
overflow: hidden;
border-right: 1px solid #e5ebf2;
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
.avatar-panel {
@@ -295,8 +298,13 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
}
.profile-main {
min-height: 0;
padding: 42px 48px 36px;
overflow: hidden;
overflow-wrap: anywhere;
background: #fff;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
.profile-header {
-2
View File
@@ -750,8 +750,6 @@ onUnmounted(() => {
</script>
<style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;600;800&display=swap");
/*
根容器
*/
@@ -48,4 +48,55 @@ describe("DocumentList permissions", () => {
expect(source).toContain("const displayValue = displayDateTime(value)");
expect(source).not.toContain('replace("T", " ").replace("Z", "")');
});
it("keeps desktop document browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="document-workbench"');
expect(source).toContain('class="document-preview-pane"');
expect(source).toContain("selectedDocument");
expect(source).toContain('@row-click="handleDocumentRowClick"');
expect(source).toContain('@row-dblclick="openDocumentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedDocument"');
expect(source).toContain("selectDocument(row);");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).not.toContain('@row-click="handleRowClick"');
});
it("stretches the desktop document workbench flush to the desktop content edges", () => {
const source = readSource();
expect(source).toContain(":global(.desktop-content:has(> .document-page--desktop))");
expect(source).toContain("padding: 0;");
expect(source).toContain(".document-page--desktop {");
expect(source).toContain("width: 100%;");
expect(source).toContain("height: 100%;");
expect(source).toContain("min-height: 100%;");
expect(source).toContain("margin: 0;");
expect(source).toContain("overflow-x: hidden;");
expect(source).toContain("border-radius: 0 !important;");
expect(source).toContain(".document-page--desktop .main-content-flat");
expect(source).toContain(".document-page--desktop .document-table-section");
expect(source).toContain(".document-page--desktop .document-workbench.is-desktop");
expect(source).toContain(".document-page--desktop .document-table-pane");
expect(source).toContain("flex: 1 1 auto;");
expect(source).toContain("min-height: 0;");
});
it("routes desktop document actions through context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openDocumentContextMenu"');
expect(source).toContain("documentContextMenu");
expect(source).toContain('class="document-context-menu"');
expect(source).not.toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedDocument\"");
expect(source).toContain("@click=\"openSelectedDocumentEditor\"");
expect(source).toContain("@click=\"deleteSelectedDocument\"");
expect(source).toContain(':disabled="isInactiveSite(selectedDocument.site_id)"');
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
expect(source).toContain('"row-selected"');
});
});
+425 -64
View File
@@ -1,5 +1,5 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="page ctms-page-shell page--flush" :class="{ 'document-page--desktop': isDesktop }" @click="closeDocumentContextMenu">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush">
<el-form :inline="true" :model="filters" class="filter-form">
@@ -29,72 +29,132 @@
</el-form>
</div>
<section class="unified-section document-table-section section--flush-x section--flush-top section--flush-bottom">
<el-table
:data="sortedItems"
v-loading="loading"
@row-click="handleRowClick"
:row-class-name="documentRowClass"
class="ctms-table"
table-layout="fixed"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope">
<template #default="{ row }">
<el-tag
effect="plain"
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
>
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ displaySite(row.site_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-secondary datetime-stack">
<span>{{ splitDateTime(row.updated_at).date }}</span>
<span class="datetime-stack__time">{{ splitDateTime(row.updated_at).time }}</span>
</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-if="canDelete" link type="danger" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
<div class="document-workbench" :class="{ 'is-desktop': isDesktop }">
<div class="document-table-pane" :tabindex="isDesktop ? 0 : undefined" @keydown.enter.prevent="openSelectedDocument">
<el-table
:data="sortedItems"
v-loading="loading"
@row-click="handleDocumentRowClick"
@row-dblclick="openDocumentDetail"
@row-contextmenu="openDocumentContextMenu"
:row-class-name="documentRowClass"
class="ctms-table"
highlight-current-row
table-layout="fixed"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
<template #default="{ row }">
<span class="font-semibold">{{ row.title }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.scope">
<template #default="{ row }">
<el-tag
effect="plain"
:type="row.scope_type === 'SITE' ? 'warning' : 'success'"
>
{{ displayEnum(TEXT.enums.scopeType, row.scope_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ displaySite(row.site_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.docType">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">-</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" show-overflow-tooltip>
<template #default="{ row }">
<span class="text-secondary datetime-stack">
<span>{{ splitDateTime(row.updated_at).date }}</span>
<span class="datetime-stack__time">{{ splitDateTime(row.updated_at).time }}</span>
</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-if="canDelete" link type="danger" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="document-preview-pane">
<template v-if="selectedDocument">
<div class="preview-head">
<div>
<div class="preview-kicker">当前文档</div>
<div class="preview-title">{{ selectedDocument.title || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedDocument">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.modules.fileVersionManagement.columns.scope }}</dt>
<dd>{{ displayEnum(TEXT.enums.scopeType, selectedDocument.scope_type) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.site }}</dt>
<dd>{{ displaySite(selectedDocument.site_id) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.docType }}</dt>
<dd>{{ displayText(selectedDocument.doc_type, TEXT.enums.documentType) }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.currentVersion }}</dt>
<dd>{{ selectedDocument.current_effective_version?.version_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</dt>
<dd>{{ displayDateTime(selectedDocument.updated_at) }}</dd>
</dl>
</template>
</el-table-column>
<template #empty>
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template>
</el-table>
<div v-else class="preview-empty">
<span>选择一行查看文档摘要</span>
</div>
</aside>
</div>
</section>
</div>
<div
v-if="isDesktop && documentContextMenu.visible && selectedDocument"
class="document-context-menu"
:style="{ left: `${documentContextMenu.x}px`, top: `${documentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedDocument">打开详情</button>
<button
v-if="canUpdate"
type="button"
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="openSelectedDocumentEditor"
>
{{ TEXT.common.actions.edit }}
</button>
<button
v-if="canDelete"
type="button"
class="danger"
:disabled="isInactiveSite(selectedDocument.site_id)"
@click="deleteSelectedDocument"
>
{{ TEXT.common.actions.delete }}
</button>
</div>
<el-drawer
v-if="editorVisible"
v-model="editorVisible"
@@ -179,6 +239,7 @@ import type { Site } from "../../types/api";
import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const route = useRoute();
const router = useRouter();
@@ -192,6 +253,9 @@ const editorVisible = ref(false);
const saving = ref(false);
const editingDocumentId = ref("");
const editorFormRef = ref<FormInstance>();
const isDesktop = isTauriRuntime();
const selectedDocumentId = ref("");
const documentContextMenu = ref({ visible: false, x: 0, y: 0 });
let desktopRefreshCleanup: (() => void) | undefined;
const trialId = computed(() => (route.params.trialId as string) || "");
@@ -228,7 +292,13 @@ const canUpdate = computed(() => can("documents.update"));
const canDelete = computed(() => can("documents.delete"));
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
const documentRowClass = ({ row }: { row: DocumentSummary }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
[
row?.id ? "clickable-row" : "",
isInactiveSite(row?.site_id) ? "row-inactive" : "",
isDesktop && row?.id === selectedDocumentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const filteredItems = computed(() =>
items.value.filter((item) => {
if (filters.scope_type && item?.scope_type !== filters.scope_type) return false;
@@ -240,6 +310,9 @@ const filteredItems = computed(() =>
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id)))
);
const selectedDocument = computed(() =>
sortedItems.value.find((item) => item.id === selectedDocumentId.value) || null
);
const editorForm = reactive({
doc_no: "",
@@ -402,11 +475,60 @@ const submitEditor = async () => {
const goDetail = (id: string) => router.push(`/documents/${id}`);
const handleRowClick = (row: DocumentSummary) => {
const selectDocument = (row: DocumentSummary) => {
if (!row?.id) return;
selectedDocumentId.value = row.id;
};
const handleDocumentRowClick = (row: DocumentSummary) => {
if (!row?.id) return;
if (isDesktop) {
selectDocument(row);
return;
}
goDetail(row.id);
};
const openDocumentDetail = (row?: DocumentSummary | null) => {
const target = row?.id ? row : selectedDocument.value;
if (target?.id) goDetail(target.id);
};
const openSelectedDocument = () => {
closeDocumentContextMenu();
openDocumentDetail(selectedDocument.value);
};
const openSelectedDocumentEditor = () => {
const target = selectedDocument.value;
closeDocumentContextMenu();
if (target) openEdit(target);
};
const deleteSelectedDocument = () => {
const target = selectedDocument.value;
closeDocumentContextMenu();
if (target) {
void confirmDelete(target);
}
};
const closeDocumentContextMenu = () => {
if (!documentContextMenu.value.visible) return;
documentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const openDocumentContextMenu = (row: DocumentSummary, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectDocument(row);
documentContextMenu.value = {
visible: true,
x: Math.min(event.clientX, window.innerWidth - 184),
y: Math.min(event.clientY, window.innerHeight - 132),
};
};
const confirmDelete = async (row: DocumentSummary) => {
if (!canDelete.value) {
ElMessage.warning("权限不足");
@@ -457,6 +579,22 @@ watch(
}
}
);
watch(
() => sortedItems.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
const rows = sortedItems.value;
if (!rows.length) {
selectedDocumentId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedDocumentId.value)) {
selectedDocumentId.value = rows[0].id;
}
},
{ immediate: true }
);
</script>
<style scoped>
@@ -466,6 +604,31 @@ watch(
gap: 0;
}
:global(.desktop-content:has(> .document-page--desktop)) {
overflow-x: hidden;
padding: 0;
}
.document-page--desktop {
width: 100%;
height: 100%;
min-height: 100%;
margin: 0;
overflow-x: hidden;
}
.document-page--desktop .main-content-flat {
display: flex;
width: auto;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
background: #ffffff;
box-shadow: none !important;
overflow-x: hidden;
}
.filter-form {
display: flex;
width: 100%;
@@ -551,6 +714,162 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02);
}
.document-page--desktop .document-table-section {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
border-radius: 0 !important;
box-shadow: none !important;
}
.document-workbench {
min-width: 0;
min-height: 0;
}
.document-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 304px;
min-height: min(570px, calc(100vh - 178px));
}
.document-page--desktop .document-workbench.is-desktop {
height: 100%;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.document-table-pane {
min-width: 0;
outline: none;
}
.document-page--desktop .document-table-pane {
min-height: 0;
overflow: hidden;
background: #ffffff;
}
.document-workbench.is-desktop .document-table-pane {
border-right: 1px solid #e3e9f1;
}
.document-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.document-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 172px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.document-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.document-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.document-context-menu button.danger {
color: #b42318;
}
.document-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.ctms-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -583,6 +902,10 @@ watch(
transition: background 0.1s ease;
}
.ctms-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.ctms-table :deep(.font-semibold) {
font-weight: 600;
color: #0a0a0a;
@@ -628,6 +951,44 @@ watch(
.ctms-table :deep(.cell-actions .el-button + .el-button) {
margin-left: 0;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-table-section) {
background: #172033;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .document-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .document-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .document-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .document-page--desktop .document-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .document-page--desktop .ctms-table .el-table__body tr.row-selected > td) {
background: #243247 !important;
}
</style>
<style>
@@ -35,4 +35,19 @@ describe("ContractFees.vue", () => {
expect(source).not.toContain('router.push("/fees/contracts/new")');
expect(source).not.toContain("`/fees/contracts/${contractId.value}`");
});
it("uses a desktop-only density class for the fee workspace", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain(":class=\"{ 'fee-contracts-page--desktop': isDesktop }\"");
expect(source).toContain("contract-shell");
expect(source).toContain(".fee-contracts-page--desktop .contract-shell");
expect(source).toContain("flex: 1 1 0;");
expect(source).toContain(".fee-contracts-page--desktop :deep(.kpi-card)");
expect(source).toContain("min-height: 104px;");
expect(source).toContain(".fee-contracts-page--desktop .table-empty");
expect(source).toContain("min-height: 132px;");
expect(source).not.toContain("empty-create-btn");
});
});
+75 -3
View File
@@ -1,6 +1,6 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="overview">
<div class="page ctms-page-shell page--flush" :class="{ 'fee-contracts-page--desktop': isDesktop }">
<div class="overview fee-overview">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
@@ -53,7 +53,7 @@
<StateLoading v-else-if="loading" :rows="6" />
<div class="main-content-flat unified-shell" v-else>
<div class="main-content-flat unified-shell contract-shell" v-else>
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item">
@@ -208,9 +208,11 @@ import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update"));
@@ -522,6 +524,76 @@ onMounted(async () => {
letter-spacing: 0.02em;
}
.fee-contracts-page--desktop {
height: 100%;
min-height: 0;
overflow: hidden;
gap: 8px;
}
.fee-contracts-page--desktop .fee-overview {
flex: 0 0 auto;
}
.fee-contracts-page--desktop .contract-shell {
display: flex;
flex: 1 1 0;
min-height: 0;
flex-direction: column;
}
.fee-contracts-page--desktop .contract-table-section {
flex: 1 1 0;
min-height: 0;
background: var(--ctms-bg-card);
}
.fee-contracts-page--desktop .table-empty {
min-height: 132px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-card) {
min-height: 104px;
padding: 12px 16px;
border-radius: 8px;
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-card:hover) {
box-shadow: none;
transform: none;
}
.fee-contracts-page--desktop :deep(.kpi-badge) {
margin-bottom: 8px;
padding: 3px 9px;
border-radius: 5px;
}
.fee-contracts-page--desktop :deep(.kpi-title) {
overflow: hidden;
font-size: 15px;
line-height: 1.25;
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.fee-contracts-page--desktop :deep(.kpi-footer) {
padding-top: 8px;
}
.fee-contracts-page--desktop :deep(.kpi-value) {
font-size: 30px;
letter-spacing: 0;
}
.fee-contracts-page--desktop :deep(.kpi-unit) {
font-size: 13px;
}
</style>
<style>
@@ -85,4 +85,37 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true');
});
it("keeps desktop shipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="shipment-workbench"');
expect(source).toContain('class="shipment-preview-pane"');
expect(source).toContain('ref="shipmentTablePaneRef"');
expect(source).toContain("selectedShipment");
expect(source).toContain('@row-click="handleShipmentRowClick"');
expect(source).toContain('@row-dblclick="openShipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedShipment"');
expect(source).toContain("selectShipment(row);");
expect(source).toContain("shipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("router.push(`/drug/shipments/${row.id}`);");
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop shipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openShipmentContextMenu"');
expect(source).toContain("shipmentContextMenu");
expect(source).toContain('class="shipment-context-menu"');
expect(source).toContain('class="preview-actions"');
expect(source).toContain('@click="openSelectedShipment"');
expect(source).toContain('@click="openSelectedShipmentEditor"');
expect(source).toContain('@click="removeSelectedShipment"');
expect(source).toContain(':disabled="isInactiveSite(selectedShipment.center_id)"');
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
expect(source).toContain('"row-selected"');
});
});
+469 -69
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
@@ -47,85 +47,178 @@
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="sortedItems"
class="shipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="shipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }">
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }">
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<div class="shipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="shipmentTablePaneRef"
class="shipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedShipment"
>
<el-table
v-loading="loading"
:data="sortedItems"
class="shipment-table"
style="width: 100%"
table-layout="fixed"
highlight-current-row
:row-class-name="shipmentRowClass"
@row-click="handleShipmentRowClick"
@row-dblclick="openShipmentDetail"
@row-contextmenu="openShipmentContextMenu"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.site_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction">
<template #default="{ row }">
<span :class="['dir-tag', row.direction === 'SEND' ? 'dir-tag--send' : 'dir-tag--return']">
{{ displayEnum(TEXT.enums.shipmentDirection, row.direction) }}
</span>
</template>
</el-table-column>
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.ship_date) }}</span></template>
</el-table-column>
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate">
<template #default="{ row }"><span class="cell-nowrap">{{ displayDate(row.receive_date) }}</span></template>
</el-table-column>
<el-table-column prop="quantity" :label="TEXT.common.fields.quantity">
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ typeof row.quantity === "number" ? row.quantity : TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-mono cell-nowrap">{{ row.batch_no || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="{ row }">
<span :class="['status-pill', `status-pill--${statusType(row.status)}`]">
{{ displayEnum(TEXT.enums.shipmentStatus, row.status) }}
</span>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button
v-if="canDelete"
link
type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span>{{ TEXT.modules.drugShipments.empty }}</span>
</div>
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="shipment-preview-pane">
<template v-if="selectedShipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前发运</div>
<div class="preview-title">{{ selectedShipment.site_name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedShipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.common.fields.direction }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentDirection, selectedShipment.direction) }}</dd>
<dt>{{ TEXT.common.fields.status }}</dt>
<dd>{{ displayEnum(TEXT.enums.shipmentStatus, selectedShipment.status) }}</dd>
<dt>{{ TEXT.common.fields.shipDate }}</dt>
<dd>{{ displayDate(selectedShipment.ship_date) }}</dd>
<dt>{{ TEXT.common.fields.receiveDate }}</dt>
<dd>{{ displayDate(selectedShipment.receive_date) }}</dd>
<dt>{{ TEXT.common.fields.quantity }}</dt>
<dd>{{ typeof selectedShipment.quantity === "number" ? selectedShipment.quantity : TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.batchNo }}</dt>
<dd>{{ selectedShipment.batch_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.carrier }}</dt>
<dd>{{ selectedShipment.carrier || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.trackingNo }}</dt>
<dd>{{ selectedShipment.tracking_no || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.remark }}</dt>
<dd>{{ selectedShipment.remark || TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button
v-if="canUpdate"
size="small"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDelete"
link
type="danger"
size="small"
:disabled="isInactiveSite(row.center_id)"
@click.stop="remove(row)"
type="danger"
plain
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<span>{{ TEXT.modules.drugShipments.empty }}</span>
<div v-else class="preview-empty">
<span>选择一行查看发运摘要</span>
</div>
</template>
</el-table>
</aside>
</div>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
<div
v-if="isDesktop && shipmentContextMenu.visible && selectedShipment"
class="shipment-context-menu"
:style="{ left: `${shipmentContextMenu.x}px`, top: `${shipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedShipment">打开详情</button>
<button
v-if="canUpdate"
type="button"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="openSelectedShipmentEditor"
>
{{ TEXT.common.actions.edit }}
</button>
<button
v-if="canDelete"
type="button"
class="danger"
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</button>
</div>
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer
v-if="drawerVisible"
@@ -290,6 +383,7 @@ import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { isTauriRuntime } from "../../runtime";
type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
@@ -322,6 +416,10 @@ const drawerVisible = ref(false);
const editingId = ref("");
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
const shipmentTablePaneRef = ref<HTMLElement | null>(null);
const isDesktop = isTauriRuntime();
const selectedShipmentId = ref("");
const shipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const filters = reactive({
center_id: study.currentSite?.id || "",
direction: "" as "" | ShipmentDirection,
@@ -363,6 +461,9 @@ const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !can
const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const selectedShipment = computed(() =>
sortedItems.value.find((item) => item.id === selectedShipmentId.value) || null
);
const requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
@@ -564,12 +665,69 @@ const handleSearch = () => {
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: ShipmentRow }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const onRowClick = (row: ShipmentRow) => {
[
row?.id ? "clickable-row" : "",
isInactiveSite(row?.center_id) ? "row-inactive" : "",
isDesktop && row?.id === selectedShipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const selectShipment = (row: ShipmentRow) => {
if (!row?.id) return;
selectedShipmentId.value = row.id;
shipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleShipmentRowClick = (row: ShipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectShipment(row);
return;
}
router.push(`/drug/shipments/${row.id}`);
};
const openShipmentDetail = (row?: ShipmentRow | null) => {
const target = row?.id ? row : selectedShipment.value;
if (target?.id) router.push(`/drug/shipments/${target.id}`);
};
const openSelectedShipment = () => {
closeShipmentContextMenu();
openShipmentDetail(selectedShipment.value);
};
const openSelectedShipmentEditor = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedShipment = () => {
const target = selectedShipment.value;
closeShipmentContextMenu();
if (target) {
void remove(target);
}
};
const closeShipmentContextMenu = () => {
if (!shipmentContextMenu.value.visible) return;
shipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const openShipmentContextMenu = (row: ShipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectShipment(row);
shipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 184)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 132)),
};
};
const statusType = (status: string) => {
switch (status) {
case "PENDING": return "info";
@@ -633,6 +791,22 @@ watch(
}
);
watch(
() => sortedItems.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
const rows = sortedItems.value;
if (!rows.length) {
selectedShipmentId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedShipmentId.value)) {
selectedShipmentId.value = rows[0].id;
}
},
{ immediate: true }
);
onMounted(async () => {
await loadSites();
await load();
@@ -648,12 +822,26 @@ onMounted(async () => {
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.shipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.shipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Page Header ==================== */
.create-btn {
height: 34px;
@@ -733,6 +921,167 @@ onMounted(async () => {
0 2px 8px rgba(0, 0, 0, 0.02);
}
/* 桌面端:table-card 填满、无圆角 */
.shipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.shipment-workbench {
min-width: 0;
min-height: 0;
}
.shipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 316px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.shipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.shipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.shipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.shipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 16px;
padding: 16px;
background: #f8fafc;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.preview-kicker {
margin-bottom: 5px;
color: #6b7e95;
font-size: 11px;
font-weight: 800;
}
.preview-title {
display: -webkit-box;
overflow: hidden;
color: #142033;
font-size: 17px;
font-weight: 800;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.preview-list {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
gap: 10px 12px;
margin: 0;
}
.preview-list dt {
color: #7b8da3;
font-size: 12px;
font-weight: 700;
}
.preview-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #223349;
font-size: 13px;
font-weight: 650;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
display: flex;
min-height: 180px;
align-items: center;
justify-content: center;
color: #7b8da3;
font-size: 13px;
font-weight: 700;
text-align: center;
}
.shipment-context-menu {
position: fixed;
z-index: 2300;
display: flex;
width: 172px;
flex-direction: column;
gap: 2px;
padding: 6px;
border: 1px solid #cbd7e5;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 14px 38px rgba(15, 23, 42, 0.16);
}
.shipment-context-menu button {
appearance: none;
display: flex;
align-items: center;
width: 100%;
min-height: 30px;
padding: 0 9px;
border: 0;
border-radius: 6px;
background: transparent;
color: #223349;
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 700;
text-align: left;
}
.shipment-context-menu button:hover:not(:disabled) {
background: #eef4fb;
color: #183756;
}
.shipment-context-menu button.danger {
color: #b42318;
}
.shipment-context-menu button:disabled {
cursor: not-allowed;
color: #9aaabd;
}
.shipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -771,6 +1120,10 @@ onMounted(async () => {
transition: background 0.1s ease;
}
.shipment-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.cell-nowrap {
white-space: nowrap;
}
@@ -1035,6 +1388,15 @@ onMounted(async () => {
gap: 12px;
align-items: stretch;
}
.shipment-workbench.is-desktop {
grid-template-columns: minmax(0, 1fr);
}
.shipment-workbench.is-desktop .shipment-table-pane {
border-right: 0;
}
.shipment-preview-pane {
display: none;
}
}
@media (max-width: 640px) {
@@ -1042,6 +1404,44 @@ onMounted(async () => {
width: 100%;
}
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .table-card) {
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-preview-pane) {
border-color: #26364a;
background: #111827;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-title),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dd) {
color: #f8fafc;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-kicker),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-list dt),
:global([data-ctms-theme="dark"] .shipment-page--desktop .preview-empty) {
color: #94a3b8;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu) {
border-color: #334155;
background: #172033;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button) {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-context-menu button:hover:not(:disabled)) {
background: #243247;
color: #bfdbfe;
}
:global([data-ctms-theme="dark"] .shipment-page--desktop .shipment-table .el-table__body tr.row-selected > td) {
background: #243247 !important;
}
</style>
<style>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readEtmfSource = () => readFileSync(resolve(__dirname, "./EtmfPlaceholder.vue"), "utf8");
describe("EtmfPlaceholder copy", () => {
it("does not show redundant directory count or select-node helper copy", () => {
const source = readEtmfSource();
expect(source).not.toContain("个目录节点");
expect(source).not.toContain("selectNodeHint");
});
});
describe("EtmfPlaceholder desktop layout", () => {
it("keeps archive status in the document header instead of a standalone side column", () => {
const source = readEtmfSource();
expect(source).toContain('class="document-status-summary"');
expect(source).toContain('class="status-summary-list"');
expect(source).not.toContain('class="etmf-node-detail"');
expect(source).toContain("grid-template-columns: 280px minmax(0, 1fr);");
expect(source).not.toContain("grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);");
});
});
+380 -240
View File
@@ -5,43 +5,41 @@
<div class="etmf-toolbar">
<div class="etmf-toolbar-main">
<div class="etmf-filter-item">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix>
<el-icon><OfficeBuilding /></el-icon>
</template>
<el-option :label="TEXT.common.labels.allSites" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable size="small" class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix>
<el-icon><OfficeBuilding /></el-icon>
</template>
<el-option :label="TEXT.common.labels.allSites" value="" />
<el-option v-for="site in siteOptions" :key="site.id" :label="site.name" :value="site.id" />
</el-select>
</div>
<div class="etmf-filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable size="small" class="filter-select-comp">
<template #prefix>
<el-icon><CircleCheck /></el-icon>
</template>
<el-option :label="TEXT.common.labels.all" value="" />
<el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</div>
</div>
<!-- 内联状态摘要徽章 -->
<div class="etmf-status-inline">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-badge" :class="`etmf-status-badge--${card.key}`">
<span class="status-badge-value">{{ card.value }}</span>
<span class="status-badge-label">{{ card.label }}</span>
</div>
</div>
<div class="etmf-toolbar-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button v-if="canCreate" @click="openNodeDialog">
<el-icon class="el-icon--left"><FolderAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newNode }}
</el-button>
<el-button v-if="canCreate && selectedNode" type="primary" @click="openDocumentDialog">
<el-button size="small" :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button v-if="canCreate && selectedNode" size="small" type="primary" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</div>
<div class="etmf-status-strip">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-card" :class="`etmf-status-card--${card.key}`">
<div class="status-card-value">{{ card.value }}</div>
<div class="status-card-label">{{ card.label }}</div>
</div>
</div>
</div>
<section class="unified-section section--flush etmf-workspace">
@@ -49,7 +47,6 @@
<div class="panel-heading panel-heading--compact">
<div>
<div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div>
<div class="panel-subtitle">{{ totalNodeCount }} 个目录节点</div>
</div>
</div>
<div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree">
@@ -86,69 +83,30 @@
<main class="etmf-document-panel">
<div class="panel-heading">
<div>
<div class="panel-heading-left">
<div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div>
<div class="panel-subtitle">
{{ selectedNode ? `${selectedNode.code} · ${scopeLabel(selectedNode.scope_type)}` : TEXT.modules.etmf.selectNodeHint }}
</div>
<span v-if="selectedNode" class="panel-subtitle">{{ selectedNode.code }} · {{ scopeLabel(selectedNode.scope_type) }}</span>
</div>
<div v-if="selectedNode" class="document-panel-meta">
<span>{{ selectedNode.document_count }} 份文件</span>
<span>{{ selectedNode.effective_document_count }} 生效版本</span>
<el-tag effect="plain" :type="statusType(selectedNode.status)">
<span>{{ selectedNode.effective_document_count }} 生效</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
</div>
<el-table
:data="documents"
v-loading="documentLoading"
class="ctms-table etmf-document-table"
table-layout="fixed"
@row-click="goDocument"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="120">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="150">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div class="empty-icon">
<el-icon><DocumentAdd /></el-icon>
</div>
<div class="empty-title">{{ selectedNode ? TEXT.modules.etmf.emptyDocuments : TEXT.modules.etmf.selectNodeHint }}</div>
<div class="empty-desc">{{ selectedNode ? "当前目录下还没有归档文件。" : "选择目录后可查看文件、版本与中心范围。" }}</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</template>
</el-table>
</main>
<aside class="etmf-node-detail">
<div class="panel-title">{{ TEXT.modules.etmf.detailTitle }}</div>
<template v-if="selectedNode">
<dl class="node-meta">
<div>
<dt>{{ TEXT.modules.etmf.fields.code }}</dt>
<dd>{{ selectedNode.code }}</dd>
</div>
<section v-if="selectedNode" class="document-status-summary">
<div class="status-summary-head">
<span class="status-summary-label">{{ TEXT.modules.etmf.detailTitle }}</span>
<el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }}
</el-tag>
</div>
<span class="status-summary-note" :class="`status-summary-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</span>
<dl class="status-summary-list">
<div>
<dt>{{ TEXT.modules.etmf.fields.scope }}</dt>
<dd>{{ scopeLabel(selectedNode.scope_type) }}</dd>
@@ -166,18 +124,44 @@
<dd>{{ selectedNode.effective_document_count }}</dd>
</div>
</dl>
<div class="node-status-note" :class="`node-status-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</div>
</template>
<div v-else class="etmf-empty-block etmf-empty-block--detail">
<div class="empty-icon">
<el-icon><CircleCheck /></el-icon>
</div>
<div class="empty-title">等待选择目录</div>
<div class="empty-desc">{{ TEXT.modules.etmf.selectNodeHint }}</div>
</div>
</aside>
</section>
<el-table
:data="documents"
v-loading="documentLoading"
class="ctms-table etmf-document-table"
table-layout="fixed"
@row-click="goDocument"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip min-width="1" />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" min-width="1">
<template #default="{ row }">
<el-tag effect="plain" type="info" size="small">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="1" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" min-width="1">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="1">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div v-if="selectedNode" class="empty-title">{{ TEXT.modules.etmf.emptyDocuments }}</div>
<div v-if="selectedNode" class="empty-desc">当前目录下还没有归档文件</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</template>
</el-table>
</main>
</section>
</div>
@@ -522,151 +506,233 @@ onMounted(load);
</script>
<style scoped>
.etmf-action-bar {
display: grid;
gap: 12px;
padding: 14px 20px;
background: #f8fafc;
border-bottom: 1px solid var(--ctms-border-light);
/* ==================== 高度链:让工作区撑满页面剩余空间 ==================== */
/* .page 本身占满路由容器给它的全部高度 */
.page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* unified-shellmain-content-flat)是直接子元素,flex:1 让它撑满 .page */
.page :deep(.main-content-flat) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ==================== 顶部单行紧凑工具栏 ==================== */
.etmf-action-bar {
padding: 8px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
flex-shrink: 0;
}
.etmf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 10px;
width: 100%;
min-width: 0;
}
.etmf-toolbar-main,
.etmf-toolbar-actions {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.etmf-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.etmf-filter-item {
width: 240px;
min-width: 180px;
width: 180px;
min-width: 140px;
}
.etmf-filter-item :deep(.el-select) {
width: 100%;
}
.etmf-status-strip {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 10px;
/* 内联状态徽章条 */
.etmf-status-inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
}
.etmf-status-card {
min-height: 64px;
display: grid;
align-content: center;
gap: 2px;
padding: 10px 14px;
border: 1px solid #e4eaf2;
.etmf-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px 5px 10px;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.07);
white-space: nowrap;
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
}
.status-card-value {
font-size: 22px;
/* 左侧彩色竖线 */
.etmf-status-badge::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 8px 0 0 8px;
background: #c8d8ef;
}
.etmf-status-badge:hover {
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
}
.status-badge-value {
font-size: 17px;
font-weight: 900;
color: #1e2a3a;
line-height: 1;
font-weight: 750;
color: #172033;
letter-spacing: -0.03em;
min-width: 1ch;
text-align: right;
}
.status-card-label {
font-size: 12px;
color: #68758a;
.status-badge-label {
font-size: 11px;
font-weight: 600;
color: #8a9ab0;
letter-spacing: 0.01em;
line-height: 1.2;
}
.etmf-status-card--missing .status-card-value {
color: #c24141;
}
/* 目录 — 蓝色 */
.etmf-status-badge--nodes::before { background: #4f7ecf; }
.etmf-status-badge--nodes .status-badge-value { color: #1e3a6e; }
.etmf-status-card--uploaded .status-card-value {
color: #b7791f;
/* 缺失 — 红色 */
.etmf-status-badge--missing {
background: linear-gradient(90deg, #fff8f8 0%, #fff 50%);
border-color: rgba(224, 82, 82, 0.2);
}
.etmf-status-badge--missing::before { background: #e05252; }
.etmf-status-badge--missing .status-badge-value { color: #c03434; }
.etmf-status-badge--missing .status-badge-label { color: #c06060; }
.etmf-status-card--effective .status-card-value {
color: #24764b;
/* 已上传 — 琥珀色 */
.etmf-status-badge--uploaded {
background: linear-gradient(90deg, #fffbf2 0%, #fff 50%);
border-color: rgba(212, 146, 10, 0.2);
}
.etmf-status-badge--uploaded::before { background: #d4920a; }
.etmf-status-badge--uploaded .status-badge-value { color: #a06b06; }
.etmf-status-badge--uploaded .status-badge-label { color: #b08030; }
/* 已生效 — 绿色 */
.etmf-status-badge--effective {
background: linear-gradient(90deg, #f4fdf8 0%, #fff 50%);
border-color: rgba(45, 172, 110, 0.2);
}
.etmf-status-badge--effective::before { background: #2dac6e; }
.etmf-status-badge--effective .status-badge-value { color: #1a7a4e; }
.etmf-status-badge--effective .status-badge-label { color: #4a9a70; }
/* 旧状态卡片已替换为内联徽章,保留空占位避免其他引用报错 */
.etmf-workspace {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px);
min-height: calc(100vh - 260px);
grid-template-columns: 280px minmax(0, 1fr);
min-height: 0;
flex: 1;
border-top: 0;
background: #fff;
overflow: hidden;
}
.etmf-tree-panel,
.etmf-document-panel,
.etmf-node-detail {
.etmf-document-panel {
min-width: 0;
padding: 16px;
min-height: 0;
padding: 12px 14px;
overflow-y: auto;
}
.etmf-tree-panel {
border-right: 1px solid var(--ctms-border-light);
background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fc 100%);
border-right: 1px solid #e4e8ef;
background: linear-gradient(180deg, #f9fbff 0%, #f4f7fd 100%);
}
.etmf-document-panel {
border-right: 1px solid var(--ctms-border-light);
background: #fff;
overflow-x: hidden;
}
.panel-heading {
min-height: 48px;
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
gap: 10px;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(79, 126, 207, 0.08);
}
.panel-heading--compact {
min-height: 38px;
margin-bottom: 10px;
margin-bottom: 8px;
}
.panel-heading-left {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
overflow: hidden;
}
.panel-title {
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.01em;
}
.panel-subtitle {
margin-top: 4px;
font-size: 12px;
font-size: 11px;
color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 6px;
}
.etmf-tree {
--el-tree-node-hover-bg-color: #eef4ff;
--el-tree-node-hover-bg-color: rgba(79, 126, 207, 0.07);
background: transparent;
padding-top: 2px;
}
.etmf-tree :deep(.el-tree-node__content) {
height: 34px;
border-radius: 7px;
margin: 2px 0;
height: 30px;
border-radius: 6px;
margin: 1px 0;
transition: background 0.15s ease;
}
.etmf-tree :deep(.is-current > .el-tree-node__content) {
background: #eaf2ff;
background: linear-gradient(90deg, rgba(79, 126, 207, 0.12) 0%, rgba(79, 126, 207, 0.04) 100%);
box-shadow: inset 3px 0 0 #4f7ecf;
}
@@ -674,16 +740,21 @@ onMounted(load);
width: 100%;
min-width: 0;
display: grid;
grid-template-columns: max-content minmax(0, 1fr) 28px max-content;
grid-template-columns: max-content minmax(0, 1fr) 22px max-content;
align-items: center;
gap: 8px;
padding-right: 8px;
gap: 6px;
padding-right: 6px;
}
.tree-node-code {
font-size: 12px;
font-weight: 700;
color: #315f9f;
font-size: 10px;
font-weight: 800;
color: #4f7ecf;
background: rgba(79, 126, 207, 0.1);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.tree-node-name {
@@ -691,159 +762,228 @@ onMounted(load);
text-overflow: ellipsis;
white-space: nowrap;
color: var(--ctms-text-main);
font-size: 12px;
}
.tree-node-count {
height: 20px;
min-width: 22px;
height: 18px;
min-width: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #edf2f7;
background: linear-gradient(135deg, #e8edf5, #dce4f0);
color: #526174;
font-size: 12px;
font-weight: 650;
font-size: 10px;
font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
}
.document-panel-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-wrap: nowrap;
justify-content: flex-end;
gap: 8px;
font-size: 12px;
gap: 6px;
font-size: 11px;
font-weight: 500;
color: #69778d;
flex-shrink: 0;
}
.document-status-summary {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
padding: 8px 12px;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 8px;
background: linear-gradient(135deg, #f9fbff 0%, #f3f7fd 100%);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.status-summary-head {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-summary-label {
color: var(--ctms-text-main);
font-size: 12px;
font-weight: 700;
}
.status-summary-note {
font-size: 11px;
color: var(--ctms-text-secondary);
line-height: 1.4;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-summary-note--effective { color: #1a5c3a; }
.status-summary-note--missing { color: #7a2020; }
.status-summary-note--uploaded { color: #7a4e0a; }
.status-summary-list {
display: flex;
gap: 6px;
margin: 0;
flex-shrink: 0;
}
.status-summary-list div {
display: flex;
align-items: baseline;
gap: 3px;
min-width: 0;
padding: 3px 8px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(79, 126, 207, 0.1);
}
.status-summary-list dt {
color: var(--ctms-text-secondary);
font-size: 10px;
font-weight: 600;
order: 2;
}
.status-summary-list dd {
min-width: 0;
margin: 0;
color: var(--ctms-text-main);
font-size: 13px;
font-weight: 800;
order: 1;
}
.etmf-document-table {
width: 100%;
}
.node-meta {
display: grid;
gap: 12px;
margin: 16px 0;
/* 文档表格行悬停效果 */
.etmf-document-table :deep(.el-table__body tr:hover > td) {
background: rgba(79, 126, 207, 0.04) !important;
cursor: pointer;
}
.node-meta div {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 10px;
/* 表格头部紧凑化 */
.etmf-document-table :deep(th.el-table__cell) {
padding: 8px 12px;
font-size: 11px;
font-weight: 700;
color: #526174;
background: #f8fafc;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.node-meta dt {
color: var(--ctms-text-secondary);
}
.node-meta dd {
min-width: 0;
margin: 0;
font-weight: 600;
color: var(--ctms-text-main);
overflow-wrap: anywhere;
}
.node-status-note {
border-left: 3px solid #8aa0bd;
background: #f6f8fb;
padding: 10px 12px;
.etmf-document-table :deep(td.el-table__cell) {
padding: 8px 12px;
font-size: 13px;
line-height: 1.5;
color: var(--ctms-text-main);
}
.node-status-note--effective {
border-left-color: #2f8f5b;
/* 隐藏表格底部分隔线(el-table inner-wrapper 的 ::before 伪元素) */
.etmf-document-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.node-status-note--missing {
border-left-color: #c84646;
/* 隐藏横向滚动条 */
.etmf-document-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.node-status-note--uploaded {
border-left-color: #c58b20;
.etmf-document-table :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.etmf-empty-block {
min-height: 168px;
min-height: 120px;
display: grid;
place-items: center;
align-content: center;
gap: 8px;
padding: 22px;
padding: 20px;
text-align: center;
color: #7b8797;
}
.etmf-empty-block--tree {
min-height: 280px;
border: 1px dashed #d8e0eb;
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
min-height: 200px;
border-radius: 10px;
background: linear-gradient(135deg, #f4f7fd 0%, #eef3fb 100%);
}
.etmf-empty-block--documents {
min-height: 420px;
}
.etmf-empty-block--detail {
min-height: 280px;
min-height: 300px;
}
.empty-icon {
width: 44px;
height: 44px;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #eef3f9;
color: #6b7c91;
font-size: 22px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
font-size: 18px;
box-shadow: 0 3px 8px rgba(79, 126, 207, 0.15);
}
.empty-title {
font-size: 15px;
font-size: 13px;
font-weight: 700;
color: #2f3a4c;
color: #2a3550;
letter-spacing: -0.01em;
}
.empty-desc {
max-width: 320px;
font-size: 13px;
line-height: 1.55;
max-width: 280px;
font-size: 12px;
line-height: 1.5;
color: #7b8797;
}
@media (max-width: 1180px) {
@media (max-width: 900px) {
.etmf-toolbar {
align-items: stretch;
flex-direction: column;
flex-wrap: wrap;
}
.etmf-toolbar-actions {
justify-content: flex-start;
.etmf-status-inline {
display: none;
}
.etmf-workspace {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
grid-template-columns: 240px minmax(0, 1fr);
}
.etmf-node-detail {
grid-column: 1 / -1;
border-top: 1px solid var(--ctms-border-light);
.status-summary-list {
display: none;
}
.document-status-summary {
flex-wrap: wrap;
}
}
@media (max-width: 760px) {
.etmf-status-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
@media (max-width: 700px) {
.etmf-toolbar {
flex-direction: column;
align-items: stretch;
}
.etmf-toolbar-main {
flex-direction: column;
align-items: stretch;
flex-wrap: wrap;
}
.etmf-filter-item {
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
describe("MaterialEquipment desktop list workflow", () => {
it("keeps desktop equipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="equipment-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="equipment-preview-pane"');
expect(source).toContain('ref="equipmentTablePaneRef"');
expect(source).toContain("selectedEquipment");
expect(source).toContain('@row-click="handleEquipmentRowClick"');
expect(source).toContain('@row-dblclick="openEquipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedEquipment"');
expect(source).toContain("selectEquipment(row);");
expect(source).toContain("equipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain('router.push({ name: "MaterialEquipmentDetail"');
expect(source).toContain('"row-selected"');
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop equipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openEquipmentContextMenu"');
expect(source).toContain("equipmentContextMenu");
expect(source).toContain('class="equipment-context-menu"');
expect(source).toContain('@click="openSelectedEquipment"');
expect(source).toContain('@click="openSelectedEquipmentEditor"');
expect(source).toContain('@click="removeSelectedEquipment"');
expect(source).toContain('@click="copySelectedEquipmentName"');
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
});
});
+474 -80
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'equipment-page--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card">
@@ -24,59 +24,118 @@
</el-button>
</div>
</div>
<el-table
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="onRowClick"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
<div class="equipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="equipmentTablePaneRef"
class="equipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedEquipment"
>
<el-table
v-loading="loading"
:data="rows"
class="equipment-table"
style="width: 100%"
table-layout="fixed"
:row-class-name="equipmentRowClass"
@row-click="handleEquipmentRowClick"
@row-dblclick="openEquipmentDetail"
@row-contextmenu="openEquipmentContextMenu"
>
<el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ row.name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="specModel" label="规格型号" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.specModel || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="unit" label="单位" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.unit || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="brand" label="品牌" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.brand || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column label="是否需要校准" show-overflow-tooltip>
<template #default="{ row }">
<span :class="['status-pill', row.needCalibration ? 'status-pill--yes' : 'status-pill--no']">
{{ row.needCalibration ? "是" : "否" }}
</span>
</template>
</el-table-column>
<el-table-column v-if="!isDesktop && (canUpdate || canDelete)" label="操作" width="130" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
<el-button v-if="canDelete" link type="danger" size="small" @click.stop="removeRow(row)">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>暂无设备数据</span>
</div>
</template>
</el-table>
</div>
<aside v-if="isDesktop" class="equipment-preview-pane">
<template v-if="selectedEquipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前设备</div>
<div class="preview-title">{{ selectedEquipment.name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedEquipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>规格型号</dt>
<dd>{{ selectedEquipment.specModel || TEXT.common.fallback }}</dd>
<dt>单位</dt>
<dd>{{ selectedEquipment.unit || TEXT.common.fallback }}</dd>
<dt>品牌</dt>
<dd>{{ selectedEquipment.brand || TEXT.common.fallback }}</dd>
<dt>产地</dt>
<dd>{{ selectedEquipment.origin || TEXT.common.fallback }}</dd>
<dt>校准</dt>
<dd>{{ selectedEquipment.needCalibration ? "需要校准" : "无需校准" }}</dd>
<dt>周期</dt>
<dd>{{ selectedEquipment.needCalibration ? `${selectedEquipment.calibrationCycleDays || TEXT.common.fallback}` : TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button v-if="canUpdate" size="small" @click="openSelectedEquipmentEditor">编辑</el-button>
<el-button v-if="canDelete" size="small" type="danger" plain @click="removeSelectedEquipment">删除</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div v-else class="preview-empty">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
</div>
<span>暂无设备数据</span>
<span>选择一行查看详情摘要</span>
</div>
</template>
</el-table>
</aside>
</div>
</div>
<div
v-if="isDesktop && equipmentContextMenu.visible && selectedEquipment"
class="equipment-context-menu"
:style="{ left: `${equipmentContextMenu.x}px`, top: `${equipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedEquipment">打开详情</button>
<button v-if="canUpdate" type="button" @click="openSelectedEquipmentEditor">编辑</button>
<button type="button" @click="copySelectedEquipmentName">复制设备名称</button>
<button v-if="canDelete" type="button" class="danger" @click="removeSelectedEquipment">删除</button>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -174,7 +233,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
@@ -192,6 +251,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
interface EquipmentRow {
id: string;
@@ -209,12 +269,16 @@ type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const isDesktop = isTauriRuntime();
const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]);
const loading = ref(false);
const saving = ref(false);
const drawerVisible = ref(false);
const editingId = ref("");
const selectedEquipmentId = ref("");
const equipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const equipmentTablePaneRef = ref<HTMLElement | null>(null);
const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
@@ -239,6 +303,7 @@ const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.c
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
const selectedEquipment = computed(() => rows.value.find((item) => item.id === selectedEquipmentId.value) || null);
const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -326,11 +391,76 @@ const openEdit = (row: EquipmentRow) => {
drawerVisible.value = true;
};
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : "");
const equipmentRowClass = ({ row }: { row: EquipmentRow }) =>
[
row?.id ? "clickable-row" : "",
isDesktop && row?.id === selectedEquipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const onRowClick = (row: EquipmentRow) => {
const selectEquipment = (row: EquipmentRow) => {
if (!row?.id) return;
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } });
closeEquipmentContextMenu();
selectedEquipmentId.value = row.id;
equipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleEquipmentRowClick = (row: EquipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectEquipment(row);
return;
}
openEquipmentDetail(row);
};
const openEquipmentDetail = (row?: EquipmentRow | null) => {
const target = row?.id ? row : selectedEquipment.value;
if (target?.id) router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: target.id } });
};
const openSelectedEquipment = () => {
closeEquipmentContextMenu();
openEquipmentDetail(selectedEquipment.value);
};
const openSelectedEquipmentEditor = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedEquipment = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) {
void removeRow(target);
}
};
const openEquipmentContextMenu = (row: EquipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectEquipment(row);
equipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 172)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 142)),
};
};
const closeEquipmentContextMenu = () => {
if (!equipmentContextMenu.value.visible) return;
equipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedEquipmentName = async () => {
const name = selectedEquipment.value?.name;
closeEquipmentContextMenu();
if (!name || !navigator.clipboard) return;
await navigator.clipboard.writeText(name);
ElMessage.success("设备名称已复制");
};
const saveForm = async () => {
@@ -405,10 +535,25 @@ watch(
filters.name = "";
loadRows();
drawerVisible.value = false;
selectedEquipmentId.value = "";
},
{ immediate: true }
);
watch(
() => rows.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
if (!rows.value.length) {
selectedEquipmentId.value = "";
return;
}
if (!rows.value.some((item) => item.id === selectedEquipmentId.value)) {
selectedEquipmentId.value = rows.value[0].id;
}
},
);
watch(
() => form.needCalibration,
(need) => {
@@ -416,6 +561,14 @@ watch(
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
}
);
onMounted(() => {
if (isDesktop) document.addEventListener("click", closeEquipmentContextMenu);
});
onBeforeUnmount(() => {
if (isDesktop) document.removeEventListener("click", closeEquipmentContextMenu);
});
</script>
<style scoped>
@@ -427,20 +580,35 @@ watch(
background: transparent;
}
/* 桌面端:撑满父容器高度 */
.equipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 桌面端:page-inner 撑满 .page */
.equipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Table Toolbar ==================== */
.table-card-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 20px;
border-bottom: 1px solid #f0f0f0;
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
gap: 12px;
background: linear-gradient(135deg, #f8faff 0%, #f2f6ff 100%);
}
.toolbar-filters {
@@ -466,16 +634,30 @@ watch(
.filter-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
font-weight: 700;
color: #7a8ca8;
text-transform: uppercase;
letter-spacing: 0.03em;
letter-spacing: 0.04em;
}
.filter-input {
width: 200px;
}
.filter-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(79, 126, 207, 0.2);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition: border-color 0.2s, box-shadow 0.2s;
}
.filter-input :deep(.el-input__wrapper:hover),
.filter-input :deep(.el-input__wrapper.is-focus) {
border-color: rgba(79, 126, 207, 0.5);
box-shadow: 0 0 0 3px rgba(79, 126, 207, 0.1);
}
.filter-actions {
display: flex;
gap: 8px;
@@ -484,21 +666,35 @@ watch(
}
.filter-btn {
border-radius: 6px;
border-radius: 8px;
}
.filter-summary {
display: inline-flex;
align-items: center;
padding: 2px 10px;
background: rgba(79, 126, 207, 0.08);
border: 1px solid rgba(79, 126, 207, 0.15);
border-radius: 20px;
font-size: 12px;
font-weight: 500;
color: #a3a3a3;
font-weight: 700;
color: #4f7ecf;
white-space: nowrap;
letter-spacing: 0.01em;
}
.create-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
padding: 0 18px;
font-weight: 600;
box-shadow: 0 2px 6px rgba(64, 128, 220, 0.25);
transition: box-shadow 0.2s, transform 0.15s;
}
.create-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 128, 220, 0.35);
}
/* ==================== Table Card ==================== */
@@ -511,6 +707,58 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02);
}
/* 桌面端:table-card 填满、无圆角 */
.equipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.equipment-workbench {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.equipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 304px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.equipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.equipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.equipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.equipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 14px;
padding: 16px;
background: linear-gradient(180deg, #f5f8ff 0%, #eef3fb 100%);
border-left: 1px solid rgba(79, 126, 207, 0.1);
}
.equipment-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
@@ -543,6 +791,10 @@ watch(
transition: background 0.1s ease;
}
.equipment-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */
.cell-nowrap {
white-space: nowrap;
@@ -573,21 +825,151 @@ watch(
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 12px;
font-size: 12px;
padding: 2px 10px;
font-size: 11px;
font-weight: 700;
border-radius: 20px;
line-height: 1.6;
line-height: 1.7;
letter-spacing: 0.02em;
}
.status-pill--yes {
background: #dcfce7;
color: #16a34a;
background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #065f46;
border: 1px solid rgba(16, 185, 129, 0.2);
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.15);
}
.status-pill--no {
background: #f5f5f5;
color: #737373;
background: #f1f5f9;
color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.15);
}
/* ==================== Desktop Preview ==================== */
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
}
.preview-kicker {
display: inline-flex;
align-items: center;
gap: 5px;
color: #4f7ecf;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(79, 126, 207, 0.1);
padding: 2px 8px;
border-radius: 20px;
}
.preview-title {
margin-top: 8px;
color: #0f172a;
font-size: 17px;
font-weight: 800;
word-break: break-word;
letter-spacing: -0.01em;
line-height: 1.3;
}
.preview-list {
display: block;
margin: 0;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 10px;
overflow: hidden;
background: #fff;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);
}
/* dt/dd float 布局:dt 左浮动作为标签列,dd 跟随在右侧 */
.preview-list dt {
float: left;
clear: left;
width: 64px;
padding: 9px 6px 9px 14px;
color: #7a8ca8;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.5;
}
.preview-list dd {
margin-left: 0;
padding: 9px 14px 9px 78px;
border-bottom: 1px solid rgba(79, 126, 207, 0.07);
color: #1a2540;
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
min-width: 0;
}
.preview-list dd:last-child {
border-bottom: none;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding-top: 4px;
}
.preview-empty {
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.equipment-context-menu {
position: fixed;
z-index: 3000;
min-width: 152px;
padding: 5px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.equipment-context-menu button {
display: block;
width: 100%;
min-height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.equipment-context-menu button:hover:not(:disabled) {
background: #eef4ff;
}
.equipment-context-menu button.danger {
color: #dc2626;
}
/* ==================== Empty State ==================== */
@@ -597,30 +979,33 @@ watch(
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 12px;
}
.empty-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f5f5;
color: #c4c4c4;
width: 52px;
height: 52px;
border-radius: 14px;
background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #4f7ecf;
box-shadow: 0 4px 12px rgba(79, 126, 207, 0.15);
}
.table-empty span {
font-size: 13px;
font-weight: 500;
color: #a3a3a3;
font-weight: 600;
color: #8aa0bd;
}
/* ==================== Drawer Editor ==================== */
:deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0;
padding: 20px 24px 8px;
padding: 20px 24px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
}
:deep(.equipment-editor-drawer > .el-drawer__body) {
@@ -634,10 +1019,10 @@ watch(
.editor-title {
font-size: 18px;
font-weight: 700;
font-weight: 800;
color: #0a0a0a;
line-height: 1.2;
letter-spacing: -0.01em;
letter-spacing: -0.02em;
}
.equipment-form {
@@ -746,6 +1131,15 @@ watch(
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.equipment-workbench.is-desktop {
grid-template-columns: 1fr;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 0;
}
.equipment-preview-pane {
display: none;
}
.field-grid--2,
.field-grid--3 {
grid-template-columns: 1fr;
@@ -462,6 +462,32 @@ watch(
.page {
display: flex;
flex-direction: column;
/* 撑满父容器(desktop-route-shell 已经是 height: 100%*/
height: 100%;
min-height: 0;
}
/* 让白色卡片壳撑满 .page 的剩余高度,并去掉圆角 */
.page > .unified-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
}
/* 让表格 section 撑满卡片壳的剩余高度 */
.page > .unified-shell > .unified-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* el-table 本身填满 section */
.page > .unified-shell > .unified-section :deep(.el-table),
.page > .unified-shell > .unified-section :deep(.el-table__inner-wrapper) {
height: 100%;
}
.ctms-page-content-grid {
@@ -689,4 +715,5 @@ watch(
font-weight: 500;
letter-spacing: 0.02em;
}
</style>
+640 -58
View File
@@ -1,72 +1,144 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="page ctms-page-shell page--flush" :class="{ 'project-overview--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-body">
<div class="overview-container">
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
<section v-if="isDesktop" class="desktop-attention-section">
<div class="desktop-attention-board">
<div class="attention-card attention-card--stages">
<div class="attention-title">阶段状态</div>
<div class="stage-status-grid">
<div v-for="item in stageStatusSummary" :key="item.key" class="stage-status-item">
<span class="legend-dot" :class="item.dotClass"></span>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</div>
</div>
</div>
</div>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div class="attention-card">
<div class="attention-title">当前推进</div>
<div v-if="activeStageItems.length" class="attention-list">
<div v-for="item in activeStageItems" :key="item.key" class="attention-list-row">
<strong>{{ item.centerName }}</strong>
<span>{{ item.stageLabel }}</span>
</div>
</div>
<div v-else class="desktop-empty-note">暂无进行中阶段</div>
</div>
<div class="attention-card">
<div class="attention-title">需关注</div>
<div class="attention-list">
<div v-for="item in attentionItems" :key="item" class="attention-list-row attention-list-row--plain">
<span>{{ item }}</span>
</div>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
<div class="overview-workbench">
<section class="overview-card overview-card--progress">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--progress">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</span>
<div class="card-title">中心整体进度</div>
</div>
<div class="card-header-right">
<el-button size="small" @click="loadOverview" class="refresh-btn">
<template #icon>
<el-icon><Refresh /></el-icon>
</template>
刷新
</el-button>
<span v-if="isDesktop" class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span>
<span class="legend-item"><span class="legend-dot pending"></span>未开始</span>
<span class="legend-item"><span class="legend-dot blocked"></span>阻塞/延期</span>
</div>
</div>
</div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
<EnrollmentBarChart
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
<StateLoading v-if="loading" :rows="6" />
<div v-else-if="centers.length === 0" class="overview-empty-panel">
<div class="overview-empty-content">
<div class="overview-empty-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</div>
<div class="overview-empty-title">中心整体进度暂未生成</div>
<div class="overview-empty-desc">当前项目未配置中心或尚未形成可展示的中心进度数据</div>
</div>
</div>
<div v-else class="progress-list">
<CenterProgressRow
v-for="(center, index) in centers"
:key="center.center_id || center.center_name || index"
:center="center"
/>
</div>
</section>
<section class="overview-card overview-card--enrollment">
<div class="card-header">
<div class="card-header-left">
<span class="card-icon card-icon--enrollment">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<div>
<div class="card-title">入组进度</div>
<div class="card-subtitle">{{ enrollmentSummary }}</div>
</div>
</div>
<el-radio-group v-if="!isDesktop" v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button>
</el-radio-group>
</div>
<StateLoading v-if="isDesktop && loading" :rows="4" />
<div v-else-if="isDesktop" class="enrollment-snapshot">
<div class="enrollment-meter">
<div class="meter-head">
<span>达成率</span>
<strong>{{ enrollmentCompletionLabel }}</strong>
</div>
<div class="meter-track">
<span class="meter-fill" :style="{ width: enrollmentCompletionWidth }"></span>
</div>
<div class="meter-foot">
<span>已入组 {{ overview?.summary.total_actual || 0 }}</span>
<span>目标 {{ overview?.summary.total_target || 0 }}</span>
</div>
</div>
<div class="enrollment-center-list">
<div v-for="row in enrollmentCenterRows" :key="row.key" class="enrollment-center-row">
<div class="enrollment-center-meta">
<span>{{ row.label }}</span>
<strong>{{ row.actual }} / {{ row.target }}</strong>
</div>
<div class="mini-progress-track">
<span class="mini-progress-fill" :style="{ width: row.percentWidth }"></span>
</div>
</div>
<div v-if="enrollmentCenterRows.length === 0" class="desktop-empty-note">
暂无中心入组数据
</div>
</div>
</div>
<EnrollmentBarChart
v-else
:mode="chartMode"
:items="chartItems"
:loading="loading"
:empty-text="chartEmptyText"
/>
</section>
</div>
</div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -80,18 +152,127 @@ import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites";
import { isTauriRuntime } from "../../runtime";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
import { STAGE_ORDER, adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel, type StageStatus } from "./project-overview/overview.adapter";
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center");
const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return 0;
return Math.min(100, Math.round((summary.total_actual / summary.total_target) * 100));
});
const enrollmentCompletionLabel = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return "未设目标";
return `${enrollmentCompletionRate.value}%`;
});
const enrollmentCompletionWidth = computed(() => `${enrollmentCompletionRate.value}%`);
const overviewUpdatedAtLabel = computed(() => {
if (!overview.value?.updated_at) return "-";
const date = new Date(overview.value.updated_at);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
};
const statusDotClassMap: Record<StageStatus, string> = {
COMPLETED: "completed",
IN_PROGRESS: "active",
NOT_STARTED: "pending",
BLOCKED: "blocked",
};
const stageStatusCounts = computed<Record<StageStatus, number>>(() => {
const counts: Record<StageStatus, number> = {
COMPLETED: 0,
IN_PROGRESS: 0,
NOT_STARTED: 0,
BLOCKED: 0,
};
centers.value.forEach((center) => {
STAGE_ORDER.forEach((stage) => {
counts[center[stage.key] || "NOT_STARTED"] += 1;
});
});
return counts;
});
const stageStatusSummary = computed(() =>
(["COMPLETED", "IN_PROGRESS", "NOT_STARTED", "BLOCKED"] as StageStatus[]).map((status) => ({
key: status,
label: statusLabelMap[status],
count: stageStatusCounts.value[status],
dotClass: statusDotClassMap[status],
}))
);
const activeStageItems = computed(() =>
centers.value
.flatMap((center, centerIndex) =>
STAGE_ORDER
.filter((stage) => center[stage.key] === "IN_PROGRESS")
.map((stage) => ({
key: `${center.center_id || centerIndex}-${stage.key}`,
centerName: center.center_name || TEXT.common.fallback,
stageLabel: stage.label,
}))
)
.slice(0, 4)
);
const attentionItems = computed(() => {
const items: string[] = [];
const targetlessCenters = centers.value.filter((center) => !center.enrollment_target).length;
const inactiveCenters = centers.value.filter((center) => center.is_active === false).length;
if (stageStatusCounts.value.BLOCKED > 0) {
items.push(`${stageStatusCounts.value.BLOCKED} 个阶段阻塞/延期`);
}
if (targetlessCenters > 0) {
items.push(`${targetlessCenters} 个中心未设置入组目标`);
}
if (overview.value && overview.value.months.length === 0) {
items.push("暂无月度入组趋势数据");
}
if (inactiveCenters > 0) {
items.push(`${inactiveCenters} 个中心已停用`);
}
if (items.length === 0) {
items.push("暂无需要额外关注的总览风险");
}
return items.slice(0, 4);
});
const enrollmentCenterRows = computed(() =>
centers.value.slice(0, 4).map((center, index) => {
const target = center.enrollment_target || 0;
const actual = center.enrollment_actual || 0;
const percent = target > 0 ? Math.min(100, Math.round((actual / target) * 100)) : 0;
return {
key: center.center_id || center.center_name || `center-${index}`,
label: center.center_name || TEXT.common.fallback,
actual,
target,
percentWidth: `${percent}%`,
};
})
);
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
siteList.map((site: any) => ({
@@ -218,6 +399,12 @@ watch(
gap: 10px;
}
.overview-workbench {
display: flex;
flex-direction: column;
gap: 10px;
}
.card-header-right {
display: flex;
align-items: center;
@@ -230,6 +417,13 @@ watch(
border-radius: 8px;
}
.overview-updated-at {
color: var(--ctms-text-secondary);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.overview-card {
background: var(--ctms-bg-card);
border: 1px solid var(--ctms-border-color);
@@ -389,6 +583,385 @@ watch(
border-radius: 8px;
}
.project-overview--desktop {
min-height: 0;
}
.project-overview--desktop .page-body {
min-height: 0;
gap: 8px;
}
.project-overview--desktop .overview-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
gap: 10px;
}
.project-overview--desktop .overview-workbench {
display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);
align-items: stretch;
overflow: hidden;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.project-overview--desktop .overview-card {
min-width: 0;
border: 0;
border-radius: 0;
padding: 12px 14px;
background: transparent;
box-shadow: none;
}
.project-overview--desktop .overview-card:hover {
box-shadow: none;
}
.project-overview--desktop .overview-card--enrollment {
border-left: 1px solid #d9e2ec;
}
.project-overview--desktop .card-header {
margin-bottom: 8px;
}
.project-overview--desktop .card-header-left {
gap: 8px;
}
.project-overview--desktop .card-icon {
width: 24px;
height: 24px;
border-radius: 5px;
}
.project-overview--desktop .card-title {
font-size: 13px;
}
.project-overview--desktop .card-subtitle,
.project-overview--desktop .progress-legend {
font-size: 11px;
}
.project-overview--desktop .progress-legend {
gap: 8px;
}
.project-overview--desktop .legend-dot {
width: 8px;
height: 8px;
}
.enrollment-snapshot {
display: flex;
flex-direction: column;
gap: 14px;
}
.enrollment-meter {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 6px;
background: #f8fafc;
}
.meter-head,
.meter-foot,
.enrollment-center-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.meter-head span,
.meter-foot,
.enrollment-center-meta span {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.meter-head strong {
color: #15344f;
font-size: 18px;
line-height: 1;
}
.meter-track,
.mini-progress-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: #e8eef5;
}
.meter-fill,
.mini-progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: #3f5d75;
}
.enrollment-center-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.enrollment-center-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.enrollment-center-meta strong {
color: #1f2f45;
font-size: 12px;
}
.project-overview--desktop .progress-list {
max-height: min(360px, calc(100vh - 300px));
overflow: auto;
padding-right: 2px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.project-overview--desktop .progress-list::-webkit-scrollbar {
width: 8px;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-track {
background: transparent;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-thumb {
border: 2px solid #ffffff;
border-radius: 999px;
background: #c8d4e1;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: minmax(126px, 156px) minmax(0, 1fr);
gap: 8px;
padding: 6px;
border-radius: 5px;
}
.project-overview--desktop :deep(.center-name) {
font-size: 13px;
}
.project-overview--desktop :deep(.center-enrollment) {
font-size: 11px;
}
.project-overview--desktop :deep(.center-timeline) {
padding: 4px 0 0;
}
.project-overview--desktop :deep(.timeline-segment) {
gap: 6px;
}
.project-overview--desktop :deep(.stage-node) {
min-width: 52px;
gap: 4px;
}
.project-overview--desktop :deep(.stage-dot) {
width: 13px;
height: 13px;
}
.project-overview--desktop :deep(.stage-in_progress .stage-dot) {
width: 16px;
height: 16px;
}
.project-overview--desktop :deep(.stage-label) {
max-width: 62px;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
.project-overview--desktop :deep(.stage-in_progress .stage-label) {
padding: 1px 6px;
}
.project-overview--desktop :deep(.stage-connector) {
min-width: 30px;
}
.project-overview--desktop :deep(.chart-scroll) {
overflow-x: auto;
overflow-y: hidden;
}
.project-overview--desktop :deep(.chart-plot) {
border-radius: 6px;
background: #f8fafc;
}
.project-overview--desktop :deep(.chart-empty-shell) {
min-height: 112px;
border-radius: 6px;
padding: 14px;
}
.project-overview--desktop .overview-empty-panel {
min-height: 104px;
padding: 12px;
border-radius: 6px;
}
.desktop-attention-board {
display: grid;
grid-template-columns: minmax(220px, 0.75fr) minmax(260px, 1fr) minmax(260px, 1fr);
gap: 10px;
}
.desktop-attention-section {
display: flex;
flex-direction: column;
}
.attention-card {
min-width: 0;
min-height: 126px;
padding: 12px 14px;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.attention-title {
margin-bottom: 10px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.stage-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.stage-status-item,
.attention-list-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 8px;
border-radius: 6px;
background: #f8fafc;
}
.stage-status-item span:not(.legend-dot),
.attention-list-row span {
min-width: 0;
overflow: hidden;
color: #53677f;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-status-item strong {
margin-left: auto;
color: #15253a;
font-size: 13px;
}
.attention-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attention-list-row strong {
min-width: 0;
overflow: hidden;
color: #15253a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.attention-list-row--plain {
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
}
.attention-list-row--plain span {
white-space: normal;
}
.desktop-empty-note {
min-height: 32px;
display: flex;
align-items: center;
color: #7b8da3;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-workbench),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-card) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-card--enrollment) {
border-left-color: #26364a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-meter),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) {
background: #111a2a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-title),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-center-meta strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .meter-head strong) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .progress-list::-webkit-scrollbar-thumb) {
border-color: #172033;
background: #3b4b60;
}
@media (max-width: 1240px) {
.project-overview--desktop .overview-workbench,
.desktop-attention-board {
grid-template-columns: 1fr;
}
.project-overview--desktop .overview-card--enrollment {
border-top: 1px solid #d9e2ec;
border-left: 0;
}
}
@media (max-width: 768px) {
.overview-container {
padding: 8px;
@@ -406,5 +979,14 @@ watch(
.card-header-right {
justify-content: flex-start;
}
.project-overview--desktop .overview-container {
grid-template-columns: 1fr;
padding-top: 8px;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: 1fr;
}
}
</style>
@@ -978,7 +978,14 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
await saveFileWithFeedback(
{ suggestedName: filename, mimeType: contentType, data: blob },
{
kind: "export",
title: "导出文件",
completedDetail: "导出文件已保存",
},
);
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -5,15 +5,17 @@ import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
describe("SubjectManagement project permissions", () => {
it("hides create and delete actions when the current role lacks backend operation permissions", () => {
it("hides create, edit, and delete actions when the current role lacks backend operation permissions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:update")');
expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canUpdateSubject"');
expect(source).toContain('v-if="canDeleteSubject"');
});
});
@@ -24,6 +26,8 @@ describe("SubjectManagement drawer editor", () => {
expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible");
expect(source).toContain("editingSubjectId");
expect(source).toContain(':subject-id="editingSubjectId || undefined"');
expect(source).not.toContain('router.push("/subjects/new")');
});
});
@@ -32,10 +36,49 @@ describe("SubjectManagement desktop list workflow", () => {
it("selects rows for preview and opens details on explicit desktop actions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("@row-click=\"selectSubject\"");
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="subject-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="subject-preview-pane"');
expect(source).toContain('ref="subjectTablePaneRef"');
expect(source).toContain("selectedSubject");
expect(source).toContain("@row-click=\"handleSubjectRowClick\"");
expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
expect(source).toContain("@keydown.enter.prevent=\"openSelectedSubject\"");
expect(source).toContain("selectSubject(row);");
expect(source).toContain("subjectTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain("subject-preview-pane");
expect(source).toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedSubjectEditor\"");
expect(source).toContain("@click=\"removeSelectedSubject\"");
expect(source).not.toContain('<el-button size="small" @click="copySelectedSubjectNo">复制编号</el-button>');
expect(source).toContain("subject-context-menu");
expect(source).toContain('v-if="!isDesktop && canDeleteSubject"');
expect(source).toContain('"row-selected"');
});
it("does not render unused table selection controls", () => {
const source = readSubjectManagementSource();
expect(source).not.toContain('type="selection"');
expect(source).not.toContain("@selection-change");
expect(source).not.toContain("selectedRows");
expect(source).not.toContain("selection-count");
});
it("keeps desktop footer controls at the bottom of the workbench", () => {
const source = readSubjectManagementSource();
expect(source).toContain("subject-page--desktop");
expect(source).toContain(".subject-page--desktop .table-card");
expect(source).toContain(".subject-workbench.is-desktop");
expect(source).toContain(".subject-table-pane");
expect(source).toContain(".pagination-wrap");
expect(source).toContain(".preview-actions");
expect(source.match(/margin-top: auto;/g)?.length).toBeGreaterThanOrEqual(2);
});
});
+132 -38
View File
@@ -1,5 +1,5 @@
<template>
<div class="page">
<div class="page" :class="{ 'subject-page--desktop': isDesktop }">
<div class="table-card">
<div class="table-card-toolbar">
<div class="toolbar-filters">
@@ -24,7 +24,6 @@
</div>
</div>
<div class="toolbar-right">
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} </span>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -32,8 +31,13 @@
</div>
</div>
<div class="subject-workbench">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
<div class="subject-workbench" :class="{ 'is-desktop': isDesktop }">
<div
class="subject-table-pane"
ref="subjectTablePaneRef"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedSubject"
>
<el-table
:data="pagedItems"
v-loading="loading"
@@ -41,13 +45,11 @@
class="subject-table"
:row-class-name="subjectRowClass"
highlight-current-row
@selection-change="onSelectionChange"
@row-click="selectSubject"
@row-click="handleSubjectRowClick"
@row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu"
table-layout="fixed"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
@@ -73,7 +75,7 @@
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<el-table-column v-if="!isDesktop && canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
@@ -104,7 +106,7 @@
</div>
</div>
<aside class="subject-preview-pane">
<aside v-if="isDesktop" class="subject-preview-pane">
<template v-if="selectedSubject">
<div class="preview-head">
<div>
@@ -130,6 +132,26 @@
{{ badge.label }}
</span>
</div>
<div class="preview-actions">
<el-button
v-if="canUpdateSubject"
size="small"
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="openSelectedSubjectEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteSubject"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="removeSelectedSubject"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<div class="empty-icon">
@@ -142,7 +164,7 @@
</div>
<div
v-if="contextMenu.visible"
v-if="isDesktop && contextMenu.visible && selectedSubject"
class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
@@ -154,13 +176,17 @@
type="button"
class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)"
@click="removeSelectedSubject"
>
删除
</button>
</div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
<SubjectEditorDrawer
v-model="subjectDrawerVisible"
:subject-id="editingSubjectId || undefined"
@success="handleSubjectEditorSuccess"
/>
</div>
</template>
@@ -179,15 +205,18 @@ import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const editingSubjectId = ref("");
const subjectTablePaneRef = ref<HTMLElement | null>(null);
const items = ref<any[]>([]);
const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
@@ -215,6 +244,7 @@ const canUseApiPermission = (operationKey: string) => {
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
};
const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => {
const studyId = study.currentStudy?.id;
@@ -251,14 +281,26 @@ const load = async () => {
}
};
const goNew = () => { subjectDrawerVisible.value = true; };
const goNew = () => {
editingSubjectId.value = "";
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { load(); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => {
contextMenu.value.visible = false;
closeSubjectContextMenu();
if (!row?.id) return;
selectedSubjectId.value = row.id;
subjectTablePaneRef.value?.focus({ preventScroll: true });
};
const handleSubjectRowClick = (row: any) => {
if (!row?.id) return;
if (isDesktop) {
selectSubject(row);
return;
}
goDetail(row.id);
};
const openSubjectDetail = (row: any) => {
if (!row?.id) return;
@@ -268,21 +310,19 @@ const openSelectedSubject = () => {
contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
};
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
if (!row?.id) return;
selectedSubjectId.value = row.id;
selectSubject(row);
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 152)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 122)),
};
};
const closeSubjectContextMenu = () => {
contextMenu.value.visible = false;
if (!contextMenu.value.visible) return;
contextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false;
@@ -291,6 +331,28 @@ const copySelectedSubjectNo = async () => {
await navigator.clipboard?.writeText(subjectNo);
ElMessage.success("编号已复制");
};
const openSelectedSubjectEditor = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (!target) return;
if (!canUpdateSubject.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(target.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingSubjectId.value = target.id;
subjectDrawerVisible.value = true;
};
const removeSelectedSubject = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (target) {
void remove(target);
}
};
const remove = async (row: any) => {
contextMenu.value.visible = false;
const studyId = study.currentStudy?.id;
@@ -349,6 +411,7 @@ watch(() => filteredItems.value.length, (total) => {
});
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => {
if (!isDesktop) return;
if (!rows.length) {
selectedSubjectId.value = "";
return;
@@ -359,7 +422,7 @@ watch(pagedItems, (rows) => {
});
onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites();
load();
@@ -369,7 +432,7 @@ onMounted(async () => {
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu);
if (isDesktop) document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.();
});
</script>
@@ -383,25 +446,53 @@ onBeforeUnmount(() => {
background: #ffffff;
}
.subject-page--desktop {
height: 100%;
min-height: 0;
}
.table-card {
background: #ffffff;
overflow: hidden;
}
.subject-page--desktop .table-card {
flex: 1;
display: flex;
min-height: 0;
flex-direction: column;
}
.subject-workbench {
min-width: 0;
min-height: 0;
}
.subject-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px;
flex: 1;
min-height: 0;
}
.subject-table-pane {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid #edf1f7;
min-height: 0;
outline: none;
}
.subject-workbench.is-desktop .subject-table-pane {
border-right: 1px solid #edf1f7;
}
.subject-preview-pane {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
min-height: 0;
padding: 16px;
background: #fbfcff;
}
@@ -431,13 +522,6 @@ onBeforeUnmount(() => {
flex-shrink: 0;
}
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item {
display: flex;
flex-direction: column;
@@ -473,6 +557,8 @@ onBeforeUnmount(() => {
/* ==================== Table ==================== */
.subject-table {
flex: 1;
min-height: 0;
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
@@ -546,7 +632,7 @@ onBeforeUnmount(() => {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px;
margin: 16px 0 0;
margin: 0;
}
.preview-list dt {
@@ -566,10 +652,17 @@ onBeforeUnmount(() => {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.preview-empty {
flex: 1;
min-height: 360px;
display: flex;
flex-direction: column;
@@ -651,6 +744,7 @@ onBeforeUnmount(() => {
.pagination-wrap {
display: flex;
justify-content: flex-end;
margin-top: auto;
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
}
@@ -680,8 +774,8 @@ onBeforeUnmount(() => {
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; }
.subject-workbench.is-desktop { grid-template-columns: 1fr; }
.subject-workbench.is-desktop .subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; }
@@ -1,5 +1,5 @@
<template>
<div class="enrollment-chart">
<div class="enrollment-chart" :class="{ 'enrollment-chart--compact': compact }">
<StateLoading v-if="loading" :rows="5" />
<div v-else-if="items.length === 0" class="chart-empty-shell">
<div class="chart-empty-body">
@@ -9,7 +9,7 @@
</div>
<div v-else class="chart-body">
<div class="chart-scroll">
<div class="chart-plot">
<div class="chart-plot" :style="chartPlotStyle">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
@@ -105,23 +105,40 @@ const props = withDefaults(
items: EnrollmentBarItem[];
loading?: boolean;
emptyText?: string;
compact?: boolean;
}>(),
{
loading: false,
emptyText: "暂无入组数据",
compact: false,
}
);
const compact = computed(() => props.compact);
const showTarget = computed(() => props.mode === "center");
const chartWidth = 960;
const chartHeight = 260;
const axisPadding = {
top: 28,
right: 32,
bottom: 48,
left: 56,
};
const baseChartWidth = computed(() => (compact.value ? 560 : 960));
const chartHeight = computed(() => (compact.value ? 240 : 260));
const axisPadding = computed(() => ({
top: compact.value ? 24 : 28,
right: compact.value ? 24 : 32,
bottom: compact.value ? 42 : 48,
left: compact.value ? 46 : 56,
}));
const chartWidth = computed(() => {
if (!compact.value) return baseChartWidth.value;
const itemWidth = showTarget.value ? 72 : 64;
return Math.max(
baseChartWidth.value,
props.items.length * itemWidth + axisPadding.value.left + axisPadding.value.right,
);
});
const chartPlotStyle = computed(() => ({
"--chart-min-width": `${chartWidth.value}px`,
"--chart-aspect": `${chartWidth.value} / ${chartHeight.value}`,
}));
const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
@@ -146,36 +163,37 @@ const yTicks = computed(() => {
});
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom);
const axisLeft = axisPadding.left;
const axisRight = chartWidth - axisPadding.right;
const axisTop = axisPadding.top;
const axisBottom = chartHeight - axisPadding.bottom;
const labelY = axisBottom + 20;
const valueGap = 10;
const barRadius = 6;
const plotWidth = computed(() => chartWidth.value - axisPadding.value.left - axisPadding.value.right);
const plotHeight = computed(() => chartHeight.value - axisPadding.value.top - axisPadding.value.bottom);
const axisLeft = computed(() => axisPadding.value.left);
const axisRight = computed(() => chartWidth.value - axisPadding.value.right);
const axisTop = computed(() => axisPadding.value.top);
const axisBottom = computed(() => chartHeight.value - axisPadding.value.bottom);
const labelY = computed(() => axisBottom.value + (compact.value ? 18 : 20));
const valueGap = computed(() => (compact.value ? 8 : 10));
const barRadius = computed(() => (compact.value ? 5 : 6));
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(52, bandWidth.value * 0.5));
const barWidth = computed(() => Math.min(compact.value ? 44 : 52, bandWidth.value * 0.5));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2;
const barCenter = (index: number) => axisLeft.value + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => {
if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value;
};
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const barTop = (value: number) => axisTop.value + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop.value + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap;
return barTop(anchor) - valueGap.value;
};
const truncateLabel = (label: string) => {
if (label.length <= 8) return label;
return `${label.slice(0, 7)}...`;
const limit = compact.value ? 7 : 8;
if (label.length <= limit) return label;
return `${label.slice(0, limit - 1)}...`;
};
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
@@ -228,7 +246,8 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
border-radius: 10px;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative;
aspect-ratio: 960 / 260;
width: 100%;
aspect-ratio: var(--chart-aspect);
min-height: 200px;
}
@@ -297,6 +316,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
font-weight: 500;
}
.enrollment-chart--compact .chart-plot {
width: max(100%, var(--chart-min-width));
min-height: 0;
border-radius: 6px;
}
.enrollment-chart--compact .chart-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.enrollment-chart--compact .tick-label {
font-size: 10px;
}
.enrollment-chart--compact .bar-value {
font-size: 11px;
}
.enrollment-chart--compact .bar-value-actual {
font-size: 12px;
}
.enrollment-chart--compact .bar-label {
font-size: 10px;
}
@media (max-width: 768px) {
.bar-label {
font-size: 11px;