Compare commits

11 Commits

Author SHA1 Message Date
Cheng Zhou 76f2d9f22a 功能(桌面端):增加在线辅助本地缓存
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
2026-07-08 20:46:56 +08:00
Cheng Zhou b73f23c1eb style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标 2026-07-08 20:46:56 +08:00
Cheng Zhou e7b18758b2 优化桌面端界面布局 2026-07-08 20:46:56 +08:00
Cheng Zhou 84d5daebab feat(desktop): 收口桌面工作台视觉与活动反馈 2026-07-08 20:46:56 +08:00
Cheng Zhou 3a866accd9 完善桌面体验与系统通知收口 2026-07-08 20:46:56 +08:00
Cheng Zhou a17fa618cd 完善桌面端回归与安全边界复审 2026-07-08 20:46:56 +08:00
Cheng Zhou 0d03e1656a 补齐桌面端附件文件流回归 2026-07-08 20:46:56 +08:00
Cheng Zhou 1c1527a224 完善桌面端端到端回归收口 2026-07-08 20:46:56 +08:00
Cheng Zhou 360988de5e 完善桌面端发布稳定化门禁 2026-07-08 20:46:56 +08:00
Cheng Zhou b8c5c4123a feat(desktop): 支持桌面端三十天免登录 2026-07-08 20:46:56 +08:00
Cheng Zhou 8c8327df92 feat(desktop): 稳定桌面端界面与文件操作反馈
- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试
2026-07-08 20:46:56 +08:00
109 changed files with 12311 additions and 1732 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
+54 -11
View File
@@ -1,5 +1,6 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from dataclasses import dataclass
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
@@ -70,12 +71,50 @@ AVATAR_ALLOWED_CONTENT_TYPES = {
}
def issue_user_token(db_user) -> Token:
@dataclass(frozen=True)
class SessionPolicy:
access_minutes: int
absolute_max_seconds: int
def normalize_session_client_type(value: str | None) -> str:
return "desktop" if (value or "").strip().lower() == "desktop" else "web"
def get_session_policy_for_client_type(client_type: str) -> SessionPolicy:
if client_type == "desktop":
max_seconds = settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
return SessionPolicy(
access_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
absolute_max_seconds=max_seconds,
)
return SessionPolicy(
access_minutes=settings.JWT_EXPIRE_MINUTES,
absolute_max_seconds=settings.ABSOLUTE_SESSION_MAX_HOURS * 3600,
)
def get_request_session_client_type(request: Request) -> str:
return normalize_session_client_type(request.headers.get("x-ctms-client-type"))
def policy_expires_at(issued_at: datetime, session_start: datetime, policy: SessionPolicy) -> datetime:
access_expires_at = issued_at + timedelta(minutes=policy.access_minutes)
session_expires_at = session_start + timedelta(seconds=policy.absolute_max_seconds)
return min(access_expires_at, session_expires_at)
def issue_user_token(db_user, request: Request) -> Token:
session_start = datetime.now(timezone.utc)
client_type = get_request_session_client_type(request)
policy = get_session_policy_for_client_type(client_type)
access_token = create_access_token(
user_id=str(db_user.id),
expires_minutes=None,
expires_minutes=policy.access_minutes,
session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=session_start,
client_type=client_type,
)
return Token(access_token=access_token, token_type="bearer")
@@ -240,23 +279,23 @@ async def get_login_key() -> LoginKeyResponse:
@router.post("/login", response_model=Token)
async def login_for_access_token(
payload: LoginRequest, db: AsyncSession = Depends(get_db_session)
payload: LoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
) -> Token:
db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user)
return issue_user_token(db_user)
return issue_user_token(db_user, request)
@router.post("/dev-login", response_model=Token)
async def dev_login_for_access_token(
payload: DevLoginRequest, db: AsyncSession = Depends(get_db_session)
payload: DevLoginRequest, request: Request, db: AsyncSession = Depends(get_db_session)
) -> Token:
if settings.ENV != "development":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
db_user = await authenticate_plain_password(payload, db)
ensure_user_active(db_user)
return issue_user_token(db_user)
return issue_user_token(db_user, request)
@router.get("/me", response_model=UserRead)
@@ -285,19 +324,23 @@ async def extend_access_token(
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat")
policy = get_session_policy_for_client_type(normalize_session_client_type(payload.get("client_type")))
if session_start_ts:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
if now_ts - int(session_start_ts) > max_seconds:
if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else:
session_start = datetime.now(timezone.utc)
issued_at = datetime.now(timezone.utc)
new_token = create_access_token(
user_id=str(db_user.id),
expires_minutes=None,
expires_minutes=policy.access_minutes,
session_start=session_start,
max_age_seconds=policy.absolute_max_seconds,
issued_at=issued_at,
client_type=normalize_session_client_type(payload.get("client_type")),
)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
expires_at = policy_expires_at(issued_at, session_start, policy)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
+1
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8
DESKTOP_SESSION_MAX_DAYS: int = 30
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
LOGIN_RSA_KEY_ID: str = "default"
+14 -1
View File
@@ -19,16 +19,29 @@ def create_access_token(
user_id: str,
expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None,
max_age_seconds: Optional[int] = None,
issued_at: Optional[datetime] = None,
client_type: Optional[str] = None,
) -> str:
now = datetime.now(timezone.utc)
now = issued_at or datetime.now(timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
session_start_time = session_start or now
if session_start_time.tzinfo is None:
session_start_time = session_start_time.replace(tzinfo=timezone.utc)
if max_age_seconds is not None:
session_expire = session_start_time + timedelta(seconds=max_age_seconds)
if expire > session_expire:
expire = session_expire
to_encode: Dict[str, Any] = {
"sub": user_id,
"exp": expire,
"iat": int(now.timestamp()),
"orig_iat": int(session_start_time.timestamp()),
}
if client_type:
to_encode["client_type"] = client_type
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
+69 -1
View File
@@ -16,7 +16,7 @@ import os
from app.main import create_app
from app.core.config import settings
from app.core.deps import get_db_session
from app.core.security import hash_password, verify_password
from app.core.security import create_access_token, decode_token_allow_expired, hash_password, verify_password
from app.crud import user as user_crud
from app.db.base_class import Base
from app.models.audit_log import AuditLog
@@ -422,6 +422,74 @@ async def test_registered_user_can_login_after_email_verification(client_and_db)
assert resp.json()["access_token"]
@pytest.mark.asyncio
async def test_desktop_login_uses_30_day_token_without_changing_web_login(client_and_db):
client, _ = client_and_db
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
desktop_resp = await client.post(
"/api/v1/auth/login",
json=await encrypted_auth_payload(client, "admin@test.com", "admin123"),
headers={"X-CTMS-Client-Type": "desktop"},
)
assert web_resp.status_code == 200
assert desktop_resp.status_code == 200
web_payload = decode_token_allow_expired(web_resp.json()["access_token"])
desktop_payload = decode_token_allow_expired(desktop_resp.json()["access_token"])
assert web_payload["client_type"] == "web"
assert desktop_payload["client_type"] == "desktop"
assert web_payload["exp"] - web_payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
assert desktop_payload["exp"] - desktop_payload["iat"] == settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600
@pytest.mark.asyncio
async def test_web_token_extension_cannot_be_upgraded_with_desktop_header(client_and_db):
client, _ = client_and_db
web_resp = await encrypted_login(client, "admin@test.com", "admin123")
token = web_resp.json()["access_token"]
resp = await client.post(
"/api/v1/auth/extend",
headers={
"Authorization": f"Bearer {token}",
"X-CTMS-Client-Type": "desktop",
},
)
assert resp.status_code == 200
payload = decode_token_allow_expired(resp.json()["accessToken"])
assert payload["client_type"] == "web"
assert payload["exp"] - payload["iat"] == settings.JWT_EXPIRE_MINUTES * 60
@pytest.mark.asyncio
async def test_desktop_token_extension_rejects_sessions_after_30_days(client_and_db):
client, SessionLocal = client_and_db
async with SessionLocal() as session:
admin = await user_crud.get_by_email(session, "admin@test.com")
session_start = datetime.now(timezone.utc) - timedelta(days=settings.DESKTOP_SESSION_MAX_DAYS, seconds=1)
token = create_access_token(
user_id=str(admin.id),
expires_minutes=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 60,
session_start=session_start,
max_age_seconds=settings.DESKTOP_SESSION_MAX_DAYS * 24 * 3600,
client_type="desktop",
)
resp = await client.post(
"/api/v1/auth/extend",
headers={
"Authorization": f"Bearer {token}",
"X-CTMS-Client-Type": "desktop",
},
)
assert resp.status_code == 401
assert "会话已到期" in resp.json().get("detail", "")
@pytest.mark.asyncio
async def test_admin_created_user_is_active_by_default(client_and_db):
client, SessionLocal = client_and_db
@@ -2,9 +2,9 @@
状态: `active`
适用范围: Web 与 macOS Desktop 统一客户端发布
最后更新: `2026-07-01`
最后更新: `2026-07-08`
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。当前允许按 `docs/desktop-local-cache-plan.md` 引入在线辅助本地缓存,但不引入离线登录、离线写入、本地业务权威数据、内嵌后端服务或离线同步。
## 1. 发布链路门禁
@@ -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,14 +50,25 @@ npm run desktop:build:app
- [ ] Tauri command 白名单仅包含凭据和更新命令。
- [ ] 前端源码不通过 query string 传递 token。
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
- [ ] 登录表单密码不写入 `localStorage``sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。
- [ ] 本地缓存能力只通过 `frontend/src/runtime/desktopDataCache.ts` 或后续同名 runtime 入口暴露,业务模块不直接调用 Tauri 存储 API、SQLite、IndexedDB、Cache Storage 或文件系统。
- [ ] 本地缓存命名空间包含 server origin、user id 和 cache schema version。
- [ ] 本地缓存不保存 token、密码、Authorization/Bearer 文本、下载凭据、临时授权 URL 或系统通知正文。
- [ ] 系统通知只能通过 `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 后端裁决。
- [ ] 本地缓存只作为服务端响应副本,mutation、审批、权限判断和审计判断仍以后端结果为准。
- [ ] Web 运行时不直接导入 Tauri API。
## 3. 端到端回归矩阵
@@ -63,9 +76,14 @@ npm run desktop:build:app
| 场景 | Web | macOS Desktop | 预期 |
| --- | --- | --- | --- |
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
| 本地缓存命中 | 必测 | 必测 | `/me` 校验通过后可先展示缓存再后台刷新 |
| 本地缓存清理 | 必测 | 必测 | 登出、切换服务器、切换用户、401/403 后旧命名空间不可读取 |
| mutation 缓存失效 | 必测 | 必测 | 新增、编辑、删除或审批成功后相关列表、详情、统计和图表缓存失效 |
| 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 |
| 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` |
| 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` |
@@ -80,9 +98,12 @@ npm run desktop:build:app
## 4. 桌面体验验收
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不把 token 写入 URL、日志、通知正文或业务缓存。
- [ ] 记住密码与 30 天免登录使用独立凭据记录;服务器切换后不复用旧服务器保存的密码。
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
- [ ] 系统偏好或个人中心提供本地缓存记录数、容量和最近清理时间,且支持手动清理。
- [ ] 通知开关显示 OS 权限状态。
- [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。
- [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。
@@ -90,10 +111,11 @@ npm run desktop:build:app
## 5. 不允许项
- [ ] 不实现离线登录、离线浏览、离线队列或离线同步。
- [ ] 不在桌面端保存 CTMS 业务数据副本
- [ ]内嵌 FastAPI、PostgreSQL、SQLite 或本地业务 API 镜像
- [ ]绕过后端做本地权限裁决或本地审计回放
- [ ] 不实现离线登录、离线写入、离线队列或离线同步。
- [ ] 不在 `/me` 校验通过前展示业务缓存
- [ ]把本地缓存作为 CTMS 业务权威数据或本地优先数据源
- [ ]内嵌 FastAPI、PostgreSQL 或本地业务 API 镜像
- [ ] 不绕过后端做本地权限裁决、本地审计判定或审计回放。
## 6. 2026-07-01 收尾验证记录
@@ -125,3 +147,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 状态一致。
+180
View File
@@ -0,0 +1,180 @@
# CTMS 桌面端本地缓存执行方案
## 目标
桌面端新增本地缓存的目标是降低重复网络请求、改善页面回访和 App 重启后的等待时间。当前项目不涉及受试者隐私问题,后端返回的 CTMS 业务 GET 数据均可进入本地缓存候选范围,但缓存仍只是服务端响应副本,不是业务权威数据。
本方案不改变以下边界:
- 登录、会话恢复和用户身份确认仍必须依赖后端 token 校验和 `/me`
- 新增、编辑、删除、审批、导入、导出、通知 ack、权限判断和审计判断仍必须以后端结果为准。
- 不实现离线登录、离线写入队列、冲突解决、本地优先工作流、本地 API 镜像或内嵌后端服务。
- token、登录密码、附件下载凭据、临时授权 URL、Authorization/Bearer 文本不得写入本地缓存、URL、日志或系统通知正文。
## 缓存范围
默认可缓存:
- 字典、枚举、菜单、权限摘要、用户可见组织/中心、应用配置、系统元数据。
- 项目、任务、机构、用户、角色、报表、监控页面等业务 GET 响应。
- 页面查询结果、分页列表、详情页只读数据和图表数据。
默认不缓存:
- POST、PUT、PATCH、DELETE 等 mutation 请求响应,除非只用于立即失效相关 GET 缓存。
- 文件下载二进制正文、一次性下载链接、带签名的临时 URL。
- token、密码、验证码、MFA 信息、Authorization header、Cookie 原文。
- 系统通知正文和可能包含临时凭据的 release notes 或错误日志。
## 架构约束
- 统一入口命名为 `desktopDataCache`,位于 `frontend/src/runtime/`,并通过 `clientRuntime` 暴露。
- 业务模块不得直接调用 Tauri 存储 API、文件系统、SQLite、IndexedDB 或 Cache Storage 来实现桌面缓存。
- Web 与 Desktop 共用业务代码,平台差异只在 runtime 适配层之后收敛。
- Tauri command 只做窄职责存储读写、清理、容量统计或目录定位,不包含 CTMS 业务规则。
- 如果引入新的 Tauri command、capability、CSP 或存储插件,必须同步评估 `frontend/scripts/verify-desktop-release.mjs``npm run runtime:check` 和桌面发布检查清单。
## 缓存命名和数据模型
缓存命名空间必须包含:
- `serverOrigin`:规范化后的后端服务地址,不包含用户名、密码、token 或 query 凭据。
- `userId`:后端 `/me` 确认后的用户标识。
- `schemaVersion`:缓存结构版本,用于升级时整体失效。
- `requestSignature`:请求方法、路径、排序后的 query、稳定序列化后的 body 摘要和必要的业务上下文。
每条缓存记录至少包含:
- `payload`:后端响应副本。
- `status`:HTTP 状态码,仅缓存成功的可展示响应。
- `headers`:只保存白名单响应头,例如 `ETag``Last-Modified``Cache-Control`、业务版本头。
- `createdAt``updatedAt``expiresAt``lastAccessedAt`
- `sourceEndpoint``cacheTags`,用于诊断和精准失效。
## 缓存策略
第一优先级是请求减量:
- 同一会话内相同 GET 请求合并为一个 in-flight promise。
- 页面返回、tab 切换和重复组件挂载时优先命中短时内存缓存。
- 页面可使用 stale-while-revalidate:先显示本地副本,再后台刷新,刷新失败时保留旧数据并显示可恢复错误。
第二优先级是持久化缓存:
- 仅在后端会话恢复和 `/me` 成功后启用业务缓存读取。
- 默认缓存 GET 响应;mutation 成功后通过 tags 或 endpoint 规则失效相关 GET 缓存。
- TTL 先按数据类型配置,缺省值建议 5 分钟;字典和元数据可延长到 24 小时;高频业务列表建议 1 到 5 分钟。
- 设置总容量上限和 LRU 清理策略,避免缓存无限增长。
第三优先级是 HTTP 条件请求:
- 后端为稳定数据提供 `ETag``Last-Modified`
- 客户端对有验证器的缓存请求带 `If-None-Match``If-Modified-Since`
- 收到 304 时刷新缓存元数据,不重复下载 payload。
## 失效和清理
必须整体清理:
- 用户登出。
- 切换服务器。
- 切换用户。
- 后端返回 401 或 403。
- `/me` 返回的用户标识、角色、权限版本或租户上下文变化。
- `schemaVersion` 升级。
必须精准失效:
- mutation 成功后,清理相关详情、列表、统计和图表缓存。
- 权限、角色、组织、项目状态变化后,清理依赖这些上下文的页面缓存。
- 后端返回明确业务版本头或失效事件时,按 tags 清理。
应提供用户入口:
- 系统偏好或个人中心诊断信息展示缓存大小、记录数、最近清理时间。
- 提供“清理本地缓存”按钮。
- 清理提示只描述缓存状态,不包含具体业务内容。
## 分阶段实施
### 当前落地状态
2026-07-08 已完成阶段 1 的首批实现:
- 新增 `frontend/src/runtime/desktopDataCache.ts`,提供内存级缓存 scope、TTL、tag 失效、清理和统计能力,并通过 `clientRuntime.dataCache` 暴露。
- `frontend/src/api/axios.ts` 已支持 GET 并发去重、显式 GET 内存缓存、mutation 成功后默认清理缓存、401/403 清理缓存、服务器切换清理缓存。
- 缓存清理、scope 切换和 tag 失效会递增 generationGET 响应返回时若 generation 已变化,不再写入缓存,避免旧用户、旧服务器或 mutation 前响应回填到新缓存空间。
- `/api/v1/auth/login-key` 明确禁用 GET 去重,避免复用登录 challenge。
- GET 去重和缓存 key 已包含 schema version、server baseURL、请求参数、非敏感请求头、responseType、paramsSerializer 和 withCredentials;包含敏感 header 或 axios auth 的请求不会进入去重或缓存。
- 登录态已在 `/me` 成功后绑定缓存 scope,登出时清空 scope 和缓存。
- 首批缓存接入范围包括项目列表/详情、项目配置、权限摘要、系统权限、权限模板、机构、成员、项目概览和仪表盘摘要。
- `npm run runtime:check``npm run desktop:release:check` 已增加底层缓存 API 边界检查,业务模块不得直接调用 `indexedDB``caches.open``CacheStorage` 或 SQLite 入口。
仍未完成:
- 持久化缓存尚未落地,当前缓存只存在于本次前端运行内存中。
- 后端 `ETag` / `Last-Modified` 和客户端 304 处理尚未落地。
- 系统偏好或个人中心缓存诊断与手动清理入口尚未落地。
- mutation 失效当前以默认全量清理为主,后续再按 tags 收敛为精准失效。
### 阶段 0:请求观测和缓存清单
- 梳理 `frontend/src/api/`、Pinia store 和高频页面的 GET 请求。
- 标记每个接口的缓存 tags、TTL、mutation 失效关系和是否需要后端 ETag。
- 记录启动、登录后首页、常用列表和详情页的请求数量作为基线。
### 阶段 1:请求去重和内存缓存
- 在 API 客户端层增加 GET 请求 in-flight 去重。
- 增加短 TTL 内存缓存,先覆盖字典、菜单、权限摘要、组织/中心和常用列表。
- 增加单元测试覆盖请求合并、TTL 过期、mutation 后失效。
### 阶段 2runtime 持久化缓存
-`frontend/src/runtime/desktopDataCache.ts` 定义平台无关接口。
- Web 端提供内存或 IndexedDB 实现;Desktop 端通过 runtime 封装 IndexedDB、Cache Storage 或 Tauri app data 存储。
- 增加命名空间、容量限制、LRU、schema version、手动清理和诊断 API。
- 更新 `runtime:check` 和桌面发布检查,禁止业务页面直接使用底层缓存存储。
### 阶段 3:页面接入
- 先接入基础数据和全局布局依赖接口。
- 再接入项目列表、任务列表、报表图表、详情页只读数据。
- 为 mutation 建立集中失效规则,避免页面各自手写清理逻辑。
### 阶段 4:后端条件请求
- 为稳定 GET 接口补 `ETag``Last-Modified`
- 前端 API 客户端接入 304 处理。
- 增加后端和前端测试,确认 304 不改变业务数据语义。
### 阶段 5:诊断、验收和发布门禁
- 在个人中心或系统偏好加入缓存诊断和清理入口。
- 验证登出、切换服务器、切换用户、401/403、权限变化和版本升级清理行为。
- 执行相关质量门禁,并把新增缓存边界纳入发布检查清单。
## 验收标准
- App 重启并完成 `/me` 校验后,已访问过的常用页面可以先展示缓存再后台刷新。
- 同一路由重复进入、组件重复挂载、多个组件请求同一资源时,不产生重复并发请求。
- 登出、切换服务器、切换用户、401/403 后不能读取旧命名空间缓存。
- mutation 成功后相关列表、详情、统计和图表缓存被失效或刷新。
- token、密码、Authorization/Bearer、下载凭据不会出现在缓存文件、IndexedDB、日志、URL 或通知正文中。
- 业务模块不直接调用 Tauri API、IndexedDB、SQLite、Cache Storage 或文件系统实现缓存。
## 质量门禁
本地缓存相关实现至少执行:
```bash
cd frontend
npm run runtime:check
npm run desktop:release:check
npm run ui:contract
npm run type-check
npm run test:unit
npm run build
```
如果新增或修改后端条件请求、缓存头、权限版本或失效事件,还需执行对应后端测试。若新增 Tauri command、capability、CSP 或存储插件,还需执行 `npm run desktop:build:app` 并在真实 macOS `.app` 中验证缓存清理和诊断入口。
+3 -1
View File
@@ -34,13 +34,15 @@
- macOSKeychain。
- WindowsCredential Manager。
桌面端登录使用后端签发的在线会话 token,可在系统凭据库中保存最长 30 天,以支持重启 App 后免输入密码。启动恢复后仍必须使用后端 token 校验和 `/auth/me` 用户状态校验;服务端不可达或会话被后端拒绝时不得进入离线模式。
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
应用挂载前异步初始化 token
1. Web 端继续读取 `localStorage.ctms_token`
2. 桌面端先删除 legacy `localStorage.ctms_token`
3. 若 legacy token 仍有效,则迁移系统凭据库。
3. 若 legacy token 仍有效,则迁移为带 30 天本机到期时间的系统凭据库会话记录
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
+98 -11
View File
@@ -11,10 +11,10 @@
- 技术路线固定为 Tauri。
- 第一开发目标是 macOS 桌面端。
- Windows 仍只作为第二阶段兼容性验证目标,未获明确批准前不发布正式安装包。
- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口发布准备,不新增第三阶段能力
- 不做离线功能
- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口发布准备,以及本文档明确批准的在线辅助本地缓存;不新增独立第三阶段桌面产品线
- 不做离线登录、离线写入、离线同步或本地优先工作流;本地缓存只作为已认证在线客户端的体验加速能力
- 不在桌面 App 内嵌本地后端服务。
- 不在桌面 App 内嵌或分发本地数据库来保存 CTMS 业务数据
- 允许桌面端保存可清除的服务端响应缓存;缓存不是业务权威数据,不得替代后端持久化、权限裁决或审计判断
- 不实现离线同步、冲突解决、本地业务数据队列、本地优先工作流。
- 不把 CTMS 业务 UI 拆成一套独立的桌面端产品;除非桌面能力确实需要小范围适配层。
- FastAPI 后端仍是业务权威来源。权限裁决、审计判断、认证、业务数据持久化都保持在服务端。
@@ -38,15 +38,17 @@
- 修复既有 Tauri、运行时适配层、文件、通知、凭据、更新、菜单/快捷键和打包问题。
- 稳定 Web 与 Desktop 共用业务代码,确保平台差异继续收敛在 `frontend/src/runtime/` 后面。
- 按 [`desktop-local-cache-plan.md`](desktop-local-cache-plan.md) 推进在线辅助本地缓存、请求去重、条件请求和缓存失效能力。
- 完成 macOS 正式发布前的签名、公证、updater 签名、制品发布、CI 门禁和人工回归。
- 做 Windows 第二阶段兼容性验证,但不发布正式 Windows 安装包。
仍然不允许:
- 离线登录、离线浏览、离线队列、离线同步或本地优先工作流。
- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像
- 离线登录、离线写入、离线队列、离线同步或本地优先工作流。
- 未完成后端会话恢复、token 校验和 `/me` 身份确认前展示业务缓存
- 本地 PostgreSQL、本地 API 镜像、内嵌业务后端,或把 SQLite/IndexedDB/Cache Storage/Tauri app data 目录作为业务数据库使用。
- 内嵌 Python/FastAPI 后端服务。
- 绕过后端做本地权限裁决、本地审计缓存或审计回放。
- 绕过后端做本地权限裁决、本地审计判定、审计回放或写入补偿
- 为桌面端复制或重写一套独立业务 UI。
## 架构方向
@@ -59,11 +61,14 @@
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
- `savedLoginCredentials`:隔离网页端浏览器凭据管理与桌面端系统凭据库中的登录表单密码保存;不得把密码写入 `localStorage``sessionStorage`、URL、日志或通知正文,且不等同于离线登录。
- `files`:隔离浏览器上传下载与原生文件能力。
- `notifications`:隔离 Web 通知与桌面系统通知。
- `updates`:隔离桌面自动更新检查与安装入口。
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、收藏模块等桌面体验状态。
- `desktopDataCache`:承接桌面端在线辅助本地缓存、缓存命名空间、失效、清理和诊断;业务模块不得直接使用 Tauri 存储 API、IndexedDB、SQLite 或文件系统实现桌面缓存。
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
@@ -74,7 +79,10 @@
- 非本地服务连接优先使用 HTTPS。
- 认证与授权决策保留在后端。
- 审计敏感决策保留在后端。
- 敏感凭据必须继续使用明确批准的安全存储方案。
- 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库
- token、登录密码、附件下载凭据和临时授权信息不得进入本地业务缓存、URL、日志或系统通知正文。
- 本地业务缓存必须按 `server origin + user id + cache schema version` 隔离;登出、切换服务器、切换用户、后端返回 401/403、权限版本变化或缓存结构升级时必须清理或失效。
- 本地缓存命中只能用于展示后端曾返回的数据副本;新增、编辑、删除、审批、权限判断和审计判断仍必须请求后端并以后端结果为准。
- 不向前端暴露宽泛文件系统访问权限。
- Tauri 权限保持最小化,并按功能精确授权。
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
@@ -109,7 +117,86 @@
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 状态一致。
## 2026-07-08 本地缓存边界调整与执行方案
经当前项目边界复核,桌面端允许新增在线辅助本地缓存,用于减少重复请求、缩短页面回访和 App 重启后的数据展示等待时间。该能力不改变 CTMS 的业务权威边界:FastAPI 后端仍是认证、授权、审计和业务持久化的唯一权威来源。
执行方案以 [`desktop-local-cache-plan.md`](desktop-local-cache-plan.md) 为准,实施顺序为:
1. 建立请求观测和缓存清单,确认高频 GET 接口、页面冷启动接口、基础数据接口和 mutation 后需要失效的资源。
2. 先实现请求去重、短时内存缓存和页面级 stale-while-revalidate,减少同一会话内重复拉取。
3.`frontend/src/runtime/` 增加 `desktopDataCache` 适配层,统一提供缓存读写、命名空间、TTL、容量上限、失效、清理和诊断能力。
4. 增加持久化缓存,默认只缓存后端 GET 响应和明确标记可缓存的数据;缓存 key 必须包含 server origin、user id、请求签名和 cache schema version。
5. 推进后端 HTTP 条件请求支持,优先为字典、菜单、权限摘要、组织/中心、项目摘要等稳定数据提供 `ETag``Last-Modified`,客户端使用 `If-None-Match``If-Modified-Since` 降低 304 场景的数据传输。
6. 建立统一失效机制:登出、切换服务器、切换用户、401/403、权限版本变化、mutation 成功、应用版本或 cache schema 变化时清理或精准失效。
7. 在系统偏好或诊断信息中提供缓存状态和手动清理入口,清理文案不得包含业务详情。
8. 补充单元测试、运行时边界检查和桌面发布检查,确保 token、密码、下载凭据不会写入缓存,业务模块不会绕过 `frontend/src/runtime/` 使用 Tauri 或底层存储 API。
仍禁止把缓存扩展为离线产品能力:不得实现离线登录、离线写入队列、冲突解决、本地审批、本地权限裁决、本地审计回放或本地 API 镜像。服务端不可达时,桌面端不得把缓存解释为已完成身份认证;是否展示已过期缓存必须以后续明确的产品验收口径为准,默认只在在线身份校验通过后使用缓存。
如果后续任务试图新增离线登录、离线写入、本地业务队列、离线同步、内嵌后端、本地 API 镜像或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
## 当前质量门禁
@@ -135,10 +222,10 @@ npm run desktop:build:app
- 先阅读本文档。
- 确认任务属于第一阶段或第二阶段。
- 确认任务不会引入离线能力
- 确认任务不会引入未批准的离线能力;本地缓存任务必须符合 2026-07-08 缓存边界和 `desktop-local-cache-plan.md`
- 确认实现不会破坏 Web 运行时。
- 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。
- 确认不会重复初始化 Tauri 或绕过既有 `frontend/src/runtime/` 运行时边界。
- 涉及 Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。
- 涉及本地缓存、Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。
如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。
+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.");
}
+111 -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,17 +206,28 @@ 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.`);
const isRuntimeFile = file.startsWith("frontend/src/runtime/");
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.`);
}
if (!isRuntimeFile) {
assert(
!/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source),
`${file}: local data cache storage must be routed through frontend/src/runtime.`,
);
}
}
};
@@ -174,6 +238,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 +259,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 +290,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 +315,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) {
@@ -31,6 +31,9 @@ for (const path of await walk(sourceDir)) {
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
violations.push(`${file}: import platform behavior through src/runtime/index.ts`);
}
if (/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source)) {
violations.push(`${file}: local data cache storage must be routed through src/runtime`);
}
}
if (violations.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");
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPatch = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({
default: {},
apiGet,
apiPatch,
apiPost,
}));
describe("auth api", () => {
it("does not deduplicate login key challenges", async () => {
const { getLoginKey } = await import("./auth");
getLoginKey();
expect(apiGet).toHaveBeenCalledWith("/api/v1/auth/login-key", { disableRequestDedupe: true });
});
});
+6 -5
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,
@@ -22,12 +22,13 @@ export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginR
apiPost<LoginResponse>("/api/v1/auth/dev-login", payload);
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
apiGet<LoginKeyResponse>("/api/v1/auth/login-key", { disableRequestDedupe: true });
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);
+153 -1
View File
@@ -34,10 +34,14 @@ vi.mock("../session/sessionManager", () => ({
describe("api axios retry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
errorMessage.mockClear();
});
afterEach(() => {
afterEach(async () => {
const { clearApiResponseCache, setApiResponseCacheScope } = await import("./axios");
setApiResponseCacheScope(null);
clearApiResponseCache();
vi.useRealTimers();
});
@@ -67,4 +71,152 @@ describe("api axios retry", () => {
await expect(request).resolves.toMatchObject({ message: "Network Error" });
expect(errorMessage).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent apiGet calls for the same request", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const [first, second] = await Promise.all([
apiGet("/api/v1/studies/", { adapter }),
apiGet("/api/v1/studies/", { adapter }),
]);
expect(adapter).toHaveBeenCalledTimes(1);
expect(first.data).toEqual({ ok: true });
expect(second.data).toEqual({ ok: true });
});
it("does not deduplicate requests when disabled by the caller", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await Promise.all([
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
]);
expect(adapter).toHaveBeenCalledTimes(2);
});
it("keeps request headers in the dedupe key when they can affect the response", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { accept: (config.headers as any)?.Accept },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const [json, csv] = await Promise.all([
apiGet("/api/v1/reports", { adapter, headers: { Accept: "application/json" } }),
apiGet("/api/v1/reports", { adapter, headers: { Accept: "text/csv" } }),
]);
expect(adapter).toHaveBeenCalledTimes(2);
expect(json.data).toEqual({ accept: "application/json" });
expect(csv.data).toEqual({ accept: "text/csv" });
});
it("serves configured apiGet calls from memory cache until ttl expires", async () => {
const { apiGet } = await import("./axios");
let sequence = 0;
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { sequence: (sequence += 1) },
status: 200,
statusText: "OK",
headers: { etag: "v1" },
config: config as InternalAxiosRequestConfig,
}));
const first = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
const second = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(first.data).toEqual({ sequence: 1 });
expect(second.data).toEqual({ sequence: 1 });
expect(adapter).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
const third = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(third.data).toEqual({ sequence: 2 });
expect(adapter).toHaveBeenCalledTimes(2);
});
it("clears cached GET responses after successful mutations", async () => {
const { apiGet, apiPost } = await import("./axios");
let sequence = 0;
const getAdapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { sequence: (sequence += 1) },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const postAdapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { id: "study-1" },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(getAdapter).toHaveBeenCalledTimes(1);
await apiPost("/api/v1/studies/", { name: "Study" }, { adapter: postAdapter });
const afterMutation = await apiGet("/api/v1/studies/", {
adapter: getAdapter,
cache: { ttlMs: 1000, tags: ["studies"] },
});
expect(afterMutation.data).toEqual({ sequence: 2 });
expect(getAdapter).toHaveBeenCalledTimes(2);
expect(postAdapter).toHaveBeenCalledTimes(1);
});
it("does not write an in-flight GET response into cache after the cache generation changes", async () => {
const { apiGet, clearApiResponseCache } = await import("./axios");
let sequence = 0;
let resolveFirst: (() => void) | undefined;
const adapter = vi.fn<AxiosAdapter>((config) => {
sequence += 1;
const response = {
data: { sequence },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
};
if (sequence === 1) {
return new Promise((resolve) => {
resolveFirst = () => resolve(response);
});
}
return Promise.resolve(response);
});
const firstRequest = apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
await vi.dynamicImportSettled();
expect(adapter).toHaveBeenCalledTimes(1);
clearApiResponseCache();
resolveFirst?.();
await expect(firstRequest).resolves.toMatchObject({ data: { sequence: 1 } });
const afterClear = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(afterClear.data).toEqual({ sequence: 2 });
expect(adapter).toHaveBeenCalledTimes(2);
});
});
+215 -9
View File
@@ -14,22 +14,195 @@ export const refreshApiBaseUrl = (): void => {
instance.defaults.baseURL = clientRuntime.apiBaseUrl();
};
if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl);
}
const NETWORK_RETRY_LIMIT = 10;
const NETWORK_RETRY_DELAY_MS = 30000;
const DEFAULT_GET_CACHE_TTL_MS = 30_000;
const API_RESPONSE_CACHE_SCHEMA_VERSION = 1;
export type ApiRequestConfig = AxiosRequestConfig & {
suppressErrorMessage?: boolean;
_retry?: boolean;
_networkRetryCount?: number;
disableNetworkRetry?: boolean;
disableRequestDedupe?: boolean;
cache?: boolean | ApiCacheConfig;
invalidateCache?: boolean | { tags?: string[] };
};
export interface ApiCacheConfig {
ttlMs?: number;
key?: string;
tags?: string[];
}
interface CachedAxiosResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Record<string, string>;
}
const pendingGetRequests = new Map<string, Promise<AxiosResponse<any>>>();
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
const sensitiveHeaderPattern = /\b(?:authorization|bearer|cookie|token|password|credential|secret)\b/i;
const stableSerialize = (value: unknown): string => {
if (value === null || value === undefined) return "";
if (value instanceof URLSearchParams) {
return stableSerialize(Object.fromEntries([...value.entries()].sort(([left], [right]) => left.localeCompare(right))));
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
}
if (typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
};
const safeClone = <T>(value: T): T => {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
};
const rawHeaderEntries = (headers: AxiosRequestConfig["headers"]): Array<[string, unknown]> => {
if (!headers) return [];
if (typeof (headers as any).toJSON === "function") {
return Object.entries((headers as any).toJSON());
}
if (typeof (headers as any).forEach === "function") {
const entries: Array<[string, unknown]> = [];
(headers as any).forEach((value: unknown, key: string) => entries.push([key, value]));
return entries;
}
return Object.entries(headers as Record<string, unknown>);
};
const hasSensitiveRequestIdentityConfig = (config?: ApiRequestConfig) =>
Boolean(config?.auth) ||
rawHeaderEntries(config?.headers).some(
([key, value]) => sensitiveHeaderPattern.test(key) || sensitiveHeaderPattern.test(String(value)),
);
const shouldSkipGetCache = (config?: ApiRequestConfig) =>
config?.responseType === "blob" ||
config?.responseType === "arraybuffer" ||
config?.cache === false ||
hasSensitiveRequestIdentityConfig(config);
const normalizeCacheConfig = (config?: ApiRequestConfig): ApiCacheConfig | null => {
if (!config?.cache || shouldSkipGetCache(config)) return null;
if (config.cache === true) return { ttlMs: DEFAULT_GET_CACHE_TTL_MS };
return {
ttlMs: config.cache.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS,
key: config.cache.key,
tags: config.cache.tags,
};
};
const createGetCacheKey = (url: string, config?: ApiRequestConfig, explicitKey?: string) => {
if (explicitKey) return explicitKey;
return stableSerialize({
baseURL: config?.baseURL || instance.defaults.baseURL || "",
headers: Object.fromEntries(
rawHeaderEntries(config?.headers)
.filter(([key, value]) => !sensitiveHeaderPattern.test(key) && !sensitiveHeaderPattern.test(String(value)))
.map(([key, value]) => [key.toLowerCase(), String(value)])
.sort(([left], [right]) => left.localeCompare(right)),
),
method: "get",
params: config?.params || null,
paramsSerializer: config?.paramsSerializer ? String(config.paramsSerializer) : null,
responseType: config?.responseType || "json",
schemaVersion: API_RESPONSE_CACHE_SCHEMA_VERSION,
url,
withCredentials: config?.withCredentials || false,
});
};
const createPendingGetKey = (url: string, config?: ApiRequestConfig) =>
createGetCacheKey(url, config, typeof config?.cache === "object" ? config.cache.key : undefined);
const cacheableResponseHeaders = (headers: AxiosResponse["headers"]): Record<string, string> => {
const rawHeaders =
headers && typeof (headers as any).toJSON === "function" ? (headers as any).toJSON() : (headers as Record<string, unknown>);
const allowed = ["cache-control", "etag", "last-modified"];
return Object.fromEntries(
Object.entries(rawHeaders || {})
.filter(([key]) => allowed.includes(key.toLowerCase()))
.map(([key, value]) => [key, String(value)]),
);
};
const toCachedResponse = <T>(response: AxiosResponse<T>): CachedAxiosResponse<T> => ({
data: safeClone(response.data),
status: response.status,
statusText: response.statusText,
headers: cacheableResponseHeaders(response.headers),
});
const fromCachedResponse = <T>(cached: CachedAxiosResponse<T>, config?: ApiRequestConfig): AxiosResponse<T> => ({
data: safeClone(cached.data),
status: cached.status,
statusText: cached.statusText,
headers: cached.headers,
config: (config || {}) as InternalAxiosRequestConfig,
});
export const clearApiResponseCache = (): void => {
pendingGetRequests.clear();
clientRuntime.dataCache.clearDesktopDataCache();
};
export const setApiResponseCacheScope = (scope?: string | null): void => {
pendingGetRequests.clear();
clientRuntime.dataCache.setDesktopDataCacheScope(scope);
};
const requestWithDedupe = <T>(url: string, config?: ApiRequestConfig): Promise<AxiosResponse<T>> => {
if (config?.disableRequestDedupe || shouldSkipGetCache(config)) {
return instance.get<T>(url, config);
}
const pendingKey = createPendingGetKey(url, config);
const existing = pendingGetRequests.get(pendingKey) as Promise<AxiosResponse<T>> | undefined;
if (existing) return existing;
const request = instance.get<T>(url, config).finally(() => {
pendingGetRequests.delete(pendingKey);
});
pendingGetRequests.set(pendingKey, request);
return request;
};
const invalidateCacheAfterMutation = async <T>(
request: Promise<AxiosResponse<T>>,
config?: ApiRequestConfig,
): Promise<AxiosResponse<T>> => {
const response = await request;
const invalidation = config?.invalidateCache;
if (invalidation !== false) {
if (typeof invalidation === "object" && invalidation.tags?.length) {
clientRuntime.dataCache.invalidateDesktopDataCacheByTags(invalidation.tags);
} else {
clearApiResponseCache();
}
}
return response;
};
if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, () => {
refreshApiBaseUrl();
setApiResponseCacheScope(null);
});
}
const clearInvalidStudyAndNavigate = async () => {
try {
const [{ useStudyStore }, { default: router }] = await Promise.all([
@@ -88,6 +261,7 @@ instance.interceptors.response.use(
}
if (status === 401) {
const config = error.config as ApiRequestConfig;
let clearedCacheForAuthFailure = false;
if (config && !config._retry) {
const { extendAccessToken } = await import("../session/sessionManager");
const result = await extendAccessToken("response-401");
@@ -98,12 +272,21 @@ instance.interceptors.response.use(
return instance(config);
}
if (result.authFailed) {
clearApiResponseCache();
clearedCacheForAuthFailure = true;
await forceAuthExpiredLogout();
}
}
if (!clearedCacheForAuthFailure) {
clearApiResponseCache();
}
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
clearApiResponseCache();
await forceAuthExpiredLogout();
} else {
if (status === 403) {
clearApiResponseCache();
}
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
if (!suppressErrorMessage) {
if (data?.message || (data as any)?.detail) {
@@ -120,14 +303,37 @@ instance.interceptors.response.use(
}
);
export const apiGet = <T = any>(url: string, config?: ApiRequestConfig) => instance.get<T>(url, config);
export const apiGet = async <T = any>(url: string, config?: ApiRequestConfig) => {
const cacheConfig = normalizeCacheConfig(config);
const cacheKey = cacheConfig ? createGetCacheKey(url, config, cacheConfig.key) : null;
const cacheGeneration = cacheConfig ? clientRuntime.dataCache.getDesktopDataCacheGeneration() : null;
if (cacheConfig && cacheKey) {
const cached = clientRuntime.dataCache.readDesktopDataCache<CachedAxiosResponse<T>>(cacheKey);
if (cached) {
return fromCachedResponse(cached, config);
}
}
const response = await requestWithDedupe<T>(url, config);
if (
cacheConfig &&
cacheKey &&
cacheGeneration !== null &&
clientRuntime.dataCache.isDesktopDataCacheGenerationCurrent(cacheGeneration)
) {
clientRuntime.dataCache.writeDesktopDataCache(cacheKey, toCachedResponse(response), {
ttlMs: cacheConfig.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS,
tags: cacheConfig.tags,
});
}
return response;
};
export const apiPost = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.post<T>(url, data, config);
invalidateCacheAfterMutation(instance.post<T>(url, data, config), config);
export const apiPatch = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.patch<T>(url, data, config);
invalidateCacheAfterMutation(instance.patch<T>(url, data, config), config);
export const apiPut = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.put<T>(url, data, config);
invalidateCacheAfterMutation(instance.put<T>(url, data, config), config);
export const apiDelete = <T = any>(url: string, config?: ApiRequestConfig) =>
instance.delete<T>(url, config);
invalidateCacheAfterMutation(instance.delete<T>(url, config), config);
export default instance;
+17 -5
View File
@@ -13,16 +13,28 @@ export interface CenterSummaryItem {
}
export const fetchProgress = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/dashboard/progress`);
apiGet(`/api/v1/studies/${studyId}/dashboard/progress`, {
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`] },
});
export const fetchFinanceSummary = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/finance/summary`);
apiGet(`/api/v1/studies/${studyId}/finance/summary`, {
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:finance`] },
});
export const fetchOverdueAesCount = (studyId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } });
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, {
params: { overdue: true, limit: 1 },
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:aes`] },
});
export const fetchLostVisits = (studyId: string, params?: Record<string, any>) =>
apiGet<VisitLostItem[]>(`/api/v1/studies/${studyId}/dashboard/lost-visits`, { params });
apiGet<VisitLostItem[]>(`/api/v1/studies/${studyId}/dashboard/lost-visits`, {
params,
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:visits`] },
});
export const fetchCenterSummary = (studyId: string) =>
apiGet<CenterSummaryItem[]>(`/api/v1/studies/${studyId}/dashboard/center-summary`);
apiGet<CenterSummaryItem[]>(`/api/v1/studies/${studyId}/dashboard/center-summary`, {
cache: { ttlMs: 30_000, tags: [`study:${studyId}`, `study:${studyId}:dashboard`, `study:${studyId}:sites`] },
});
+9 -2
View File
@@ -2,10 +2,17 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { StudyMember, UserInfo } from "../types/api";
export const listMembers = (studyId: string, params?: Record<string, any>) =>
apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, { params });
apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, {
params,
cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] },
});
export const listMemberCandidates = (studyId: string, params?: Record<string, any>) =>
apiGet<UserInfo[]>(`/api/v1/studies/${studyId}/members/candidates`, { params, suppressErrorMessage: true });
apiGet<UserInfo[]>(`/api/v1/studies/${studyId}/members/candidates`, {
params,
suppressErrorMessage: true,
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] },
});
export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) =>
apiPost<StudyMember>(`/api/v1/studies/${studyId}/members/`, payload);
+4 -1
View File
@@ -1,3 +1,6 @@
import { apiGet } from "./axios";
export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`);
export const fetchProjectOverview = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/overview`, {
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:overview`] },
});
+36 -11
View File
@@ -17,10 +17,16 @@ import type {
// 接口级权限
export const fetchApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, config);
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, {
...(config || {}),
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
});
export const fetchMyApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions/me`, config);
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions/me`, {
...(config || {}),
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
});
export const updateApiEndpointPermissions = (
studyId: string,
@@ -32,14 +38,20 @@ export const updateApiEndpointPermissions = (
});
export const fetchApiOperations = () =>
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`);
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`, {
cache: { ttlMs: 24 * 60 * 60_000, tags: ["permission-operations"] },
});
// 系统监测API
export const fetchPermissionMetrics = () =>
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`);
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`, {
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
});
export const fetchCacheStats = () =>
apiGet<CacheStatsResponse>(`/api/v1/permission-monitoring/cache-stats`);
apiGet<CacheStatsResponse>(`/api/v1/permission-monitoring/cache-stats`, {
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
});
export const fetchPermissionAlerts = (level?: string, limit?: number) =>
apiGet<AlertsResponse>(`/api/v1/permission-monitoring/alerts`, {
@@ -47,7 +59,9 @@ export const fetchPermissionAlerts = (level?: string, limit?: number) =>
});
export const fetchPermissionHealth = () =>
apiGet<HealthResponse>(`/api/v1/permission-monitoring/health`);
apiGet<HealthResponse>(`/api/v1/permission-monitoring/health`, {
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
});
export const resetPermissionMetrics = () =>
apiPost<void>(`/api/v1/permission-monitoring/reset-metrics`);
@@ -87,7 +101,9 @@ export const fetchIpLocations = (params?: { days?: number; limit?: number }) =>
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
export const fetchStatsSummary = () =>
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`);
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`, {
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
});
// 权限模板API
export interface PermissionTemplate {
@@ -129,10 +145,15 @@ export interface ApplyTemplateResponse {
}
export const fetchPermissionTemplates = (params?: { template_type?: string; category?: string }) =>
apiGet<PermissionTemplate[]>(`/api/v1/permission-templates`, { params });
apiGet<PermissionTemplate[]>(`/api/v1/permission-templates`, {
params,
cache: { ttlMs: 5 * 60_000, tags: ["permission-templates"] },
});
export const fetchPermissionTemplate = (templateId: string) =>
apiGet<PermissionTemplate>(`/api/v1/permission-templates/${templateId}`);
apiGet<PermissionTemplate>(`/api/v1/permission-templates/${templateId}`, {
cache: { ttlMs: 5 * 60_000, tags: ["permission-templates", `permission-template:${templateId}`] },
});
export const createPermissionTemplate = (payload: PermissionTemplateCreate) =>
apiPost<PermissionTemplate>(`/api/v1/permission-templates`, payload);
@@ -165,11 +186,15 @@ export interface SystemPermissionsResponse {
}
export const fetchSystemPermissions = () =>
apiGet<SystemPermissionsResponse>(`/api/v1/system-permissions`);
apiGet<SystemPermissionsResponse>(`/api/v1/system-permissions`, {
cache: { ttlMs: 24 * 60 * 60_000, tags: ["system-permissions"] },
});
// 项目角色生效管理
export const fetchActiveRoles = (studyId: string) =>
apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`);
apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, {
cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
});
export const updateActiveRoles = (studyId: string, activeRoles: string[]) =>
apiPut<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, { active_roles: activeRoles });
+1
View File
@@ -5,6 +5,7 @@ export const fetchSites = (studyId: string, params?: Record<string, any>, config
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
...(config || {}),
params: { include_inactive: true, ...(params || {}), ...(config?.params || {}) },
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:sites`] },
});
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
+12 -4
View File
@@ -12,7 +12,8 @@ import type {
} from "../types/setupConfig";
// Backend expects trailing slash to avoid 307 redirect
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
export const fetchStudies = () =>
apiGet<ApiListResponse<Study>>("/api/v1/studies/", { cache: { ttlMs: 5 * 60_000, tags: ["studies"] } });
export const createStudy = (payload: Partial<Study> & { name: string }) =>
apiPost<Study>("/api/v1/studies/", payload);
@@ -20,7 +21,8 @@ export const createStudy = (payload: Partial<Study> & { name: string }) =>
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
export const fetchStudyDetail = (studyId: string) =>
apiGet<Study>(`/api/v1/studies/${studyId}`, { cache: { ttlMs: 5 * 60_000, tags: ["studies", `study:${studyId}`] } });
export const deleteStudy = (studyId: string) =>
apiDelete(`/api/v1/studies/${studyId}`);
@@ -29,7 +31,10 @@ export const lockStudy = (studyId: string) =>
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
export const fetchStudySetupConfig = (studyId: string) =>
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, { suppressErrorMessage: true });
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, {
suppressErrorMessage: true,
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] },
});
export const saveStudySetupConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) =>
apiPut<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, payload, { suppressErrorMessage: true });
@@ -38,7 +43,10 @@ export const publishStudySetupConfig = (studyId: string, payload: StudySetupConf
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/publish`, payload, { suppressErrorMessage: true });
export const fetchStudySetupConfigVersions = (studyId: string) =>
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, { suppressErrorMessage: true });
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, {
suppressErrorMessage: true,
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] },
});
export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: number) =>
apiDelete(`/api/v1/studies/${studyId}/setup-config/versions/${targetVersion}`, { suppressErrorMessage: true });
@@ -1,7 +1,7 @@
import { auditExportColumns } from "./auditExportColumns";
import { formatAuditRows } from "./auditExportFormatter";
import type { AuditEvent } from "..";
import { saveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const BOM = "\ufeff";
@@ -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 saveFile({
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>
+452 -2
View File
@@ -3,26 +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", () => {
@@ -34,4 +65,423 @@ describe("desktop layout shell", () => {
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
expect(navigation).toContain("export const getActiveLayoutPath");
});
it("keeps desktop context labels and command entry points de-duplicated", () => {
const source = readDesktopLayoutSource();
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: 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();
const webLayout = readWebLayoutSource();
expect(preferences).toContain('class="preferences-sidebar"');
expect(preferences).toContain('class="preferences-pane"');
expect(preferences).toContain("const activeSectionId = ref<PreferenceSectionId>");
expect(preferences).toContain('grid-template-columns: 232px minmax(0, 1fr);');
expect(preferences).toContain("height: min(700px, calc(100vh - 96px));");
expect(preferences).toContain("scrollbar-gutter: stable;");
expect(preferences).toContain('{ id: "connection", label: "连接"');
expect(preferences).toContain('{ id: "appearance", label: "外观"');
expect(preferences).toContain('{ id: "notifications", label: "通知"');
expect(preferences).toContain('{ id: "updates", label: "更新"');
expect(preferences).toContain('{ id: "diagnostics", label: "诊断信息"');
expect(preferences).toContain("testDesktopServerConnection");
expect(preferences).toContain("saveDesktopServerConfig");
expect(preferences).toContain("测试连通性");
expect(preferences).not.toContain("保存服务器");
expect(preferences).toContain('ElMessage.success("服务器设置已保存")');
expect(preferences).toContain("ElMessage.error(`保存失败:${message}`)");
expect(preferences).toContain("normalizeDesktopServerUrl");
expect(preferences).toContain("确认切换服务器");
expect(preferences).toContain('<Transition name="preferences-header" mode="out-in">');
expect(preferences).toContain('<Transition name="preferences-panel" mode="out-in">');
expect(preferences).toContain('<Transition name="connection-feedback">');
expect(preferences).toContain("const selectSection = (sectionId: PreferenceSectionId)");
expect(preferences).toContain("const setConnectionPending = (message: string)");
expect(preferences).toContain("const scheduleConnectionPending = (message: string)");
expect(preferences).toContain("}, 180);");
expect(preferences).toContain('scrollTo({ top: 0, behavior: "smooth" })');
expect(preferences).not.toContain("connectionStatus.value = null");
expect(preferences).toContain("min-width: 100px;");
expect(preferences).toContain("connection-feedback-enter-active");
expect(preferences).toContain("@media (prefers-reduced-motion: reduce)");
expect(preferences).toContain(':global([data-ctms-theme="dark"] .desktop-preferences)');
expect(preferences).not.toContain(':global([data-ctms-theme="dark"]) .desktop-preferences');
expect(desktopLayout).toContain(':global([data-ctms-theme="dark"] .desktop-workbench)');
expect(desktopLayout).not.toContain(':global([data-ctms-theme="dark"]) .desktop-workbench');
expect(desktopLayout).toContain('width="940px"');
expect(webLayout).toContain('width="940px"');
expect(desktopLayout).toContain('title: "连接设置"');
expect(webLayout).toContain('title: "连接设置"');
expect(desktopLayout).not.toContain('router.push("/desktop/server-settings")');
expect(webLayout).not.toContain('router.push("/desktop/server-settings")');
expect(desktopLayout).not.toContain('width="720px"');
expect(webLayout).not.toContain('width="720px"');
});
it("keeps client diagnostics out of profile while desktop controls stay in preferences", () => {
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(profile).not.toContain("form-section--desktop");
expect(profile).not.toContain("客户端与通知");
expect(profile).not.toContain("getDesktopNotificationSubscription");
expect(profile).not.toContain("setDesktopNotificationSubscription");
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
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");
expect(preferences).not.toContain('{ label: "主题"');
expect(preferences).not.toContain('{ label: "服务器"');
});
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf('<div class="workspace-tabs"');
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('@click="navigateWorkspaceTab(item.path)"');
expect(tabsTemplate).toContain('draggable="true"');
expect(tabsTemplate).toContain('@dragstart="startWorkspaceTabDrag(item.path, $event)"');
expect(tabsTemplate).toContain('@drop.prevent="dropWorkspaceTab(item.path, $event)"');
expect(source).toContain("const draggingWorkspaceTabPath = ref(\"\")");
expect(source).toContain("const navigateWorkspaceTab = (path: string) => {");
expect(source).toContain("if (activeMenu.value === path) return;");
expect(source).toContain("next[existingIndex] = { ...next[existingIndex], ...item };");
expect(source).toContain("const [moved] = next.splice(sourceIndex, 1);");
expect(source).toContain("<KeepAlive :max=\"DESKTOP_WORKSPACE_TAB_CACHE_MAX\">");
expect(source).toContain(":key=\"desktopRouteCacheKey(currentRoute)\"");
expect(source).toContain("const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;");
expect(source).not.toContain('<div :key="currentRoute.fullPath" class="desktop-route-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) => {");
expect(source).toContain("const restoreLastClosedWorkspaceTab = () => {");
expect(source).toContain('id: `workspace-tab:${item.path}`');
expect(source).toContain('title: `切换标签:${item.title}`');
expect(source).toContain('title: "重新打开最近关闭标签"');
});
it("keeps desktop navigation hierarchy aligned with the web sidebar", () => {
const desktopLayout = readDesktopLayoutSource();
const navigation = readNavigationSource();
const sidebarStart = desktopLayout.indexOf('<div class="sidebar-scroll">');
const sidebarEnd = desktopLayout.indexOf("</aside>", sidebarStart);
const sidebarTemplate = desktopLayout.slice(sidebarStart, sidebarEnd);
expect(sidebarTemplate.indexOf('v-if="adminNavigationItems.length"')).toBeLessThan(
sidebarTemplate.indexOf('v-if="projectNavigationItems.length"'),
);
expect(sidebarTemplate).toContain('@click="toggleNavigationGroup(item)"');
expect(sidebarTemplate).toContain(':aria-expanded="isNavigationGroupExpanded(item)"');
expect(sidebarTemplate).toContain('v-show="isNavigationGroupExpanded(item)"');
expect(desktopLayout).toContain("const expandedNavigationGroups = ref<Set<string>>(new Set());");
expect(navigation).toContain('label: "系统设置"');
expect(navigation).toContain('label: "邮件服务"');
expect(navigation).toContain('group: "系统设置"');
expect(navigation).toContain("item.children?.length ? item.children : [item]");
});
it("keeps notification and update preferences lightweight", () => {
const serverSettings = readDesktopServerSettingsSource();
const preferences = readDesktopPreferencesSource();
const desktopUpdateManager = readDesktopUpdateManagerSource();
const desktopLayout = readDesktopLayoutSource();
expect(serverSettings).toContain("connectionDiagnostic");
expect(serverSettings).not.toContain("复制连接诊断");
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(["@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).toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus");
expect(desktopLayout).toContain("新版本 ${pending.version}");
});
it("routes user-facing file actions through feedback helpers", () => {
const helper = readFileTaskFeedbackSource();
const attachments = readAttachmentListSource();
const documentDetail = readDocumentDetailSource();
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;");
});
});
@@ -154,7 +154,7 @@ import type {
} from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import { saveFile } from "@/runtime";
import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false,
@@ -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 saveFile({ 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 = () => {
+3 -2
View File
@@ -29,7 +29,8 @@
import { computed } from "vue";
import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display";
import { clientRuntime, pickFiles } from "../runtime";
import { clientRuntime } from "../runtime";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -65,7 +66,7 @@ const fileListProxy = computed({
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
const pickNativeAttachments = async () => {
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
const files = await pickFilesWithFeedback({ multiple: true, title: TEXT.common.labels.attachments });
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
const additions: UploadUserFile[] = files
.filter((file) => !existing.has(`${file.name}:${file.size}`))
+2 -2
View File
@@ -47,8 +47,8 @@
<script setup lang="ts">
import { onBeforeUnmount, reactive, watch } from "vue";
import { downloadAttachment } from "../api/attachments";
import { saveFile } from "../runtime";
import { displayDateTime, displayUser } from "../utils/display";
import { saveFileWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -97,7 +97,7 @@ const loadImageUrls = async () => {
const download = async (id: string) => {
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
const response = await downloadAttachment(id);
await saveFile({
await saveFileWithFeedback({
suggestedName: file?.filename || "attachment",
mimeType: response.headers?.["content-type"] || file?.content_type,
data: response.data,
+27 -34
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>
@@ -405,7 +395,7 @@
class="desktop-preferences-dialog"
:show-close="false"
:close-on-click-modal="true"
width="720px"
width="940px"
align-center
destroy-on-close
>
@@ -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),
);
@@ -779,7 +764,7 @@ const handleDesktopMenuCommand = (command: string) => {
return;
}
if (command === "ctms.desktop.serverSettings") {
router.push("/desktop/server-settings");
openDesktopPreferences();
return;
}
if (command === "ctms.desktop.refresh") {
@@ -839,12 +824,10 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
},
{
id: "desktop:server-settings",
title: "服务器设置",
title: "连接设置",
group: "桌面",
visible: true,
run: () => {
void router.push("/desktop/server-settings");
},
run: openDesktopPreferences,
},
{
id: "desktop:update",
@@ -1086,7 +1069,6 @@ watch(() => route.path, () => {
if (isDesktop) {
const current = currentDesktopRoutePreference.value;
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
refreshDesktopRoutePreferences();
}
}
@@ -1147,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);
}
@@ -2506,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) {
@@ -2517,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) {
@@ -2529,7 +2522,7 @@ useDesktopShortcuts(
}
:global(.desktop-preferences-dialog .el-dialog__body) {
padding: 20px;
background: #f8fafc;
padding: 0;
background: transparent;
}
</style>
@@ -152,7 +152,9 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
import { clientRuntime } from "../../runtime";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "../../session/desktopActivityCenter";
type AttachmentEntityGroup = {
entityType: string;
@@ -252,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) {
@@ -280,7 +283,7 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
const pickPendingNative = async (group: UploadGroup) => {
if (!canUploadGroup(group)) return;
const selected = await pickFiles({ multiple: true, title: group.label });
const selected = await pickFilesWithFeedback({ multiple: true, title: group.label });
selected.forEach((file) => queuePendingFile(group.key, file));
};
@@ -292,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);
}
},
});
@@ -312,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[] = [];
@@ -319,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) => {
@@ -339,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;
@@ -351,7 +395,7 @@ const uploadImmediate = async (options: any) => {
};
const pickImmediateNative = async () => {
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
const [file] = await pickFilesWithFeedback({ multiple: false, title: headerTitle.value });
if (file) await uploadImmediate({ file });
};
@@ -398,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 {
await saveFile({
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 {
await openFile({
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);
}
};
+12 -3
View File
@@ -95,11 +95,20 @@ export const buildAdminNavigationItems = (options: {
keywords: ["monitoring"],
},
{
label: "邮件服务",
label: "系统设置",
path: "/admin/email-settings",
group: TEXT.menu.admin,
icon: "settings",
keywords: ["email"],
keywords: ["settings"],
children: [
{
label: "邮件服务",
path: "/admin/email-settings",
group: "系统设置",
icon: "settings",
keywords: ["email", "settings"],
},
],
},
);
}
@@ -295,4 +304,4 @@ export const buildProjectNavigationItems = (options: {
};
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
items.flatMap((item) => [item, ...(item.children || [])]);
items.flatMap((item) => item.children?.length ? item.children : [item]);
-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) {
+3
View File
@@ -1,5 +1,6 @@
import { resolveApiBaseUrl } from "./apiBaseUrl";
import { getAppMetadata } from "./appMetadata";
import * as dataCache from "./desktopDataCache";
import * as files from "./files";
import * as notifications from "./notifications";
import * as updates from "./updates";
@@ -33,6 +34,7 @@ export interface ClientRuntime {
files: typeof files;
notifications: typeof notifications;
updates: typeof updates;
dataCache: typeof dataCache;
}
export const clientRuntime: ClientRuntime = {
@@ -47,6 +49,7 @@ export const clientRuntime: ClientRuntime = {
files,
notifications,
updates,
dataCache,
capabilities: () => ({
serverConfiguration: isTauriRuntime(),
nativeFiles: isTauriRuntime(),
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearDesktopDataCache,
getDesktopDataCacheGeneration,
getDesktopDataCacheStats,
invalidateDesktopDataCacheByTags,
isDesktopDataCacheGenerationCurrent,
readDesktopDataCache,
setDesktopDataCacheScope,
writeDesktopDataCache,
} from "./desktopDataCache";
describe("desktopDataCache", () => {
afterEach(() => {
setDesktopDataCacheScope(null);
clearDesktopDataCache();
});
it("isolates records by scope and clears records when scope changes", () => {
setDesktopDataCacheScope("user-a");
writeDesktopDataCache("studies", { items: [{ id: "study-a" }] }, { ttlMs: 1000, now: 100 });
expect(readDesktopDataCache("studies", 200)).toEqual({ items: [{ id: "study-a" }] });
setDesktopDataCacheScope("user-b");
expect(readDesktopDataCache("studies", 200)).toBeNull();
expect(getDesktopDataCacheStats()).toMatchObject({ scope: "user-b", entries: 0 });
});
it("expires records by ttl", () => {
writeDesktopDataCache("overview", { total: 3 }, { ttlMs: 100, now: 1000 });
expect(readDesktopDataCache("overview", 1099)).toEqual({ total: 3 });
expect(readDesktopDataCache("overview", 1100)).toBeNull();
});
it("invalidates records by tag", () => {
writeDesktopDataCache("sites", { items: [] }, { ttlMs: 1000, tags: ["study:1:sites"], now: 100 });
writeDesktopDataCache("members", { items: [] }, { ttlMs: 1000, tags: ["study:1:members"], now: 100 });
invalidateDesktopDataCacheByTags(["study:1:sites"]);
expect(readDesktopDataCache("sites", 200)).toBeNull();
expect(readDesktopDataCache("members", 200)).toEqual({ items: [] });
});
it("bumps generation when records are invalidated", () => {
const initialGeneration = getDesktopDataCacheGeneration();
writeDesktopDataCache("sites", { items: [] }, { ttlMs: 1000, tags: ["study:1:sites"], now: 100 });
invalidateDesktopDataCacheByTags(["study:1:sites"]);
expect(isDesktopDataCacheGenerationCurrent(initialGeneration)).toBe(false);
const afterTagInvalidation = getDesktopDataCacheGeneration();
clearDesktopDataCache();
expect(isDesktopDataCacheGenerationCurrent(afterTagInvalidation)).toBe(false);
});
});
+140
View File
@@ -0,0 +1,140 @@
export interface DesktopDataCacheWriteOptions {
ttlMs: number;
tags?: string[];
now?: number;
}
export interface DesktopDataCacheStats {
scope: string;
entries: number;
estimatedBytes: number;
oldestUpdatedAt: number | null;
newestUpdatedAt: number | null;
}
interface DesktopDataCacheRecord {
value: unknown;
expiresAt: number;
createdAt: number;
updatedAt: number;
lastAccessedAt: number;
tags: string[];
estimatedBytes: number;
}
const DEFAULT_SCOPE = "anonymous";
const MAX_MEMORY_CACHE_ENTRIES = 300;
const records = new Map<string, DesktopDataCacheRecord>();
let currentScope = DEFAULT_SCOPE;
let cacheGeneration = 0;
const normalizeScope = (scope?: string | null) => {
const normalized = String(scope || "").trim();
return normalized || DEFAULT_SCOPE;
};
const scopedKey = (key: string) => `${currentScope}:${key}`;
const cloneValue = <T>(value: T): T => {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
};
const estimateBytes = (value: unknown) => {
try {
return new Blob([JSON.stringify(value)]).size;
} catch {
return 0;
}
};
const pruneExpired = (now: number) => {
for (const [key, record] of records.entries()) {
if (record.expiresAt <= now) {
records.delete(key);
}
}
};
const enforceEntryLimit = () => {
if (records.size <= MAX_MEMORY_CACHE_ENTRIES) return;
const sorted = [...records.entries()].sort(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt);
const removeCount = records.size - MAX_MEMORY_CACHE_ENTRIES;
sorted.slice(0, removeCount).forEach(([key]) => records.delete(key));
};
export const setDesktopDataCacheScope = (scope?: string | null): void => {
const nextScope = normalizeScope(scope);
if (nextScope === currentScope) return;
records.clear();
currentScope = nextScope;
cacheGeneration += 1;
};
export const getDesktopDataCacheScope = (): string => currentScope;
export const getDesktopDataCacheGeneration = (): number => cacheGeneration;
export const isDesktopDataCacheGenerationCurrent = (generation: number): boolean => generation === cacheGeneration;
export const readDesktopDataCache = <T = unknown>(key: string, now: number = Date.now()): T | null => {
const recordKey = scopedKey(key);
const record = records.get(recordKey);
if (!record) return null;
if (record.expiresAt <= now) {
records.delete(recordKey);
return null;
}
record.lastAccessedAt = now;
return cloneValue(record.value as T);
};
export const writeDesktopDataCache = <T = unknown>(
key: string,
value: T,
options: DesktopDataCacheWriteOptions,
): void => {
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) return;
const now = options.now ?? Date.now();
pruneExpired(now);
records.set(scopedKey(key), {
value: cloneValue(value),
expiresAt: now + options.ttlMs,
createdAt: now,
updatedAt: now,
lastAccessedAt: now,
tags: [...(options.tags || [])],
estimatedBytes: estimateBytes(value),
});
enforceEntryLimit();
};
export const invalidateDesktopDataCacheByTags = (tags: string[]): void => {
if (!tags.length) return;
const tagSet = new Set(tags);
for (const [key, record] of records.entries()) {
if (record.tags.some((tag) => tagSet.has(tag))) {
records.delete(key);
}
}
cacheGeneration += 1;
};
export const clearDesktopDataCache = (): void => {
records.clear();
cacheGeneration += 1;
};
export const getDesktopDataCacheStats = (): DesktopDataCacheStats => {
const updatedAtValues = [...records.values()].map((record) => record.updatedAt);
return {
scope: currentScope,
entries: records.size,
estimatedBytes: [...records.values()].reduce((sum, record) => sum + record.estimatedBytes, 0),
oldestUpdatedAt: updatedAtValues.length ? Math.min(...updatedAtValues) : null,
newestUpdatedAt: updatedAtValues.length ? Math.max(...updatedAtValues) : null,
};
};
@@ -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 =>
+21 -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,
@@ -29,6 +27,19 @@ export {
listenDesktopMenuCommand,
type DesktopMenuCommand,
} from "./desktopMenu";
export {
clearDesktopDataCache,
getDesktopDataCacheGeneration,
getDesktopDataCacheScope,
getDesktopDataCacheStats,
invalidateDesktopDataCacheByTags,
isDesktopDataCacheGenerationCurrent,
readDesktopDataCache,
setDesktopDataCacheScope,
writeDesktopDataCache,
type DesktopDataCacheStats,
type DesktopDataCacheWriteOptions,
} from "./desktopDataCache";
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export {
cleanupTemporaryFiles,
@@ -43,6 +54,7 @@ export {
getNotificationPermission,
requestNotificationPermission,
showSystemNotification,
showSystemNotificationProbe,
type NotificationPermissionState,
} from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
@@ -53,6 +65,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();
};
@@ -0,0 +1,202 @@
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";
const invokeMock = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/api/core", () => ({
invoke: invokeMock,
}));
const SERVER_ORIGIN = "https://ctms.example.com/";
const DESKTOP_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
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`;
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("secure session storage", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T00:00:00.000Z"));
resetSecureSessionStorageForTests();
Object.defineProperty(window, "localStorage", { value: createStorage(), configurable: true });
localStorage.clear();
localStorage.setItem(DESKTOP_SERVER_URL_KEY, SERVER_ORIGIN);
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
invokeMock.mockReset();
invokeMock.mockResolvedValue(undefined);
});
afterEach(() => {
vi.useRealTimers();
resetSecureSessionStorageForTests();
localStorage.clear();
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
});
it("stores desktop tokens as a 30 day secure session record", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
await setSessionToken(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 });
expect(stored.expiresAt - stored.storedAt).toBe(DESKTOP_SESSION_MAX_AGE_MS);
});
it("restores a valid desktop secure session record on startup", async () => {
const token = createJwt(Date.now() + DESKTOP_SESSION_MAX_AGE_MS);
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();
expect(getSessionToken()).toBe(token);
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) => {
if (command === "credential_get") {
return JSON.stringify({
version: 1,
token,
storedAt: Date.now() - DESKTOP_SESSION_MAX_AGE_MS - 1_000,
expiresAt: Date.now() - 1_000,
});
}
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBeNull();
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) => {
if (command === "credential_get") return token;
return undefined;
});
await initializeSecureSessionStorage();
expect(getSessionToken()).toBe(token);
expect(invokeMock).toHaveBeenCalledWith("credential_set", {
serverOrigin: SERVER_ORIGIN,
token: expect.stringContaining(token),
});
});
});
+62 -6
View File
@@ -1,7 +1,14 @@
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;
type StoredSessionToken = {
token: string;
needsRewrite: boolean;
};
let cachedToken: string | null = null;
let initialized = false;
@@ -29,6 +36,57 @@ const isUsableJwt = (token: string): boolean => {
}
};
const serializeSessionToken = (token: string, now: number = Date.now()): string =>
JSON.stringify({
version: DESKTOP_SESSION_RECORD_VERSION,
token,
storedAt: now,
expiresAt: now + DESKTOP_SESSION_MAX_AGE_MS,
});
const parseStoredSessionToken = (stored: string | null, now: number = Date.now()): StoredSessionToken | null => {
if (!stored) return null;
try {
const payload = JSON.parse(stored) as {
version?: unknown;
token?: unknown;
storedAt?: unknown;
expiresAt?: unknown;
};
if (payload.version !== DESKTOP_SESSION_RECORD_VERSION || typeof payload.token !== "string") {
return null;
}
if (
typeof payload.storedAt !== "number" ||
typeof payload.expiresAt !== "number" ||
!Number.isFinite(payload.storedAt) ||
!Number.isFinite(payload.expiresAt)
) {
return null;
}
const localExpiresAt = Math.min(payload.expiresAt, payload.storedAt + DESKTOP_SESSION_MAX_AGE_MS);
if (localExpiresAt <= now || !isUsableJwt(payload.token)) return null;
return { token: payload.token, needsRewrite: false };
} catch {
return isUsableJwt(stored) ? { token: stored, needsRewrite: true } : null;
}
};
const readCredentialToken = async (serverOrigin: string): Promise<string | null> => {
const stored = await invokeCredential<string | null>("credential_get", { serverOrigin });
const parsed = parseStoredSessionToken(stored);
if (parsed?.needsRewrite) {
await invokeCredential<void>("credential_set", {
serverOrigin,
token: serializeSessionToken(parsed.token),
});
}
if (!parsed && stored) {
await invokeCredential<void>("credential_delete", { serverOrigin });
}
return parsed?.token ?? null;
};
export const initializeSecureSessionStorage = async (): Promise<void> => {
if (initialized) return;
initialized = true;
@@ -52,13 +110,11 @@ export const initializeSecureSessionStorage = async (): Promise<void> => {
if (legacyToken && isUsableJwt(legacyToken)) {
await invokeCredential<void>("credential_set", {
serverOrigin: activeServerOrigin,
token: legacyToken,
token: serializeSessionToken(legacyToken),
});
cachedToken = legacyToken;
} else {
cachedToken = await invokeCredential<string | null>("credential_get", {
serverOrigin: activeServerOrigin,
});
cachedToken = await readCredentialToken(activeServerOrigin);
}
secureStorageAvailable = true;
} catch (error) {
@@ -87,7 +143,7 @@ export const setSessionToken = async (token: string): Promise<void> => {
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
await invokeCredential<void>("credential_set", { serverOrigin, token });
await invokeCredential<void>("credential_set", { serverOrigin, token: serializeSessionToken(token) });
activeServerOrigin = serverOrigin;
cachedToken = token;
initialized = true;
+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,
});
});
});
+166 -8
View File
@@ -5,11 +5,13 @@ 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;
const POSTPONE_MS = 24 * 60 * 60 * 1000;
const POSTPONE_PREFIX = "ctms_desktop_update_postponed:";
export const DESKTOP_UPDATE_STATUS_CHANGED_EVENT = "ctms:desktop-update-status-changed";
let initialized = false;
let checkTimer: number | null = null;
@@ -18,10 +20,60 @@ let promptVisible = false;
export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed";
export interface DesktopUpdateStatusSnapshot {
available: boolean;
checking: boolean;
installing: boolean;
lastStatus: DesktopUpdateCheckStatus | "idle";
lastCheckedAt: string | null;
lastError: string;
pendingUpdate: DesktopUpdateInfo | null;
postponedUntil: string | null;
}
export interface DesktopUpdateCheckOptions {
notifyWhenCurrent?: boolean;
promptWhenAvailable?: boolean;
}
const updateStatus: DesktopUpdateStatusSnapshot = {
available: isDesktopUpdaterAvailable(),
checking: false,
installing: false,
lastStatus: "idle",
lastCheckedAt: null,
lastError: "",
pendingUpdate: null,
postponedUntil: null,
};
const snapshotUpdateStatus = (): DesktopUpdateStatusSnapshot => ({ ...updateStatus });
const emitUpdateStatus = () => {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, {
detail: snapshotUpdateStatus(),
}),
);
};
const setUpdateStatus = (patch: Partial<DesktopUpdateStatusSnapshot>) => {
Object.assign(updateStatus, patch);
emitUpdateStatus();
};
export const getDesktopUpdateStatus = (): DesktopUpdateStatusSnapshot => snapshotUpdateStatus();
export const listenDesktopUpdateStatus = (listener: (status: DesktopUpdateStatusSnapshot) => void) => {
if (typeof window === "undefined") return () => {};
const onStatusChange = (event: Event) => {
listener((event as CustomEvent<DesktopUpdateStatusSnapshot>).detail);
};
window.addEventListener(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, onStatusChange);
return () => window.removeEventListener(DESKTOP_UPDATE_STATUS_CHANGED_EVENT, onStatusChange);
};
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
const getPostponeUntil = (version: string): number => {
@@ -34,25 +86,58 @@ const getPostponeUntil = (version: string): number => {
};
const postponeVersion = (version: string) => {
const until = Date.now() + POSTPONE_MS;
try {
window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS));
window.localStorage.setItem(postponeKey(version), String(until));
} catch {
/* ignore */
}
setUpdateStatus({ lastStatus: "postponed", postponedUntil: new Date(until).toISOString() });
};
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 {
@@ -62,13 +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 {
@@ -76,21 +168,86 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
}
};
export const promptForPendingDesktopUpdate = async (): Promise<DesktopUpdateCheckStatus> => {
if (updateStatus.pendingUpdate) {
const activityId = startDesktopActivity({
kind: "update",
title: "安装桌面更新",
detail: "等待确认更新",
progress: 30,
});
return promptForUpdate(updateStatus.pendingUpdate, activityId);
}
return checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true, promptWhenAvailable: true });
};
export const checkDesktopUpdateAndPrompt = async (
options: DesktopUpdateCheckOptions = {},
): Promise<DesktopUpdateCheckStatus> => {
const activityId = options.notifyWhenCurrent || options.promptWhenAvailable
? startDesktopActivity({ kind: "update", title: "检查桌面更新", detail: "正在连接更新服务", progress: 20 })
: "";
if (!isDesktopUpdaterAvailable()) {
if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新");
setUpdateStatus({
available: false,
checking: false,
installing: false,
lastStatus: "disabled",
lastCheckedAt: new Date().toISOString(),
lastError: "",
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();
if (update) {
return promptForUpdate(update);
const postponedUntilMs = getPostponeUntil(update.version);
const suppressed = postponedUntilMs > Date.now();
setUpdateStatus({
checking: false,
lastStatus: suppressed ? "suppressed" : "available",
lastCheckedAt: checkedAt,
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, activityId);
}
finishUpdateActivity(activityId, suppressed ? "cancelled" : "completed", {
detail: suppressed ? "已选择稍后提醒" : "发现可用更新",
});
return suppressed ? "suppressed" : "available";
}
if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本");
setUpdateStatus({
checking: false,
lastStatus: "up-to-date",
lastCheckedAt: checkedAt,
lastError: "",
pendingUpdate: null,
postponedUntil: null,
});
finishUpdateActivity(activityId, "completed", { detail: "当前已是最新版本" });
return "up-to-date";
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : "桌面端更新检查失败";
setUpdateStatus({
checking: false,
lastStatus: "failed",
lastCheckedAt: new Date().toISOString(),
lastError: message,
});
finishUpdateActivity(activityId, "failed", { detail: "更新检查失败" });
// 启动和定时检查不打断录入;下一轮继续检查。
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
return "failed";
@@ -119,4 +276,5 @@ export const stopDesktopUpdateManager = () => {
}
initialized = false;
promptVisible = false;
setUpdateStatus({ checking: false, installing: false });
};
+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");
+17 -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 { clearApiResponseCache, setApiResponseCacheScope, type ApiRequestConfig } from "../api/axios";
import { setToken, clearToken, getToken } from "../utils/auth";
import { encryptLoginPayload } from "../utils/loginCrypto";
import type { UserInfo } from "../types/api";
@@ -17,9 +18,10 @@ 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 {
clearApiResponseCache();
const { data } =
allowInsecureDevLogin && !window.isSecureContext
? await devLogin({ email, password })
@@ -29,10 +31,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,23 +57,27 @@ 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;
setApiResponseCacheScope(data?.id || data?.email || null);
if (data?.email) {
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
}
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;
setApiResponseCacheScope(null);
sessionStore.resetActivity();
await clearToken();
studyStore.clearCurrentStudy();
+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;
}
}
+20 -6
View File
@@ -7,6 +7,20 @@
--unified-shell-padding-y: 10px;
--unified-title-color: #0f2345;
--unified-muted-color: #6f84a8;
--unified-table-header-bg: #f7f9fc;
--unified-table-header-color: #34506f;
--unified-row-divider: #edf2f8;
}
:root[data-ctms-theme="dark"] {
--unified-shell-bg: var(--ctms-bg-card);
--unified-shell-border: var(--ctms-border-color);
--unified-shell-divider: var(--ctms-border-color);
--unified-title-color: var(--ctms-text-main);
--unified-muted-color: var(--ctms-text-secondary);
--unified-table-header-bg: var(--ctms-bg-muted);
--unified-table-header-color: var(--ctms-text-main);
--unified-row-divider: var(--ctms-border-color);
}
.ctms-page-shell {
@@ -85,7 +99,7 @@
.page>.page-header.unified-action-bar {
border: 0;
border-radius: var(--unified-shell-radius);
background: #ffffff;
background: var(--unified-shell-bg);
box-shadow: none;
}
@@ -156,15 +170,15 @@
}
.unified-shell .el-table th.el-table__cell {
background-color: #f7f9fc;
color: #34506f;
background-color: var(--unified-table-header-bg);
color: var(--unified-table-header-color);
font-weight: 600;
height: 40px;
font-size: 13px;
}
.unified-shell .el-table td.el-table__cell {
border-bottom-color: #edf2f8;
border-bottom-color: var(--unified-row-divider);
padding: 7px 0;
}
@@ -178,7 +192,7 @@
}
.unified-shell .el-tabs__nav-wrap::after {
background-color: #edf2f8;
background-color: var(--unified-row-divider);
}
.unified-shell .el-tabs__item {
@@ -263,4 +277,4 @@
padding-left: 14px;
padding-right: 14px;
}
}
}
+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();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { ElMessage } from "element-plus";
import {
clientRuntime,
openFile,
pickFiles,
saveFile,
type FileOutput,
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) {
ElMessage.success(selectedFilesMessage(files.length));
}
return files;
};
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;
}
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,
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;
}
};
File diff suppressed because it is too large Load Diff
@@ -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
+125 -15
View File
@@ -22,6 +22,19 @@
:closable="false"
/>
<div v-if="connectionDiagnostic" class="connection-diagnostic">
<div class="diagnostic-grid">
<span>检查时间</span>
<strong>{{ connectionDiagnostic.checkedAt }}</strong>
<span>健康检查</span>
<code>{{ connectionDiagnostic.healthUrl }}</code>
<span>耗时</span>
<strong>{{ connectionDiagnostic.durationMs }}ms</strong>
<span>HTTP</span>
<strong>{{ connectionDiagnostic.httpStatus || "-" }}</strong>
</div>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError">
<el-input
@@ -33,10 +46,6 @@
/>
</el-form-item>
<div class="hint">
允许 HTTPS 服务地址本地开发可使用 http://localhost http://127.0.0.1
</div>
<div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
@@ -51,7 +60,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
@@ -65,13 +74,46 @@ const serverUrl = ref(currentServerUrl || "");
const urlError = ref("");
const saving = ref(false);
const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null);
const connectionDiagnostic = ref<{
healthUrl: string;
checkedAt: string;
durationMs: number;
httpStatus?: number;
} | null>(null);
const canCancel = computed(() => Boolean(currentServerUrl));
const HEALTH_TIMEOUT_MS = 10_000;
const formatDiagnosticTime = (date = new Date()) =>
date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const toConnectionError = (
message: string,
details: {
serverUrl: string;
healthUrl: string;
durationMs: number;
httpStatus?: number;
},
) => Object.assign(new Error(message), details);
const checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString();
const controller = new AbortController();
const startedAt = performance.now();
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
const details = () => ({
serverUrl: baseUrl,
healthUrl,
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
});
try {
const response = await fetch(healthUrl, {
method: "GET",
@@ -79,14 +121,21 @@ const checkHealth = async (baseUrl: string) => {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
throw toConnectionError(`服务器健康检查返回 HTTP ${response.status}`, {
...details(),
httpStatus: response.status,
});
}
return {
...details(),
httpStatus: response.status,
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("连接超时,请确认服务端地址和网络状态");
throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
}
if (error instanceof TypeError) {
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
}
throw error;
} finally {
@@ -95,10 +144,25 @@ const checkHealth = async (baseUrl: string) => {
};
const clearSessionForServerChange = async () => {
await auth.logout();
await auth.logout({ rememberCurrentStudy: false });
studyStore.clearCurrentStudy();
};
const confirmServerChange = async (previous: string | null, next: string) => {
if (!previous || previous === next) return true;
const confirmed = await ElMessageBox.confirm(
"切换服务器会退出当前会话并清除当前项目上下文,确认后需要重新登录。",
"确认切换服务器",
{
type: "warning",
confirmButtonText: "切换并退出登录",
cancelButtonText: "继续编辑",
distinguishCancelAndClose: true,
},
).catch(() => null);
return Boolean(confirmed);
};
const save = async () => {
urlError.value = "";
connectionStatus.value = null;
@@ -110,8 +174,9 @@ const save = async () => {
saving.value = true;
try {
await checkHealth(normalized.url);
const health = await checkHealth(normalized.url);
const previous = getDesktopServerUrl();
if (!(await confirmServerChange(previous, normalized.url))) return;
const result = setDesktopServerUrl(normalized.url);
if (!result.ok) {
urlError.value = result.reason;
@@ -125,15 +190,32 @@ const save = async () => {
title: "连接已确认",
message: result.url,
};
ElMessage.success("服务器连接已确认");
connectionDiagnostic.value = {
healthUrl: health.healthUrl,
checkedAt: formatDiagnosticTime(),
durationMs: health.durationMs,
httpStatus: health.httpStatus,
};
router.replace("/login");
} catch (error) {
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
connectionStatus.value = {
type: "error",
title: "连接检查失败",
message,
};
connectionDiagnostic.value = {
healthUrl: details.healthUrl || new URL("health", normalized.url).toString(),
checkedAt: formatDiagnosticTime(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
};
urlError.value = message;
} finally {
saving.value = false;
@@ -147,6 +229,7 @@ const goBack = () => {
<style scoped>
.desktop-settings-page {
box-sizing: border-box;
min-height: 100vh;
display: flex;
align-items: center;
@@ -157,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;
@@ -215,11 +300,36 @@ h1 {
margin-bottom: 18px;
}
.hint {
margin-top: -8px;
.connection-diagnostic {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
padding: 12px;
border: 1px solid #dbe6f2;
border-radius: 8px;
background: #fbfdff;
}
.diagnostic-grid {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 6px 10px;
min-width: 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
font-size: 12px;
}
.diagnostic-grid strong {
min-width: 0;
color: #1e293b;
font-weight: 700;
}
.diagnostic-grid code {
min-width: 0;
overflow-wrap: anywhere;
}
.actions {
@@ -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;
+82 -274
View File
@@ -1,114 +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>
<section class="form-section form-section--desktop">
<div class="section-heading">
<span class="section-kicker">Desktop</span>
<h4>客户端与通知</h4>
</div>
<el-form-item v-if="isDesktop" label="系统通知">
<div class="desktop-setting-stack">
<div class="desktop-setting-row">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
</div>
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
</div>
</el-form-item>
<el-form-item v-if="isDesktop && desktopUpdaterAvailable" label="桌面更新">
<div class="desktop-setting-row">
<el-button size="small" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
检查更新
</el-button>
<span class="desktop-setting-hint">正式版本会按发布源检查签名更新</span>
</div>
</el-form-item>
<el-form-item label="客户端信息">
<div class="client-metadata-panel">
<dl class="client-metadata-list">
<template v-for="row in clientMetadataRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<el-button size="small" @click="copyClientMetadata">复制</el-button>
</div>
</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>
@@ -118,25 +77,9 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth";
import {
clientRuntime,
getAppMetadata,
getDesktopServerUrl,
getNotificationPermission,
isDesktopUpdaterAvailable,
isTauriRuntime,
pickFiles,
requestNotificationPermission,
type NotificationPermissionState,
} from "../runtime";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
const emit = defineEmits<{
"close-request": [];
@@ -144,31 +87,6 @@ const emit = defineEmits<{
saved: [];
}>();
const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const clientMetadataRows = [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{ label: "服务器", value: getDesktopServerUrl() || "未配置" },
{
label: "能力",
value: [
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
];
const clientMetadataText = clientMetadataRows.map((row) => `${row.label}: ${row.value}`).join("\n");
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopUpdateChecking = ref(false);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
@@ -192,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" }],
@@ -247,72 +164,6 @@ const loadProfile = async () => {
avatarPreview.value = data.avatar_url || undefined;
};
const loadDesktopNotificationSubscription = async () => {
if (!isDesktop) return;
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
};
const notificationPermissionText = computed(() => {
if (!isDesktop) return "不可用";
if (notificationPermission.value === "granted") return "系统已允许";
if (notificationPermission.value === "denied") return "系统已拒绝";
if (notificationPermission.value === "prompt") return "等待授权";
return "不可用";
});
const notificationPermissionTagType = computed<"success" | "warning" | "danger" | "info">(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (!isDesktop || desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
const enable = Boolean(value);
if (enable) {
const permission = await requestNotificationPermission();
notificationPermission.value = permission;
if (permission !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
}
const { data } = await setDesktopNotificationSubscription(enable);
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const copyClientMetadata = async () => {
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(clientMetadataText);
ElMessage.success("客户端信息已复制");
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
try {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
} finally {
desktopUpdateChecking.value = false;
}
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
@@ -345,7 +196,7 @@ const onSubmit = async () => {
};
const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar,
@@ -368,27 +219,32 @@ const selectAndUploadAvatar = async () => {
onMounted(() => {
loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
});
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
</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 {
@@ -442,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 {
@@ -483,59 +344,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2;
}
.form-section--desktop {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.desktop-setting-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.desktop-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.desktop-setting-hint {
color: #7f92ad;
font-size: 12px;
}
.client-metadata-panel {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.client-metadata-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
flex: 1;
min-width: 0;
margin: 0;
color: #40566f;
font-size: 12px;
}
.client-metadata-list dt {
color: #7f92ad;
font-weight: 700;
}
.client-metadata-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
-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");
/*
根容器
*/
+2 -2
View File
@@ -1954,7 +1954,7 @@ import {
type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type SetupStepKey =
| "project-info"
@@ -5345,7 +5345,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
await saveFile({
await saveFileWithFeedback({
suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob,
@@ -378,8 +378,8 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { openFile, pickFiles, saveFile } from "../../runtime";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const route = useRoute();
const auth = useAuthStore();
@@ -524,7 +524,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
}));
const triggerFileInput = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本",
@@ -813,7 +813,7 @@ const downloadVersion = async (version: DocumentVersion) => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
};
@@ -822,7 +822,7 @@ const openVersion = async (version: DocumentVersion) => {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFile({
await openFileWithFeedback({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
@@ -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);");
});
});

Some files were not shown because too many files have changed in this diff Show More