11 Commits

Author SHA1 Message Date
Cheng Zhou d082920ae1 style: 优化个人中心和偏好设置弹窗样式,重构工作入口为精致分屏布局并移除首字徽标 2026-07-08 20:20:41 +08:00
Cheng Zhou 7d88d6053a 优化桌面端界面布局 2026-07-07 11:11:43 +08:00
Cheng Zhou 4aa1eb00b6 feat(desktop): 收口桌面工作台视觉与活动反馈 2026-07-02 15:52:16 +08:00
Cheng Zhou 5b387226a3 完善桌面体验与系统通知收口 2026-07-02 15:13:08 +08:00
Cheng Zhou a7b631f468 完善桌面端回归与安全边界复审 2026-07-02 10:43:30 +08:00
Cheng Zhou c385ca7de9 补齐桌面端附件文件流回归 2026-07-02 10:13:45 +08:00
Cheng Zhou 9a470d3a75 完善桌面端端到端回归收口 2026-07-02 10:04:31 +08:00
Cheng Zhou 965c38d3b2 完善桌面端发布稳定化门禁 2026-07-02 09:41:55 +08:00
Cheng Zhou f33cc0948e feat(desktop): 支持桌面端三十天免登录 2026-07-02 09:06:24 +08:00
Cheng Zhou 46f488c8ae feat(desktop): 稳定桌面端界面与文件操作反馈
- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试
2026-07-01 17:04:23 +08:00
Cheng Zhou d60a2fa5b2 fix(auth): 支持无邮箱后缀时手动输入 2026-07-01 14:37:57 +08:00
95 changed files with 11448 additions and 1695 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 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 fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession 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) 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( access_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
expires_minutes=None, expires_minutes=policy.access_minutes,
session_start=session_start, 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") 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) @router.post("/login", response_model=Token)
async def login_for_access_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: ) -> Token:
db_user = await authenticate_encrypted_password(payload, db) db_user = await authenticate_encrypted_password(payload, db)
ensure_user_active(db_user) 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) @router.post("/dev-login", response_model=Token)
async def dev_login_for_access_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: ) -> Token:
if settings.ENV != "development": if settings.ENV != "development":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
db_user = await authenticate_plain_password(payload, db) db_user = await authenticate_plain_password(payload, db)
ensure_user_active(db_user) ensure_user_active(db_user)
return issue_user_token(db_user) return issue_user_token(db_user, request)
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
@@ -285,19 +324,23 @@ async def extend_access_token(
if db_user.status != UserStatus.ACTIVE: if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat") 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: if session_start_ts:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600 if now_ts - int(session_start_ts) > policy.absolute_max_seconds:
if now_ts - int(session_start_ts) > max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc) session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else: else:
session_start = datetime.now(timezone.utc) session_start = datetime.now(timezone.utc)
issued_at = datetime.now(timezone.utc)
new_token = create_access_token( new_token = create_access_token(
user_id=str(db_user.id), user_id=str(db_user.id),
expires_minutes=None, expires_minutes=policy.access_minutes,
session_start=session_start, 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) return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
+1
View File
@@ -19,6 +19,7 @@ class Settings(BaseSettings):
JWT_EXPIRE_MINUTES: int = 60 JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120 JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8 ABSOLUTE_SESSION_MAX_HOURS: int = 8
DESKTOP_SESSION_MAX_DAYS: int = 30
LOGIN_RSA_PRIVATE_KEY: Optional[str] = None LOGIN_RSA_PRIVATE_KEY: Optional[str] = None
LOGIN_RSA_PUBLIC_KEY: Optional[str] = None LOGIN_RSA_PUBLIC_KEY: Optional[str] = None
LOGIN_RSA_KEY_ID: str = "default" LOGIN_RSA_KEY_ID: str = "default"
+14 -1
View File
@@ -19,16 +19,29 @@ def create_access_token(
user_id: str, user_id: str,
expires_minutes: Optional[int] = None, expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None, session_start: Optional[datetime] = None,
max_age_seconds: Optional[int] = None,
issued_at: Optional[datetime] = None,
client_type: Optional[str] = None,
) -> str: ) -> 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) expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
session_start_time = session_start or now 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] = { to_encode: Dict[str, Any] = {
"sub": user_id, "sub": user_id,
"exp": expire, "exp": expire,
"iat": int(now.timestamp()), "iat": int(now.timestamp()),
"orig_iat": int(session_start_time.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) 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.main import create_app
from app.core.config import settings from app.core.config import settings
from app.core.deps import get_db_session 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.crud import user as user_crud
from app.db.base_class import Base from app.db.base_class import Base
from app.models.audit_log import AuditLog 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"] 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 @pytest.mark.asyncio
async def test_admin_created_user_is_active_by_default(client_and_db): async def test_admin_created_user_is_active_by_default(client_and_db):
client, SessionLocal = client_and_db client, SessionLocal = client_and_db
@@ -2,7 +2,7 @@
状态: `active` 状态: `active`
适用范围: Web 与 macOS Desktop 统一客户端发布 适用范围: Web 与 macOS Desktop 统一客户端发布
最后更新: `2026-07-01` 最后更新: `2026-07-02`
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。 本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。
@@ -28,10 +28,12 @@ npm run desktop:build:app
- [ ] `frontend/package.json``package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。 - [ ] `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` 通过。 - [ ] `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 已签名和公证。 - [ ] macOS app 已签名和公证。
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。 - [ ] 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` - [ ] 设置 `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:check -- --feed <latest.json> --artifacts-dir <artifact-dir>` - [ ] 正式 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` - [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。 - [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
@@ -48,12 +50,19 @@ npm run desktop:build:app
- [ ] Tauri command 白名单仅包含凭据和更新命令。 - [ ] Tauri command 白名单仅包含凭据和更新命令。
- [ ] 前端源码不通过 query string 传递 token。 - [ ] 前端源码不通过 query string 传递 token。
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。 - [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
- [ ] 登录表单密码不写入 `localStorage``sessionStorage`;Web 端只使用浏览器凭据管理能力,Desktop 端只使用系统凭据库。
- [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。 - [ ] 系统通知只能通过 `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 门禁。 - [ ] 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、日志、系统通知正文、下载链接或持久化业务缓存中。 - [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。
- [ ] 密码不出现在 URL、日志、系统通知正文、诊断信息或明文浏览器存储中。
- [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。 - [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。
- [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。 - [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。
- [ ] Web 运行时不直接导入 Tauri API。 - [ ] Web 运行时不直接导入 Tauri API。
@@ -63,6 +72,8 @@ npm run desktop:build:app
| 场景 | Web | macOS Desktop | 预期 | | 场景 | Web | macOS Desktop | 预期 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 | | 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
| 记住密码 | 必测 | 必测 | Web 使用浏览器凭据管理/自动填充;Desktop 使用系统凭据库;未勾选时不继续写入保存密码 |
| 30 天免登录 | 不适用 | 必测 | 关闭并重启 App 后复用系统凭据库中的后端在线会话;超过 30 天或 `/me` 校验失败后重新登录 |
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 | | 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 | | 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 | | 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
@@ -80,6 +91,8 @@ npm run desktop:build:app
## 4. 桌面体验验收 ## 4. 桌面体验验收
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。 - [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
- [ ] 30 天免登录仍只保存系统凭据库会话记录,不把 token 写入 URL、日志、通知正文或业务缓存。
- [ ] 记住密码与 30 天免登录使用独立凭据记录;服务器切换后不复用旧服务器保存的密码。
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。 - [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。 - [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。 - [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
@@ -125,3 +138,87 @@ npm run desktop:build:app
- 签名后的 updater artifacts、`.sig`、checksum manifest 和 `latest.json` 在真实发布目录内通过 `npm run desktop:update-feed:check` - 签名后的 updater artifacts、`.sig`、checksum manifest 和 `latest.json` 在真实发布目录内通过 `npm run desktop:update-feed:check`
- 不可变制品上传完成后,再原子替换线上 `latest.json` - 不可变制品上传完成后,再原子替换线上 `latest.json`
- Desktop 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。 - 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 状态一致。
+3 -1
View File
@@ -34,13 +34,15 @@
- macOSKeychain。 - macOSKeychain。
- WindowsCredential Manager。 - WindowsCredential Manager。
桌面端登录使用后端签发的在线会话 token,可在系统凭据库中保存最长 30 天,以支持重启 App 后免输入密码。启动恢复后仍必须使用后端 token 校验和 `/auth/me` 用户状态校验;服务端不可达或会话被后端拒绝时不得进入离线模式。
Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。 Rust 仅暴露固定 service 下的读取、写入、删除命令。凭据 account 使用规范化服务端 origin 的 SHA-256,避免明文服务端地址散落在系统凭据项名称中。
应用挂载前异步初始化 token 应用挂载前异步初始化 token
1. Web 端继续读取 `localStorage.ctms_token` 1. Web 端继续读取 `localStorage.ctms_token`
2. 桌面端先删除 legacy `localStorage.ctms_token` 2. 桌面端先删除 legacy `localStorage.ctms_token`
3. 若 legacy token 仍有效,则迁移系统凭据库。 3. 若 legacy token 仍有效,则迁移为带 30 天本机到期时间的系统凭据库会话记录
4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。 4. 若迁移或读取凭据失败,则内存 token 置空并要求重新登录,不回退明文存储。
登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。 登出、服务器切换、认证失效时必须同步清除内存 token 和当前服务端 origin 对应的系统凭据。
+66 -2
View File
@@ -59,11 +59,13 @@
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。 - `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。 - `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。 - `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
- 桌面端允许保存后端签发的最长 30 天在线会话,用于重启 App 后免输入密码;该会话必须存放在系统凭据库中,启动后仍需由后端 token 和 `/me` 校验确认身份,不等同于离线登录。
- `savedLoginCredentials`:隔离网页端浏览器凭据管理与桌面端系统凭据库中的登录表单密码保存;不得把密码写入 `localStorage``sessionStorage`、URL、日志或通知正文,且不等同于离线登录。
- `files`:隔离浏览器上传下载与原生文件能力。 - `files`:隔离浏览器上传下载与原生文件能力。
- `notifications`:隔离 Web 通知与桌面系统通知。 - `notifications`:隔离 Web 通知与桌面系统通知。
- `updates`:隔离桌面自动更新检查与安装入口。 - `updates`:隔离桌面自动更新检查与安装入口。
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。 - `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
- `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。 - `desktopMenu``desktopUiPreferences`:承接桌面菜单命令、收藏模块等桌面体验状态。
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。 - `clientRuntime`:作为业务侧获取平台能力的聚合入口。
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。 业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
@@ -74,7 +76,7 @@
- 非本地服务连接优先使用 HTTPS。 - 非本地服务连接优先使用 HTTPS。
- 认证与授权决策保留在后端。 - 认证与授权决策保留在后端。
- 审计敏感决策保留在后端。 - 审计敏感决策保留在后端。
- 敏感凭据必须继续使用明确批准的安全存储方案。 - 敏感凭据必须继续使用明确批准的安全存储方案;网页端密码只能交给浏览器凭据管理能力,桌面端密码只能交给系统凭据库
- 不向前端暴露宽泛文件系统访问权限。 - 不向前端暴露宽泛文件系统访问权限。
- Tauri 权限保持最小化,并按功能精确授权。 - Tauri 权限保持最小化,并按功能精确授权。
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。 - 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
@@ -109,6 +111,68 @@
5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。 5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。
6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。 6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。
## 2026-07-02 发布稳定化推进记录
本次推进仍保持第一、二阶段边界,不引入离线、本地业务存储、内嵌后端或独立桌面业务 UI。
已补齐的发布稳定化自动化:
- 新增 `npm run desktop:build:macos-release`,用于 macOS Universal `app`/`dmg` 正式候选构建。
- 新增 `npm run desktop:update-feed:create`,从签名 updater artifact 和 `.sig` 生成 `latest.json``SHA256SUMS.txt`,并要求 artifact URL 使用包含当前版本号的 HTTPS 不可变路径。
- `npm run desktop:update-feed:check` 在传入 `--artifacts-dir` 时同步校验 `SHA256SUMS.txt`、updater artifact、`.sig``latest.json`
- 新增 `npm run desktop:release-readiness:check`,用于在进入正式签名候选构建前确认当前提交精确匹配 `vX.Y.Z` tag、构建元数据、签名/公证变量、updater 私钥和生产 artifact HTTPS 基址已经齐备。
- 新增 `.github/workflows/desktop-release-candidate.yml`,在 release tag 上执行签名 macOS 候选构建、feed 生成、feed 校验和 GitHub artifact 上传;它不替代人工发布审批和生产下载源原子替换。
- `npm run desktop:release:check` 已纳入上述发布候选工作流和脚本存在性检查,防止发布链路门禁被误删。
仍需正式发布负责人在真实发布环境完成:
- Apple Developer 签名、公证凭据和组织 updater 私钥配置。
- 从正式 `vX.Y.Z` tag 运行 signed macOS release candidate workflow。
- 将已校验的不可变制品上传到生产下载源,最后原子替换线上 `latest.json`
- 执行桌面端人工端到端回归、最小窗口体验验收和真实系统通知/自动更新验证。
进入端到端人工回归前,应先确认 `npm run desktop:release-readiness:check` 在正式 release tag 和签名环境中通过;否则只能进行普通 smoke 验证,不能判定 macOS 发布稳定化已经完成。
## 2026-07-02 安全边界复审推进记录
本次安全边界复审在端到端自动化收口之后继续推进,仍不引入离线能力、本地业务数据存储、内嵌后端或独立桌面业务 UI。
已补齐的安全边界自动化:
- Tauri 通知 capability 已从 `notification:default` 收敛为权限查询、权限请求和发送通知三项显式权限。
- `npm run desktop:release:check` 已拒绝 `notification:default`、opener URL/reveal 权限和 WebView 直连 updater 权限,继续要求文件与 opener scope 仅限 `$TEMP/ctms-desktop/**`
- `npm run desktop:release:check` 已扩展 token URL 和日志静态检查,覆盖 `token``access_token``Authorization``Bearer` 形态。
- 自动更新弹窗 release notes 已过滤 URL、token 查询参数和 Authorization/Bearer 形态文本,避免从 feed 将下载链接或凭据样式文本带入用户界面。
- Rust 凭据命令新增单测,确认带凭据 server origin 被拒绝,系统凭据库 account 使用 origin 哈希且不暴露原始服务器地址。
仍需正式发布负责人在真实发布环境确认:
- 生产 release notes 内容保持通用,不包含项目、文件、下载链接或敏感业务详情。
- 签名、公证、updater feed 和 artifact 上传日志不泄露 Apple 凭据、updater 私钥或下载源内部凭据。
## 2026-07-02 桌面体验收口推进记录
本次体验收口继续保持在线桌面客户端边界,不新增离线、本地业务存储或独立桌面业务 UI。
已补齐的桌面体验收口:
- 个人中心新增客户端诊断信息展示与复制能力,内容仅包含客户端类型、版本、平台、构建通道、提交、服务器和能力状态,不包含 token 或业务敏感数据。
- 桌面侧栏已移除被动“最近访问”入口和对应本地记录,只保留用户主动维护的收藏入口;收藏仅保存路由元数据,不保存业务数据或敏感凭据。
- 登录页、服务器设置页、个人中心和系统偏好增加最小窗口布局约束,长服务器地址、健康检查 URL、诊断值和更新/通知状态说明均可在容器内换行。
- 服务器设置页和个人中心在内容高度超过窗口时使用内部滚动,避免 `1180x760` 下对话框或面板溢出。
- 通知权限运行时已区分 WebView `Notification.permission` 的授权、拒绝和待授权状态;取消 macOS 权限请求时继续保持“待授权”,避免错误显示为“已拒绝”。
- 系统偏好通知页已移除授权成功态的固定标签文案,开启状态只由开关表达;待授权、已拒绝和不可用状态继续显示提示标签。
- 系统偏好通知页新增“测试通知”命令,完整走 `frontend/src/runtime/notifications.ts` -> `@tauri-apps/plugin-notification` 的系统通知发送链路;测试通知文案为固定通用内容,不包含项目、文件、token 或其他敏感业务信息。
- `showSystemNotification` 和测试通知调用会在发送前确认系统通知权限,只有真正发起系统通知时才返回成功;桌面通知轮询据此只 ack 已实际发起系统通知的后端通知。
- 真实 macOS `.app` 已验证系统偏好通知开关可调用系统通知权限并生效。验证截图位于 `/Users/zcc/Library/Application Support/CleanShot/media/media_boKznpjOwt/CleanShot 2026-07-02 at 11.07.52@2x.png`
- `Layout.desktop.test.ts` 已覆盖个人中心诊断、最小窗口断点、服务器设置滚动和偏好页控制区换行契约。
- `notifications.test.ts` 已覆盖通知权限状态映射和测试通知发送链路。
仍需真实桌面环境人工确认:
- macOS `.app``1180x760` 下的登录页、服务器设置、个人中心、系统偏好和更新弹窗实际布局。
- 系统通知权限拒绝后的开关状态、权限提示和错误提示是否与 OS 状态一致。
如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。 如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
## 当前质量门禁 ## 当前质量门禁
+2 -2
View File
@@ -106,8 +106,8 @@ npm run desktop:build:app
npm run desktop:build:app npm run desktop:build:app
``` ```
正式桌面发布构建仍必须设置 updater 签名私钥执行 正式桌面发布构建仍必须设置 updater 签名私钥和 Apple 签名/公证变量后,先执行
`npm run desktop:build -- --bundles app` `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; 2. build macOS Universal desktop artifacts;
3. sign and notarize the macOS app; 3. sign and notarize the macOS app;
4. produce updater artifacts and `.sig` files with the updater private key; 4. produce updater artifacts and `.sig` files with the updater private key;
5. generate `latest.json` and a checksum manifest; 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 <latest.json> --artifacts-dir <artifact-dir>`; 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; 7. upload immutable artifacts first;
8. atomically replace `latest.json` last. 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 `darwin-aarch64` and `darwin-x86_64` entries pointing at the same Universal
update package. 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 ## Windows Build Readiness
Second-phase Windows work is limited to CI compatibility validation. A 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 TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD"
export REQUIRE_DESKTOP_SIGNING=true export REQUIRE_DESKTOP_SIGNING=true
npm run release:env:check npm run release:env:check
npm run desktop:build -- --bundles app npm run desktop:release-readiness:check
npm run desktop:update-feed:check -- --feed src-tauri/target/release/bundle/latest.json --artifacts-dir src-tauri/target/release/bundle 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 The Desktop build must run on macOS for the current first-phase target. A signed
+61 -1
View File
@@ -9,7 +9,67 @@
</head> </head>
<body> <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> <script type="module" src="/src/main.ts"></script>
</body> </body>
+3
View File
@@ -11,8 +11,11 @@
"desktop:dev": "tauri dev", "desktop:dev": "tauri dev",
"desktop:build": "tauri build", "desktop:build": "tauri build",
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app", "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:bundle:dmg": "tauri build --bundles dmg",
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs", "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", "release:env:check": "node scripts/verify-release-build-env.mjs",
"version:check": "node scripts/client-version.mjs --check", "version:check": "node scripts/client-version.mjs --check",
"version:set": "node scripts/client-version.mjs --set", "version:set": "node scripts/client-version.mjs --set",
@@ -0,0 +1,156 @@
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const args = process.argv.slice(2);
const values = (name) => {
const found = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === name && args[index + 1]) {
found.push(args[index + 1]);
index += 1;
}
}
return found;
};
const value = (name) => values(name)[0];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
const outputDir = resolve(
frontendDir,
value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed",
);
const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString();
const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES;
const includes = values("--include").map((path) => resolve(frontendDir, path));
assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required.");
assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required.");
const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined;
const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined;
const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined;
const uploadFiles = [];
const readRequiredFile = async (path, description) => {
try {
return await readFile(path);
} catch (error) {
fail(`${description} cannot be read: ${path} (${error.message})`);
return undefined;
}
};
const sha256 = async (path) => {
const data = await readFile(path);
return createHash("sha256").update(data).digest("hex");
};
const normalizedBaseUrl = () => {
if (!baseUrlRaw) return undefined;
try {
const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`);
assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS.");
assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters.");
assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`);
return url;
} catch (error) {
fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`);
return undefined;
}
};
const copyIntoOutput = async (sourcePath) => {
const destination = resolve(outputDir, basename(sourcePath));
if (sourcePath !== destination) {
await copyFile(sourcePath, destination);
}
return destination;
};
if (resolvedArtifactPath && signaturePath) {
await readRequiredFile(resolvedArtifactPath, "Updater artifact");
const signature = await readRequiredFile(signaturePath, "Updater artifact signature");
if (signature) {
const signatureText = signature.toString("utf8").trim();
assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short.");
}
}
for (const includePath of includes) {
try {
const metadata = await stat(includePath);
assert(metadata.isFile(), `Included release file must be a file: ${includePath}`);
} catch (error) {
fail(`Included release file cannot be read: ${includePath} (${error.message})`);
}
}
const baseUrl = normalizedBaseUrl();
if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) {
await mkdir(outputDir, { recursive: true });
const artifactUrl = new URL(artifactName, baseUrl).toString();
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
const signature = (await readFile(signaturePath, "utf8")).trim();
const latest = {
version: packageInfo.version,
pub_date: pubDate,
platforms: {
"darwin-aarch64": {
signature,
url: artifactUrl,
},
"darwin-x86_64": {
signature,
url: artifactUrl,
},
},
};
if (notes) {
latest.notes = notes;
}
uploadFiles.push(await copyIntoOutput(resolvedArtifactPath));
uploadFiles.push(await copyIntoOutput(signaturePath));
for (const includePath of includes) {
uploadFiles.push(await copyIntoOutput(includePath));
}
const latestPath = resolve(outputDir, "latest.json");
await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`);
uploadFiles.push(latestPath);
const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()];
const checksumLines = [];
for (const filePath of uniqueFiles) {
checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`);
}
const checksumPath = resolve(outputDir, "SHA256SUMS.txt");
await writeFile(checksumPath, `${checksumLines.join("\n")}\n`);
console.log(`Desktop update feed created in ${outputDir}`);
console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`);
console.log(` - SHA256SUMS.txt`);
}
if (failures.length > 0) {
console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
}
@@ -0,0 +1,101 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const env = process.env;
const fullShaPattern = /^[0-9a-f]{40}$/i;
const expectedTag = `v${packageInfo.version}`;
const requiredSecretLikeEnv = [
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const git = (args) =>
execFileSync("git", args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitMaybe = (args) => {
try {
return git(args);
} catch {
return undefined;
}
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
};
const validateBaseUrl = () => {
const raw = env.DESKTOP_UPDATE_BASE_URL;
requireEnv("DESKTOP_UPDATE_BASE_URL");
if (!raw) return;
let url;
try {
url = new URL(raw.endsWith("/") ? raw : `${raw}/`);
} catch (error) {
fail(`DESKTOP_UPDATE_BASE_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "DESKTOP_UPDATE_BASE_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "DESKTOP_UPDATE_BASE_URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "DESKTOP_UPDATE_BASE_URL must not include token query parameters.");
assert(
url.pathname.includes(packageInfo.version),
`DESKTOP_UPDATE_BASE_URL must include the immutable version segment ${packageInfo.version}.`,
);
};
const headSha = gitMaybe(["rev-parse", "HEAD"]);
const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]);
const status = gitMaybe(["status", "--porcelain"]);
assert(Boolean(headSha), "Current Git commit cannot be resolved.");
assert(exactTag === expectedTag, `Current commit must be exactly tagged ${expectedTag}; found ${exactTag || "<none>"}.`);
assert(status === "", "Release readiness requires a clean working tree.");
assert(env.VITE_BUILD_CHANNEL === "release", "VITE_BUILD_CHANNEL must be release.");
assert(fullShaPattern.test(env.VITE_BUILD_COMMIT || ""), "VITE_BUILD_COMMIT must be the full release commit SHA.");
if (headSha && env.VITE_BUILD_COMMIT) {
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
}
for (const name of requiredSecretLikeEnv) {
requireEnv(name);
}
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
validateBaseUrl();
if (failures.length > 0) {
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop release readiness check passed.");
}
+104 -2
View File
@@ -71,6 +71,13 @@ const verifyTauriConfig = async () => {
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access."); assert(!/\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?.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?.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 () => { const verifyCapabilities = async () => {
@@ -86,7 +93,21 @@ const verifyCapabilities = async () => {
"fs:allow-read-dir", "fs:allow-read-dir",
"fs:allow-read-text-file", "fs:allow-read-text-file",
"fs:allow-write-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) { for (const file of files) {
const capability = await readJson(resolve(capabilitiesDir, file)); 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(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`); 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.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"); const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
@@ -127,6 +164,19 @@ const verifyRustBoundary = async () => {
dialogIndex < 0 || singleInstanceIndex < dialogIndex, dialogIndex < 0 || singleInstanceIndex < dialogIndex,
"Single-instance plugin must be registered before other desktop plugins.", "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 handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || []; const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
@@ -134,6 +184,9 @@ const verifyRustBoundary = async () => {
"credentials::credential_get", "credentials::credential_get",
"credentials::credential_set", "credentials::credential_set",
"credentials::credential_delete", "credentials::credential_delete",
"credentials::login_credential_get",
"credentials::login_credential_set",
"credentials::login_credential_delete",
"updates::desktop_update_check", "updates::desktop_update_check",
"updates::desktop_update_install", "updates::desktop_update_install",
]; ];
@@ -153,14 +206,18 @@ const verifySourceSafety = async () => {
for (const path of files) { for (const path of files) {
const source = await readFile(path, "utf8"); const source = await readFile(path, "utf8");
const file = relative(rootDir, path); const file = relative(rootDir, path);
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`); assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`);
assert( 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.`, `${file}: token-related values must not be written to console logs.`,
); );
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") { if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`); 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") { if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`); fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
} }
@@ -174,6 +231,19 @@ const verifyNotificationBoundary = async () => {
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content."); 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 verifyUpdaterBoundary = async () => {
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8"); 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."); assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
@@ -182,6 +252,19 @@ const verifyUpdaterBoundary = async () => {
}; };
const verifyWorkflowGates = async () => { const 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 workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [ const requiredCommands = [
"npm run version:check", "npm run version:check",
@@ -200,6 +283,24 @@ const verifyWorkflowGates = async () => {
} }
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL."); assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT."); assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
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(); await verifyTauriConfig();
@@ -207,6 +308,7 @@ await verifyCapabilities();
await verifyRustBoundary(); await verifyRustBoundary();
await verifySourceSafety(); await verifySourceSafety();
await verifyNotificationBoundary(); await verifyNotificationBoundary();
await verifySessionBoundary();
await verifyUpdaterBoundary(); await verifyUpdaterBoundary();
await verifyWorkflowGates(); await verifyWorkflowGates();
@@ -1,4 +1,5 @@
import { access, readFile } from "node:fs/promises"; import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path"; import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -18,6 +19,10 @@ const feedPath = resolve(
); );
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR; const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL; 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 fail = (message) => failures.push(message);
const assert = (condition, 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; let feed;
try { try {
feed = JSON.parse(await readFile(feedPath, "utf8")); feed = JSON.parse(await readFile(feedPath, "utf8"));
@@ -40,6 +94,8 @@ try {
} }
if (feed) { if (feed) {
const checksums = await readChecksumManifest();
await assertManifestEntries(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, ""); const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {}; const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"]; const darwinArm = platforms["darwin-aarch64"];
@@ -80,14 +136,21 @@ if (feed) {
if (artifactDir) { if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname)); const artifactPath = resolve(artifactDir, basename(url.pathname));
const signaturePath = `${artifactPath}.sig`;
await assertFileExists(artifactPath, `${platform} updater artifact`); 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) { if (darwinArm?.url && darwinIntel?.url) {
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact."); 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) { if (failures.length > 0) {
@@ -47,6 +47,9 @@ if (isTagBuild) {
if (env.REQUIRE_DESKTOP_SIGNING === "true") { if (env.REQUIRE_DESKTOP_SIGNING === "true") {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS."); 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");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD"); requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("APPLE_ID"); requireEnv("APPLE_ID");
@@ -56,6 +59,9 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") {
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY), Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.", "APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
); );
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
} }
if (failures.length > 0) { if (failures.length > 0) {
+3 -1
View File
@@ -16,7 +16,9 @@
{ "path": "$TEMP/ctms-desktop/**" } { "path": "$TEMP/ctms-desktop/**" }
] ]
}, },
"notification:default", "notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
{ {
"identifier": "opener:allow-open-path", "identifier": "opener:allow-open-path",
"allow": [ "allow": [
+91 -7
View File
@@ -1,7 +1,8 @@
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use url::Url; 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> { fn credential_account(server_origin: &str) -> Result<String, String> {
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_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))] #[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)?; let account = credential_account(server_origin)?;
keyring::Entry::new(CREDENTIAL_SERVICE, &account) keyring::Entry::new(service, &account)
.map_err(|error| format!("无法访问系统凭据库:{error}")) .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 || { tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))] #[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() { return match entry.get_password() {
Ok(token) => Ok(Some(token)), Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None), 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 || { tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))] #[cfg(any(target_os = "macos", windows))]
{ {
return get_entry(&server_origin)? return get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?
.set_password(&token) .set_password(&token)
.map_err(|error| format!("保存系统凭据失败:{error}")); .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 || { tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))] #[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() { return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除系统凭据失败:{error}")), Err(error) => Err(format!("删除系统凭据失败:{error}")),
@@ -94,9 +95,74 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
.map_err(|error| format!("删除系统凭据任务失败:{error}"))? .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)] #[cfg(test)]
mod tests { mod tests {
use super::credential_account; use super::{credential_account, LOGIN_CREDENTIAL_SERVICE, SESSION_CREDENTIAL_SERVICE};
#[test] #[test]
fn account_is_stable_for_same_origin() { 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://ctms.example.com").is_err());
assert!(credential_account("http://localhost:8000").is_ok()); 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_get,
credentials::credential_set, credentials::credential_set,
credentials::credential_delete, credentials::credential_delete,
credentials::login_credential_get,
credentials::login_credential_set,
credentials::login_credential_delete,
updates::desktop_update_check, updates::desktop_update_check,
updates::desktop_update_install, updates::desktop_update_install,
]) ])
+5 -1
View File
@@ -17,7 +17,11 @@
"width": 1440, "width": 1440,
"height": 900, "height": 900,
"minWidth": 1180, "minWidth": 1180,
"minHeight": 760 "minHeight": 760,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 16, "y": 18 }
} }
], ],
"security": { "security": {
+6 -1
View File
@@ -13,8 +13,13 @@ import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime"; import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue"; import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
if (isTauriRuntime()) { const isDesktopRuntime = isTauriRuntime();
if (isDesktopRuntime) {
applyDesktopThemePreference(); applyDesktopThemePreference();
document.body.classList.add("is-desktop-runtime");
} else {
document.body.classList.remove("is-desktop-runtime");
} }
initSessionManager(); initSessionManager();
initDesktopNotificationManager(); initDesktopNotificationManager();
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiDelete = vi.fn();
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({
apiDelete,
apiGet,
apiPost,
}));
describe("attachments api", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("downloads attachment blobs without putting credentials in the URL", async () => {
const { downloadAttachment } = await import("./attachments");
downloadAttachment("attachment-1");
expect(apiGet).toHaveBeenCalledWith("/api/v1/attachments/attachment-1/download", {
responseType: "blob",
});
expect(apiGet.mock.calls[0][0]).not.toContain("token");
expect(apiGet.mock.calls[0][0]).not.toContain("access_token");
});
it("uploads attachments as multipart form data", async () => {
const { uploadAttachment } = await import("./attachments");
const file = new File(["content"], "report.pdf", { type: "application/pdf" });
const onUploadProgress = vi.fn();
uploadAttachment("study-1", "startup_initiation", "entity-1", file, { onUploadProgress });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/startup_initiation/entity-1/attachments/",
expect.any(FormData),
{
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress,
},
);
const formData = apiPost.mock.calls[0][1] as FormData;
expect(formData.get("file")).toBe(file);
});
it("deletes attachments through the scoped attachment endpoint", async () => {
const { deleteAttachment } = await import("./attachments");
deleteAttachment("attachment-1");
expect(apiDelete).toHaveBeenCalledWith("/api/v1/attachments/attachment-1");
});
});
+5 -4
View File
@@ -1,5 +1,5 @@
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import api, { apiGet, apiPatch, apiPost } from "./axios"; import api, { apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
import type { import type {
UserMeResponse, UserMeResponse,
LoginRequest, LoginRequest,
@@ -24,10 +24,11 @@ export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginR
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> => export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key"); apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me"); export const fetchMe = (config?: ApiRequestConfig): Promise<AxiosResponse<UserMeResponse>> =>
apiGet<UserMeResponse>("/api/v1/auth/me", config);
export const fetchEmailDomains = (): Promise<AxiosResponse<EmailDomainsResponse>> => export const fetchEmailDomains = (config?: ApiRequestConfig): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains"); apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains", config);
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> => export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register", payload); apiPost("/api/v1/auth/register", payload);
@@ -1,7 +1,7 @@
import { auditExportColumns } from "./auditExportColumns"; import { auditExportColumns } from "./auditExportColumns";
import { formatAuditRows } from "./auditExportFormatter"; import { formatAuditRows } from "./auditExportFormatter";
import type { AuditEvent } from ".."; import type { AuditEvent } from "..";
import { saveFile } from "../../runtime"; import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const BOM = "\ufeff"; const BOM = "\ufeff";
@@ -24,9 +24,17 @@ export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportO
const rows = formatAuditRows(events); const rows = formatAuditRows(events);
const csv = buildCsv(rows); const csv = buildCsv(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
await saveFile({ await saveFileWithFeedback(
{
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`, suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8", mimeType: "text/csv;charset=utf-8",
data: blob, 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> <style scoped>
.panel { .panel {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
border: 0; border: 0;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
overflow: hidden; overflow: hidden;
padding: 24px 14px; 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 { .header {
@@ -101,37 +107,69 @@ const openManager = () => {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 0 8px 16px; padding: 12px 12px;
border-bottom: 1px solid #edf2f8; border: 1px solid rgba(110, 153, 205, 0.18);
color: #8b98aa; 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-size: 14px;
font-weight: 800; 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 { .menu {
flex: 1 1 auto;
max-height: calc(100vh - 240px); max-height: calc(100vh - 240px);
min-height: 0;
overflow: auto; overflow: auto;
border-right: 0; border-right: 0;
padding: 14px 0 0; padding: 14px 2px 0;
background: transparent; background: transparent;
} }
.menu :deep(.el-menu-item) { .menu :deep(.el-menu-item) {
position: relative;
height: 44px; height: 44px;
margin: 4px 0; margin: 4px 0;
padding: 0 10px !important; padding: 0 10px !important;
border: 1px solid transparent;
border-radius: 10px; border-radius: 10px;
color: #5f6f7a; color: #506475;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
line-height: 44px; 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) { .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; color: #1f5fb8;
} }
.menu :deep(.el-menu-item.is-active) { .menu :deep(.el-menu-item.is-active) {
background: #e7f4ff; border-color: rgba(47, 123, 232, 0.18);
color: #1f5fb8; 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 { .cat-icon {
display: inline-flex; display: inline-flex;
@@ -142,10 +180,11 @@ const openManager = () => {
height: 24px; height: 24px;
margin-right: 10px; margin-right: 10px;
border-radius: 7px; border-radius: 7px;
background: rgba(95, 111, 122, 0.08); background: linear-gradient(135deg, rgba(221, 234, 249, 0.9) 0%, rgba(242, 248, 255, 0.9) 100%);
color: #697982; color: #517194;
font-size: 13px; font-size: 13px;
font-weight: 800; font-weight: 800;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.82) inset;
} }
.cat-icon :deep(svg) { .cat-icon :deep(svg) {
font-size: 13px; font-size: 13px;
@@ -153,20 +192,21 @@ const openManager = () => {
height: 1em; height: 1em;
} }
.menu :deep(.el-menu-item.is-active) .cat-icon { .menu :deep(.el-menu-item.is-active) .cat-icon {
background: #d7ebff; background: linear-gradient(135deg, #2f7be8 0%, #24b7d8 100%);
color: #1f5fb8; color: #ffffff;
box-shadow: 0 8px 18px rgba(47, 123, 232, 0.25);
} }
.category-create-btn { .category-create-btn {
height: 34px; height: 34px;
border: 0; border: 0;
border-radius: 10px; border-radius: 10px;
background: #415a77; background: linear-gradient(135deg, #2f527a 0%, #203a5c 100%);
font-weight: 800; 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:hover,
.category-create-btn:focus { .category-create-btn:focus {
background: #344960; background: linear-gradient(135deg, #254866 0%, #172f4e 100%);
} }
.name { .name {
flex: 1; flex: 1;
@@ -183,8 +223,8 @@ const openManager = () => {
height: 18px; height: 18px;
padding: 0 6px; padding: 0 6px;
border-radius: 999px; border-radius: 999px;
background: rgba(95, 111, 122, 0.08); background: rgba(88, 111, 137, 0.1);
color: #697982; color: #5d7590;
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
line-height: 18px; line-height: 18px;
@@ -192,7 +232,7 @@ const openManager = () => {
} }
.menu :deep(.el-menu-item.is-active) .cat-count { .menu :deep(.el-menu-item.is-active) .cat-count {
background: #d7ebff; background: linear-gradient(135deg, #cfe7ff 0%, #dcf7ff 100%);
color: #1f5fb8; color: #1559ad;
} }
</style> </style>
+156 -19
View File
@@ -1,18 +1,20 @@
<template> <template>
<div class="faq-list-card"> <div class="faq-list-card" :class="{ 'faq-list-card--desktop': isDesktop }">
<el-table <el-table
:data="items" :data="items"
v-loading="loading" v-loading="loading"
style="width: 100%" style="width: 100%"
class="faq-table" class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName" :row-class-name="rowClassName"
:height="isDesktop ? '100%' : undefined"
@row-click="onRowClick" @row-click="onRowClick"
table-layout="fixed" table-layout="fixed"
> >
<el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip> <el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<div class="question-cell"> <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"> <el-link type="primary" :underline="false" class="question-link">
{{ scope.row.question }} {{ scope.row.question }}
</el-link> </el-link>
@@ -52,12 +54,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { computed } from "vue"; import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { deleteFaqItem } from "../api/faqs"; import { deleteFaqItem } from "../api/faqs";
import type { FaqItem, FaqCategory } from "../api/faqs"; import type { FaqItem, FaqCategory } from "../api/faqs";
import { displayDateTime, displayEnum } from "../utils/display"; import { displayDateTime, displayEnum } from "../utils/display";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { isTauriRuntime } from "../runtime";
const props = defineProps<{ const props = defineProps<{
items: FaqItem[]; items: FaqItem[];
@@ -69,6 +73,7 @@ const props = defineProps<{
const emit = defineEmits(["refresh"]); const emit = defineEmits(["refresh"]);
const router = useRouter(); const router = useRouter();
const isDesktop = isTauriRuntime();
const categoryMap = computed(() => const categoryMap = computed(() =>
props.categories.reduce<Record<string, string>>((acc, cur) => { props.categories.reduce<Record<string, string>>((acc, cur) => {
@@ -81,10 +86,6 @@ const categoryLabel = (categoryId: string) => {
return categoryMap.value[categoryId] || categoryId || "--"; return categoryMap.value[categoryId] || categoryId || "--";
}; };
const questionInitial = (question?: string) => {
return String(question || "?").trim().slice(0, 1) || "?";
};
const splitDateTime = (value?: string | number | Date | null) => { const splitDateTime = (value?: string | number | Date | null) => {
const displayValue = displayDateTime(value); const displayValue = displayDateTime(value);
const [date, time] = displayValue.split(" "); 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) { .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
border-bottom: 0; height: 46px;
background: transparent; border-bottom: 1px solid #dce8f7;
color: #8a98aa; background: linear-gradient(180deg, #f7fbff 0%, #eef5fc 100%);
color: #5d7088;
font-size: 14px; font-size: 14px;
font-weight: 800; font-weight: 800;
} }
@@ -166,21 +168,23 @@ const remove = async (row: FaqItem) => {
} }
.faq-table :deep(.faq-row td.el-table__cell) { .faq-table :deep(.faq-row td.el-table__cell) {
padding: 12px 0; padding: 14px 0;
border-bottom: 1px solid #e8eef6; border-bottom: 1px solid #e8eef6;
background: #ffffff; background: #ffffff;
transition: background 0.18s ease; transition: background 0.18s ease;
} }
.faq-table :deep(.faq-row:hover td.el-table__cell) { .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--with-actions :deep(col) {
.faq-table :deep(col:nth-child(2)) { width: 14%; } width: 20%;
.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--without-actions :deep(col) {
width: 25%;
}
@@ -205,10 +209,19 @@ const remove = async (row: FaqItem) => {
width: 34px; width: 34px;
height: 34px; height: 34px;
border-radius: 10px; border-radius: 10px;
background: #f3f7fb; background:
color: #5e6f7b; radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.42) 0%, transparent 28%),
font-size: 15px; linear-gradient(135deg, #2f7be8 0%, #23b7d9 100%);
color: #ffffff;
font-size: 17px;
font-weight: 800; 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 { .question-link {
@@ -257,4 +270,128 @@ const remove = async (row: FaqItem) => {
.question-link { .question-link {
cursor: pointer; 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> </style>
+452 -2
View File
@@ -3,26 +3,57 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8"); 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 readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8"); const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "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", () => { describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => { it("routes desktop and web shells through the runtime flag", () => {
const source = readLayoutSource(); const source = readLayoutSource();
const app = readAppSource();
expect(source).toContain("const isDesktop = isTauriRuntime()"); expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />"); expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />"); 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", () => { it("uses route-only desktop preference storage", () => {
const source = readDesktopLayoutSource(); const source = readDesktopLayoutSource();
const webSource = readWebLayoutSource();
expect(source).toContain("readDesktopRecentRoutes");
expect(source).toContain("readDesktopFavoriteRoutes"); expect(source).toContain("readDesktopFavoriteRoutes");
expect(source).toContain("recordDesktopRecentRoute");
expect(source).toContain("currentDesktopRoutePreference"); 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", () => { 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(desktopLayout).toContain("getActiveLayoutPath(route.path)");
expect(navigation).toContain("export const getActiveLayoutPath"); 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"; } from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue"; import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta"; import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import { saveFile } from "@/runtime"; import { saveFileWithFeedback } from "@/utils/fileTaskFeedback";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), { const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false, showSecurityLog: false,
@@ -519,7 +519,14 @@ const openSecurityLogDialog = () => {
const downloadLogFile = async (fileName: string, lines: string[]) => { const downloadLogFile = async (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" }); 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 = () => { const downloadInterfaceLog = () => {
+3 -2
View File
@@ -29,7 +29,8 @@
import { computed } from "vue"; import { computed } from "vue";
import type { UploadUserFile } from "element-plus"; import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display"; import { displayDateTime, displayUser } from "../utils/display";
import { clientRuntime, pickFiles } from "../runtime"; import { clientRuntime } from "../runtime";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
const props = defineProps<{ 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 quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
const pickNativeAttachments = async () => { 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 existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
const additions: UploadUserFile[] = files const additions: UploadUserFile[] = files
.filter((file) => !existing.has(`${file.name}:${file.size}`)) .filter((file) => !existing.has(`${file.name}:${file.size}`))
+2 -2
View File
@@ -47,8 +47,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, reactive, watch } from "vue"; import { onBeforeUnmount, reactive, watch } from "vue";
import { downloadAttachment } from "../api/attachments"; import { downloadAttachment } from "../api/attachments";
import { saveFile } from "../runtime";
import { displayDateTime, displayUser } from "../utils/display"; import { displayDateTime, displayUser } from "../utils/display";
import { saveFileWithFeedback } from "../utils/fileTaskFeedback";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
const props = defineProps<{ const props = defineProps<{
@@ -97,7 +97,7 @@ const loadImageUrls = async () => {
const download = async (id: string) => { const download = async (id: string) => {
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id); const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
const response = await downloadAttachment(id); const response = await downloadAttachment(id);
await saveFile({ await saveFileWithFeedback({
suggestedName: file?.filename || "attachment", suggestedName: file?.filename || "attachment",
mimeType: response.headers?.["content-type"] || file?.content_type, mimeType: response.headers?.["content-type"] || file?.content_type,
data: response.data, data: response.data,
+27 -34
View File
@@ -22,16 +22,6 @@
</el-menu-item> </el-menu-item>
</el-menu-item-group> </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"> <el-menu-item-group v-if="auth.user" class="menu-group">
<template #title> <template #title>
<span class="menu-divider">{{ TEXT.menu.admin }}</span> <span class="menu-divider">{{ TEXT.menu.admin }}</span>
@@ -405,7 +395,7 @@
class="desktop-preferences-dialog" class="desktop-preferences-dialog"
:show-close="false" :show-close="false"
:close-on-click-modal="true" :close-on-click-modal="true"
width="720px" width="940px"
align-center align-center
destroy-on-close destroy-on-close
> >
@@ -434,13 +424,12 @@ import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager"; import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { import {
DESKTOP_SERVER_URL_CHANGED_EVENT, DESKTOP_SERVER_URL_CHANGED_EVENT,
clearLegacyDesktopRouteHistoryPreference,
getAppMetadata, getAppMetadata,
getDesktopServerUrl, getDesktopServerUrl,
isTauriRuntime, isTauriRuntime,
listenDesktopMenuCommand, listenDesktopMenuCommand,
readDesktopFavoriteRoutes, readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
recordDesktopRecentRoute,
toggleDesktopFavoriteRoute, toggleDesktopFavoriteRoute,
type DesktopRoutePreference, type DesktopRoutePreference,
} from "../runtime"; } from "../runtime";
@@ -472,7 +461,6 @@ const profileDialogDirty = ref(false);
const commandPaletteVisible = ref(false); const commandPaletteVisible = ref(false);
const desktopPreferencesVisible = ref(false); const desktopPreferencesVisible = ref(false);
const desktopServerUrl = ref(getDesktopServerUrl()); const desktopServerUrl = ref(getDesktopServerUrl());
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes()); const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
const headerOverviewStats = ref<{ const headerOverviewStats = ref<{
centerActual: number; centerActual: number;
@@ -720,9 +708,6 @@ const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
const refreshDesktopRoutePreferences = () => { const refreshDesktopRoutePreferences = () => {
if (!isDesktop) return; if (!isDesktop) return;
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
);
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) => desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) =>
desktopNavigationItems.value.some((navItem) => navItem.path === item.path), desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
); );
@@ -779,7 +764,7 @@ const handleDesktopMenuCommand = (command: string) => {
return; return;
} }
if (command === "ctms.desktop.serverSettings") { if (command === "ctms.desktop.serverSettings") {
router.push("/desktop/server-settings"); openDesktopPreferences();
return; return;
} }
if (command === "ctms.desktop.refresh") { if (command === "ctms.desktop.refresh") {
@@ -839,12 +824,10 @@ const desktopCommands = computed<DesktopCommand[]>(() => {
}, },
{ {
id: "desktop:server-settings", id: "desktop:server-settings",
title: "服务器设置", title: "连接设置",
group: "桌面", group: "桌面",
visible: true, visible: true,
run: () => { run: openDesktopPreferences,
void router.push("/desktop/server-settings");
},
}, },
{ {
id: "desktop:update", id: "desktop:update",
@@ -1086,7 +1069,6 @@ watch(() => route.path, () => {
if (isDesktop) { if (isDesktop) {
const current = currentDesktopRoutePreference.value; const current = currentDesktopRoutePreference.value;
if (current) { if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
refreshDesktopRoutePreferences(); refreshDesktopRoutePreferences();
} }
} }
@@ -1147,10 +1129,7 @@ onMounted(async () => {
}, 1000); }, 1000);
if (isDesktop) { if (isDesktop) {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl); window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
const current = currentDesktopRoutePreference.value; clearLegacyDesktopRouteHistoryPreference();
if (current) {
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
}
refreshDesktopRoutePreferences(); refreshDesktopRoutePreferences();
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined); desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
} }
@@ -2506,9 +2485,15 @@ useDesktopShortcuts(
} }
:global(.profile-settings-dialog) { :global(.profile-settings-dialog) {
border-radius: 16px; /* 移除外层卡片外壳,只保留容器行为 */
overflow: hidden; --el-dialog-bg-color: transparent !important;
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26); --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) { :global(.profile-settings-dialog .el-dialog__header) {
@@ -2517,11 +2502,19 @@ useDesktopShortcuts(
:global(.profile-settings-dialog .el-dialog__body) { :global(.profile-settings-dialog .el-dialog__body) {
padding: 0; padding: 0;
background: transparent;
} }
:global(.desktop-preferences-dialog) { :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) { :global(.desktop-preferences-dialog .el-dialog__header) {
@@ -2529,7 +2522,7 @@ useDesktopShortcuts(
} }
:global(.desktop-preferences-dialog .el-dialog__body) { :global(.desktop-preferences-dialog .el-dialog__body) {
padding: 20px; padding: 0;
background: #f8fafc; background: transparent;
} }
</style> </style>
@@ -152,7 +152,9 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions"; import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales"; 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 = { type AttachmentEntityGroup = {
entityType: string; entityType: string;
@@ -252,6 +254,7 @@ const tableProgress = computed(() => {
return group ? progressMap[group.key] || 0 : 0; return group ? progressMap[group.key] || 0 : 0;
}); });
const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100); const isImmediateUploading = computed(() => tableProgress.value > 0 && tableProgress.value < 100);
const desktopActivityEnabled = () => nativeFiles;
const validateFile = (file: File) => { const validateFile = (file: File) => {
if (file.size > maxSize.value * 1024 * 1024) { if (file.size > maxSize.value * 1024 * 1024) {
@@ -280,7 +283,7 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
const pickPendingNative = async (group: UploadGroup) => { const pickPendingNative = async (group: UploadGroup) => {
if (!canUploadGroup(group)) return; 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)); 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)}`)); uploadGroups.value.flatMap((group) => (pendingMap[group.key] || []).map((item) => `${group.key}:${pendingFileKey(item.file)}`));
const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0)); const hasPendingUploads = computed(() => uploadGroups.value.some((group) => (pendingMap[group.key] || []).length > 0));
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 (!props.studyId || !targetEntityId) return;
if (!validateFile(file)) throw new Error("invalid file"); if (!validateFile(file)) throw new Error("invalid file");
progressMap[group.key] = 0; progressMap[group.key] = 0;
await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, { await uploadAttachment(props.studyId, group.entityType, targetEntityId, file, {
onUploadProgress: (evt: ProgressEvent) => { onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) { 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; if (!targetEntityId) return;
let hasFailure = false; 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) { for (const group of uploadGroups.value) {
const items = [...(pendingMap[group.key] || [])]; const items = [...(pendingMap[group.key] || [])];
const remainItems: PendingUploadItem[] = []; const remainItems: PendingUploadItem[] = [];
@@ -319,19 +339,36 @@ const uploadPending = async (entityId?: string) => {
try { try {
item.status = "uploading"; item.status = "uploading";
item.error = ""; 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) { } catch (e: any) {
hasFailure = true; hasFailure = true;
item.status = "failed"; item.status = "failed";
item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed; item.error = e?.response?.data?.message || TEXT.common.messages.uploadFailed;
remainItems.push(item); remainItems.push(item);
} finally { } finally {
completedCount += 1;
if (activityId && totalCount) {
updateDesktopActivity(activityId, {
detail: `已处理 ${completedCount}/${totalCount}`,
progress: Math.min(99, Math.round((completedCount / totalCount) * 100)),
});
}
progressMap[group.key] = 0; progressMap[group.key] = 0;
} }
} }
pendingMap[group.key] = remainItems; 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) => { const uploadImmediate = async (options: any) => {
@@ -339,11 +376,18 @@ const uploadImmediate = async (options: any) => {
const group = tableUploadGroup.value; const group = tableUploadGroup.value;
if (!file || !group || !props.entityId) return; if (!file || !group || !props.entityId) return;
if (!validateFile(file)) return; if (!validateFile(file)) return;
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "upload", title: "上传附件", detail: "正在上传附件", progress: 0 })
: "";
try { try {
await uploadFile(group, file, props.entityId); await uploadFile(group, file, props.entityId, (progress) => {
ElMessage.success(TEXT.common.messages.uploadSuccess); updateDesktopActivity(activityId, { detail: "正在上传附件", progress });
});
finishDesktopActivity(activityId, "completed", { detail: "附件上传完成" });
if (!desktopActivityEnabled()) ElMessage.success(TEXT.common.messages.uploadSuccess);
load(); load();
} catch (e: any) { } catch (e: any) {
finishDesktopActivity(activityId, "failed", { detail: "附件上传失败" });
ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed); ElMessage.error(e?.response?.data?.message || e?.message || TEXT.common.messages.uploadFailed);
} finally { } finally {
progressMap[group.key] = 0; progressMap[group.key] = 0;
@@ -351,7 +395,7 @@ const uploadImmediate = async (options: any) => {
}; };
const pickImmediateNative = async () => { 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 }); if (file) await uploadImmediate({ file });
}; };
@@ -398,25 +442,48 @@ const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
}; };
const download = async (row: any) => { const download = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "download", title: "下载附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try { try {
await saveFile({ const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "等待选择保存位置", progress: 65 });
await saveFileWithFeedback({
suggestedName: row?.filename || "download", suggestedName: row?.filename || "download",
mimeType: row?.content_type, mimeType: row?.content_type,
data: await fetchAttachmentBlob(row), data: blob,
}, {
activityId,
title: "下载附件",
pendingDetail: "等待选择保存位置",
completedDetail: "附件已保存",
cancelledDetail: "已取消保存附件",
}); });
} catch { } catch {
finishDesktopActivity(activityId, "failed", { detail: "附件下载失败" });
ElMessage.error(TEXT.common.messages.downloadFailed); ElMessage.error(TEXT.common.messages.downloadFailed);
} }
}; };
const openExternally = async (row: any) => { const openExternally = async (row: any) => {
const activityId = desktopActivityEnabled()
? startDesktopActivity({ kind: "open", title: "打开附件", detail: "正在从服务器获取文件", progress: 20 })
: "";
try { try {
await openFile({ const blob = await fetchAttachmentBlob(row);
updateDesktopActivity(activityId, { detail: "正在准备文件", progress: 65 });
await openFileWithFeedback({
suggestedName: row?.filename || "attachment", suggestedName: row?.filename || "attachment",
mimeType: row?.content_type, mimeType: row?.content_type,
data: await fetchAttachmentBlob(row), data: blob,
}, {
activityId,
title: "打开附件",
pendingDetail: "正在准备文件",
completedDetail: "已交给系统打开",
}); });
} catch { } catch {
finishDesktopActivity(activityId, "failed", { detail: "附件打开失败" });
ElMessage.error(TEXT.common.messages.previewNotSupported); ElMessage.error(TEXT.common.messages.previewNotSupported);
} }
}; };
+12 -3
View File
@@ -95,11 +95,20 @@ export const buildAdminNavigationItems = (options: {
keywords: ["monitoring"], keywords: ["monitoring"],
}, },
{ {
label: "邮件服务", label: "系统设置",
path: "/admin/email-settings", path: "/admin/email-settings",
group: TEXT.menu.admin, group: TEXT.menu.admin,
icon: "settings", 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[]) => 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 目录", treeTitle: "TMF 目录",
detailTitle: "归档状态", detailTitle: "归档状态",
noNodeSelected: "未选择目录", noNodeSelected: "未选择目录",
selectNodeHint: "请选择左侧目录查看文件",
emptyDocuments: "当前目录暂无文件", emptyDocuments: "当前目录暂无文件",
rootNode: "根目录", rootNode: "根目录",
actions: { 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 router from "./router";
import { getToken } from "./utils/auth"; import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study"; import { useStudyStore } from "./store/study";
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime"; import { cleanupTemporaryFiles, initializeSecureSessionStorage, isTauriRuntime, shouldRequireDesktopServerUrl } from "./runtime";
const bootstrap = async () => { const bootstrap = async () => {
await initializeSecureSessionStorage().catch((error) => { await initializeSecureSessionStorage().catch((error) => {
@@ -30,7 +30,7 @@ const bootstrap = async () => {
// 初始化项目上下文 // 初始化项目上下文
const studyStore = useStudyStore(); const studyStore = useStudyStore();
studyStore.loadCurrentStudy(); studyStore.loadCurrentStudy();
if (getToken() && !shouldRequireDesktopServerUrl()) { if (getToken() && !shouldRequireDesktopServerUrl() && !isTauriRuntime()) {
await studyStore.rehydrateStudyForLastUser(); await studyStore.rehydrateStudyForLastUser();
await studyStore.loadCurrentStudyPermissions().catch(() => {}); 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]"); 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(); const source = readRouter();
expect(source).not.toContain("findPmAdminLandingPath"); expect(source).not.toContain("findPmAdminLandingPath");
expect(source).not.toContain("pmAdminLandingModules"); 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", () => { 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 Register from "../views/Register.vue";
import ForgotPassword from "../views/ForgotPassword.vue"; import ForgotPassword from "../views/ForgotPassword.vue";
import DesktopServerSettings from "../views/DesktopServerSettings.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 StudyHome from "../views/StudyHome.vue";
import FaqDetail from "../views/FaqDetail.vue"; import FaqDetail from "../views/FaqDetail.vue";
import AuditLogs from "../views/admin/AuditLogs.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 SubjectDetail from "../views/subjects/SubjectDetail.vue";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
import { resolveDesktopRouteRedirect } from "./desktopGuard"; 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_READ = "system:permissions:read";
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config"; const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
@@ -86,6 +90,18 @@ const routes: RouteRecordRaw[] = [
component: DesktopServerSettings, component: DesktopServerSettings,
meta: { public: true, title: "服务器设置" }, 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: "/", path: "/",
component: Layout, component: Layout,
@@ -512,6 +528,7 @@ const ensureProjectPermissionAccess = async (
}; };
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
const isDesktopRuntime = isTauriRuntime();
const desktopRedirect = resolveDesktopRouteRedirect(to); const desktopRedirect = resolveDesktopRouteRedirect(to);
if (desktopRedirect) { if (desktopRedirect) {
next({ path: desktopRedirect }); next({ path: desktopRedirect });
@@ -522,10 +539,15 @@ router.beforeEach(async (to, _from, next) => {
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const getToken = () => auth.token; const getToken = () => auth.token;
let token = getToken(); let token = getToken();
if (!auth.user && getToken()) { const isDesktopSessionRestoreRoute = to.path === DESKTOP_SESSION_RESTORE_PATH;
if (!auth.user && getToken() && !isDesktopSessionRestoreRoute) {
try { try {
await auth.fetchMe(); await auth.fetchMe({ disableNetworkRetry: true, suppressErrorMessage: true });
} catch { } catch (error) {
if (isDesktopRuntime && shouldPreserveDesktopSessionOnAuthCheckFailure(error)) {
next({ path: DESKTOP_SESSION_RESTORE_PATH, query: { redirect: to.fullPath } });
return;
}
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面 // 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
await auth.logout(); await auth.logout();
next({ path: "/login" }); next({ path: "/login" });
@@ -534,7 +556,11 @@ router.beforeEach(async (to, _from, next) => {
} }
token = getToken(); token = getToken();
const isAdmin = isSystemAdmin(auth.user); 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) { if (isAdmin) {
await studyStore.ensureDefaultActiveStudy(); await studyStore.ensureDefaultActiveStudy();
} else { } else {
@@ -544,8 +570,22 @@ router.beforeEach(async (to, _from, next) => {
if (token && studyStore.currentStudy && auth.user?.email) { if (token && studyStore.currentStudy && auth.user?.email) {
studyStore.rememberCurrentStudyForUser(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; return;
} }
if (isAdmin && to.path === "/") { if (isAdmin && to.path === "/") {
@@ -554,6 +594,10 @@ router.beforeEach(async (to, _from, next) => {
} }
if ((to.path === "/login" || to.path === "/register") && token) { if ((to.path === "/login" || to.path === "/register") && token) {
if (!auth.forceLogin) { if (!auth.forceLogin) {
if (isDesktopRuntime) {
next({ path: "/desktop/project-entry" });
return;
}
next({ next({
path: isAdmin path: isAdmin
? studyStore.currentStudy ? studyStore.currentStudy
@@ -621,7 +665,7 @@ router.beforeEach(async (to, _from, next) => {
} }
} }
if (to.meta.requiresStudy && !studyStore.currentStudy) { 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; return;
} }
if (to.meta.requiresStudy && studyStore.currentStudy) { if (to.meta.requiresStudy && studyStore.currentStudy) {
@@ -1,13 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { import {
DESKTOP_FAVORITE_ROUTES_KEY, DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_KEY, DESKTOP_THEME_KEY,
applyDesktopThemePreference, applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
readDesktopFavoriteRoutes, readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference, readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference, setDesktopThemePreference,
toggleDesktopFavoriteRoute, toggleDesktopFavoriteRoute,
} from "./desktopUiPreferences"; } from "./desktopUiPreferences";
@@ -32,17 +30,17 @@ describe("desktop UI preferences", () => {
document.documentElement.style.colorScheme = ""; document.documentElement.style.colorScheme = "";
}); });
it("stores only route metadata for recent desktop routes", () => { it("stores only route metadata for desktop favorites", () => {
recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" }); toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
expect(readDesktopRecentRoutes()).toEqual([ expect(readDesktopFavoriteRoutes()).toEqual([
expect.objectContaining({ expect.objectContaining({
path: "/subjects", path: "/subjects",
title: "受试者", title: "受试者",
group: "当前项目", 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", () => { 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"); 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", () => { it("stores and applies only the desktop theme enum", () => {
expect(readDesktopThemePreference()).toBe("light"); 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_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
export const DESKTOP_THEME_KEY = "ctms_desktop_theme"; export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed"; export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
@@ -12,10 +11,10 @@ export interface DesktopRoutePreference {
updatedAt: string; updatedAt: string;
} }
const MAX_RECENT_ROUTES = 8;
const MAX_FAVORITE_ROUTES = 12; const MAX_FAVORITE_ROUTES = 12;
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light"; const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme"; 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"; const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
@@ -80,19 +79,11 @@ export const setDesktopThemePreference = (theme: DesktopThemePreference): Deskto
return nextTheme; return nextTheme;
}; };
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY); export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
export const recordDesktopRecentRoute = (route: Pick<DesktopRoutePreference, "path" | "title" | "group">): DesktopRoutePreference[] => { export const clearLegacyDesktopRouteHistoryPreference = () => {
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() }); if (!isStorageAvailable()) return;
if (!item) return readDesktopRecentRoutes(); window.localStorage.removeItem(LEGACY_DESKTOP_ROUTE_HISTORY_KEY);
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 isDesktopFavoriteRoute = (path: string): boolean => export const isDesktopFavoriteRoute = (path: string): boolean =>
+8 -3
View File
@@ -10,15 +10,13 @@ export {
} from "./desktopServerConfig"; } from "./desktopServerConfig";
export { export {
DESKTOP_FAVORITE_ROUTES_KEY, DESKTOP_FAVORITE_ROUTES_KEY,
DESKTOP_RECENT_ROUTES_KEY,
DESKTOP_THEME_CHANGED_EVENT, DESKTOP_THEME_CHANGED_EVENT,
DESKTOP_THEME_KEY, DESKTOP_THEME_KEY,
applyDesktopThemePreference, applyDesktopThemePreference,
clearLegacyDesktopRouteHistoryPreference,
isDesktopFavoriteRoute, isDesktopFavoriteRoute,
readDesktopFavoriteRoutes, readDesktopFavoriteRoutes,
readDesktopRecentRoutes,
readDesktopThemePreference, readDesktopThemePreference,
recordDesktopRecentRoute,
setDesktopThemePreference, setDesktopThemePreference,
toggleDesktopFavoriteRoute, toggleDesktopFavoriteRoute,
type DesktopRoutePreference, type DesktopRoutePreference,
@@ -43,6 +41,7 @@ export {
getNotificationPermission, getNotificationPermission,
requestNotificationPermission, requestNotificationPermission,
showSystemNotification, showSystemNotification,
showSystemNotificationProbe,
type NotificationPermissionState, type NotificationPermissionState,
} from "./notifications"; } from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform"; export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
@@ -53,6 +52,12 @@ export {
isSecureSessionStorageAvailable, isSecureSessionStorageAvailable,
setSessionToken, setSessionToken,
} from "./secureSessionStorage"; } from "./secureSessionStorage";
export {
clearLoginCredential,
getSavedLoginCredential,
saveLoginCredential,
type SavedLoginCredential,
} from "./savedLoginCredentials";
export { export {
checkForDesktopUpdate, checkForDesktopUpdate,
installPendingDesktopUpdate, 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"; 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> => { export const getNotificationPermission = async (): Promise<NotificationPermissionState> => {
if (!isTauriRuntime()) return "unsupported"; if (!isTauriRuntime()) return "unsupported";
const webPermission = readWebNotificationPermission();
if (webPermission === "granted" || webPermission === "denied") return webPermission;
const { isPermissionGranted } = await import("@tauri-apps/plugin-notification"); const { isPermissionGranted } = await import("@tauri-apps/plugin-notification");
return (await isPermissionGranted()) ? "granted" : "prompt"; return (await isPermissionGranted()) ? "granted" : "prompt";
}; };
@@ -12,14 +41,19 @@ export const requestNotificationPermission = async (): Promise<NotificationPermi
if (!isTauriRuntime()) return "unsupported"; if (!isTauriRuntime()) return "unsupported";
const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification"); const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification");
if (await isPermissionGranted()) return "granted"; if (await isPermissionGranted()) return "granted";
return (await requestPermission()) === "granted" ? "granted" : "denied"; return toNotificationPermissionState(await requestPermission());
}; };
export const showSystemNotification = async (): Promise<void> => { const sendStaticSystemNotification = async (payload: StaticNotificationPayload): Promise<boolean> => {
if (!isTauriRuntime()) return; if (!isTauriRuntime()) return false;
if ((await getNotificationPermission()) !== "granted") return false;
const { sendNotification } = await import("@tauri-apps/plugin-notification"); const { sendNotification } = await import("@tauri-apps/plugin-notification");
sendNotification({ sendNotification(payload);
title: "CTMS 文件更新", return true;
body: "有新的文件版本待查看",
});
}; };
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 { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform"; 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 cachedToken: string | null = null;
let initialized = false; 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> => { export const initializeSecureSessionStorage = async (): Promise<void> => {
if (initialized) return; if (initialized) return;
initialized = true; initialized = true;
@@ -52,13 +110,11 @@ export const initializeSecureSessionStorage = async (): Promise<void> => {
if (legacyToken && isUsableJwt(legacyToken)) { if (legacyToken && isUsableJwt(legacyToken)) {
await invokeCredential<void>("credential_set", { await invokeCredential<void>("credential_set", {
serverOrigin: activeServerOrigin, serverOrigin: activeServerOrigin,
token: legacyToken, token: serializeSessionToken(legacyToken),
}); });
cachedToken = legacyToken; cachedToken = legacyToken;
} else { } else {
cachedToken = await invokeCredential<string | null>("credential_get", { cachedToken = await readCredentialToken(activeServerOrigin);
serverOrigin: activeServerOrigin,
});
} }
secureStorageAvailable = true; secureStorageAvailable = true;
} catch (error) { } catch (error) {
@@ -87,7 +143,7 @@ export const setSessionToken = async (token: string): Promise<void> => {
const serverOrigin = getDesktopServerUrl(); const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址"); if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
await invokeCredential<void>("credential_set", { serverOrigin, token }); await invokeCredential<void>("credential_set", { serverOrigin, token: serializeSessionToken(token) });
activeServerOrigin = serverOrigin; activeServerOrigin = serverOrigin;
cachedToken = token; cachedToken = token;
initialized = true; 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 { data } = await claimDesktopNotifications();
const deliveredIds: string[] = []; const deliveredIds: string[] = [];
let deliveryFailed = false;
for (const item of data.items) { for (const item of data.items) {
await showSystemNotification(); try {
if (await showSystemNotification()) {
deliveredIds.push(item.id); deliveredIds.push(item.id);
} else {
deliveryFailed = true;
}
} catch {
deliveryFailed = true;
}
} }
if (data.claim_token && deliveredIds.length) { if (data.claim_token && deliveredIds.length) {
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds); await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
} }
if (deliveryFailed) {
throw new Error("desktop notification delivery failed");
}
failureCount = 0; failureCount = 0;
schedule(POLL_INTERVAL_MS); schedule(POLL_INTERVAL_MS);
} catch { } 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, isDesktopUpdaterAvailable,
type DesktopUpdateInfo, type DesktopUpdateInfo,
} from "../runtime"; } from "../runtime";
import { finishDesktopActivity, startDesktopActivity, updateDesktopActivity } from "./desktopActivityCenter";
const INITIAL_CHECK_DELAY_MS = 30_000; const INITIAL_CHECK_DELAY_MS = 30_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const POSTPONE_MS = 24 * 60 * 60 * 1000; const POSTPONE_MS = 24 * 60 * 60 * 1000;
const POSTPONE_PREFIX = "ctms_desktop_update_postponed:"; const POSTPONE_PREFIX = "ctms_desktop_update_postponed:";
export const DESKTOP_UPDATE_STATUS_CHANGED_EVENT = "ctms:desktop-update-status-changed";
let initialized = false; let initialized = false;
let checkTimer: number | null = null; let checkTimer: number | null = null;
@@ -18,10 +20,60 @@ let promptVisible = false;
export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed"; 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 { export interface DesktopUpdateCheckOptions {
notifyWhenCurrent?: boolean; 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 postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
const getPostponeUntil = (version: string): number => { const getPostponeUntil = (version: string): number => {
@@ -34,25 +86,58 @@ const getPostponeUntil = (version: string): number => {
}; };
const postponeVersion = (version: string) => { const postponeVersion = (version: string) => {
const until = Date.now() + POSTPONE_MS;
try { try {
window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS)); window.localStorage.setItem(postponeKey(version), String(until));
} catch { } catch {
/* ignore */ /* ignore */
} }
setUpdateStatus({ lastStatus: "postponed", postponedUntil: new Date(until).toISOString() });
}; };
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now(); 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 releaseNotes = (update: DesktopUpdateInfo): string => {
const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`]; const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`];
if (update.notes?.trim()) { const notes = update.notes ? sanitizeReleaseNotes(update.notes) : "";
lines.push("", "发布说明:", update.notes.trim()); if (notes) {
lines.push("", "发布说明:", notes);
} }
lines.push("", "确认后将下载、验签、安装并重启应用。"); lines.push("", "确认后将下载、验签、安装并重启应用。");
return lines.join("\n"); 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"; if (promptVisible || isSuppressed(update.version)) return "suppressed";
promptVisible = true; promptVisible = true;
try { try {
@@ -62,13 +147,20 @@ const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdate
distinguishCancelAndClose: true, distinguishCancelAndClose: true,
type: "info", type: "info",
}); });
updateUpdateActivity(activityId, { detail: "正在下载并安装更新", progress: 75 });
setUpdateStatus({ installing: true, lastError: "" });
await installPendingDesktopUpdate(); await installPendingDesktopUpdate();
setUpdateStatus({ installing: false, lastStatus: "available", pendingUpdate: update });
finishUpdateActivity(activityId, "completed", { detail: "更新安装已启动" });
return "available"; return "available";
} catch (error) { } catch (error) {
if (error === "cancel" || error === "close") { if (error === "cancel" || error === "close") {
postponeVersion(update.version); postponeVersion(update.version);
finishUpdateActivity(activityId, "cancelled", { detail: "已选择稍后提醒" });
return "postponed"; return "postponed";
} }
setUpdateStatus({ installing: false, lastStatus: "failed", lastError: "桌面端更新安装失败" });
finishUpdateActivity(activityId, "failed", { detail: "更新安装失败" });
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。"); ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
return "failed"; return "failed";
} finally { } 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 ( export const checkDesktopUpdateAndPrompt = async (
options: DesktopUpdateCheckOptions = {}, options: DesktopUpdateCheckOptions = {},
): Promise<DesktopUpdateCheckStatus> => { ): Promise<DesktopUpdateCheckStatus> => {
const activityId = options.notifyWhenCurrent || options.promptWhenAvailable
? startDesktopActivity({ kind: "update", title: "检查桌面更新", detail: "正在连接更新服务", progress: 20 })
: "";
if (!isDesktopUpdaterAvailable()) { 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"; return "disabled";
} }
setUpdateStatus({ available: true, checking: true, lastError: "" });
updateUpdateActivity(activityId, { detail: "正在读取更新信息", progress: 40 });
try { try {
const update = await checkForDesktopUpdate(); const update = await checkForDesktopUpdate();
const checkedAt = new Date().toISOString();
if (update) { 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);
} }
if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本"); finishUpdateActivity(activityId, suppressed ? "cancelled" : "completed", {
detail: suppressed ? "已选择稍后提醒" : "发现可用更新",
});
return suppressed ? "suppressed" : "available";
}
setUpdateStatus({
checking: false,
lastStatus: "up-to-date",
lastCheckedAt: checkedAt,
lastError: "",
pendingUpdate: null,
postponedUntil: null,
});
finishUpdateActivity(activityId, "completed", { detail: "当前已是最新版本" });
return "up-to-date"; 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("桌面端更新检查失败,请稍后重试或联系管理员。"); if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
return "failed"; return "failed";
@@ -119,4 +276,5 @@ export const stopDesktopUpdateManager = () => {
} }
initialized = false; initialized = false;
promptVisible = 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", () => ({ vi.mock("../api/authClient", () => ({
extendToken: vi.fn(), 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", () => { describe("session manager idle logout", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.clearAllMocks();
runtimeMocks.isTauriRuntime.mockReturnValue(false);
vi.useFakeTimers(); vi.useFakeTimers();
setActivePinia(createPinia()); setActivePinia(createPinia());
const storage = (() => { const storage = (() => {
@@ -39,14 +59,15 @@ describe("session manager idle logout", () => {
value: storage, value: storage,
configurable: true, configurable: true,
}); });
Object.defineProperty(window, "BroadcastChannel", { channelPostMessage.mockClear();
value: class { vi.stubGlobal(
"BroadcastChannel",
class {
onmessage: ((event: MessageEvent) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null;
postMessage() {} postMessage = channelPostMessage;
close() {} close() {}
}, },
configurable: true, );
});
}); });
it("shows a timeout warning one minute before automatic logout", async () => { 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"); 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 () => { it("clears the timeout warning when user activity resumes", async () => {
vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z")); vi.setSystemTime(new Date("2026-03-10T01:00:00.000Z"));
const { useSessionStore } = await import("../store/session"); const { useSessionStore } = await import("../store/session");
@@ -92,4 +131,47 @@ describe("session manager idle logout", () => {
expect(session.timeoutWarningVisible).toBe(false); expect(session.timeoutWarningVisible).toBe(false);
expect(session.timeoutAt).toBe(0); 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 router from "../router";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session"; import { useSessionStore } from "../store/session";
import { isTauriRuntime } from "../runtime";
import { getToken, setToken } from "../utils/auth"; import { getToken, setToken } from "../utils/auth";
import { extendToken } from "../api/authClient"; import { extendToken } from "../api/authClient";
import { parseJwtExp } from "./jwt"; import { parseJwtExp } from "./jwt";
@@ -24,11 +25,17 @@ let extendPromise: Promise<{ token: string | null; authFailed: boolean }> | null
let initialized = false; let initialized = false;
const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null; const channel = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("ctms-auth") : null;
const shouldEnforceIdleTimeout = () => !isTauriRuntime();
const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) => const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) =>
session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000; session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000;
const reconcileSessionState = (now: number = Date.now()) => { const reconcileSessionState = (now: number = Date.now()) => {
const session = useSessionStore(); const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return true;
}
if (now >= getTimeoutAt(session)) { if (now >= getTimeoutAt(session)) {
void forceLogout(LOGOUT_REASON_TIMEOUT); void forceLogout(LOGOUT_REASON_TIMEOUT);
return false; return false;
@@ -40,6 +47,7 @@ const broadcast = (message: BroadcastMessage) => {
if (channel) { if (channel) {
channel.postMessage(message); channel.postMessage(message);
} }
if (message.type === "TOKEN_UPDATED") return;
try { try {
localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() })); localStorage.setItem("ctms_auth_broadcast", JSON.stringify({ ...message, ts: Date.now() }));
} catch { } catch {
@@ -69,6 +77,10 @@ const scheduleIdleCheck = () => {
window.clearTimeout(idleTimer); window.clearTimeout(idleTimer);
} }
const session = useSessionStore(); const session = useSessionStore();
if (!shouldEnforceIdleTimeout()) {
session.clearTimeoutWarning();
return;
}
const now = Date.now(); const now = Date.now();
const timeoutAt = getTimeoutAt(session); const timeoutAt = getTimeoutAt(session);
const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000; const warningAt = timeoutAt - TIMEOUT_WARNING_SECONDS * 1000;
@@ -223,7 +235,7 @@ export const startTokenKeepAlive = () => {
const remaining = expAt - Date.now(); const remaining = expAt - Date.now();
const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000; const timeoutMs = IDLE_TIMEOUT_MINUTES * 60 * 1000;
const active = Date.now() - session.lastUserActiveAt < timeoutMs; 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"); void extendAccessToken("early");
} }
}, 10000); }, 10000);
+27
View File
@@ -99,6 +99,33 @@ describe("auth store logout", () => {
expect(session.timeoutWarningVisible).toBe(false); 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 () => { it("uses encrypted login by default outside a secure browser context", async () => {
vi.stubGlobal("isSecureContext", false); vi.stubGlobal("isSecureContext", false);
const { useAuthStore } = await import("./auth"); const { useAuthStore } = await import("./auth");
+9 -4
View File
@@ -1,6 +1,7 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth"; import { getLoginKey, login as apiLogin, devLogin, fetchMe } from "../api/auth";
import type { ApiRequestConfig } from "../api/axios";
import { setToken, clearToken, getToken } from "../utils/auth"; import { setToken, clearToken, getToken } from "../utils/auth";
import { encryptLoginPayload } from "../utils/loginCrypto"; import { encryptLoginPayload } from "../utils/loginCrypto";
import type { UserInfo } from "../types/api"; import type { UserInfo } from "../types/api";
@@ -17,7 +18,7 @@ export const useAuthStore = defineStore("auth", () => {
const loading = ref(false); const loading = ref(false);
const forceLogin = 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; loading.value = true;
try { try {
const { data } = const { data } =
@@ -29,10 +30,12 @@ export const useAuthStore = defineStore("auth", () => {
useSessionStore().resetActivity(); useSessionStore().resetActivity();
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email); localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
const me = await fetchMeAction(); const me = await fetchMeAction();
if (options.restoreStudy !== false) {
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const userKey = me?.email || email; const userKey = me?.email || email;
await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin }); await studyStore.restoreStudyForUser(userKey, { preferActive: me?.is_admin });
await studyStore.loadCurrentStudyPermissions().catch(() => {}); await studyStore.loadCurrentStudyPermissions().catch(() => {});
}
forceLogin.value = false; forceLogin.value = false;
} finally { } finally {
loading.value = false; loading.value = false;
@@ -53,8 +56,8 @@ export const useAuthStore = defineStore("auth", () => {
}); });
}; };
const fetchMeAction = async () => { const fetchMeAction = async (config?: ApiRequestConfig) => {
const { data } = await fetchMe(); const { data } = await fetchMe(config);
user.value = data; user.value = data;
if (data?.email) { if (data?.email) {
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email); localStorage.setItem(LAST_LOGIN_EMAIL_KEY, data.email);
@@ -62,11 +65,13 @@ export const useAuthStore = defineStore("auth", () => {
return data; return data;
}; };
const logout = async () => { const logout = async (options: { rememberCurrentStudy?: boolean } = {}) => {
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || ""; const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
if (options.rememberCurrentStudy !== false) {
studyStore.rememberCurrentStudyForUser(userKey); studyStore.rememberCurrentStudyForUser(userKey);
}
token.value = null; token.value = null;
user.value = null; user.value = null;
forceLogin.value = false; forceLogin.value = false;
+575 -2
View File
@@ -3,8 +3,6 @@
* 参考 shadcn-admin 的轻量中性分层风格 * 参考 shadcn-admin 的轻量中性分层风格
*/ */
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap");
:root { :root {
/* 品牌色 - 低饱和蓝灰 */ /* 品牌色 - 低饱和蓝灰 */
--ctms-primary: #3f5d75; --ctms-primary: #3f5d75;
@@ -638,6 +636,363 @@ body {
font-size: 13px; 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 上动态设置 * --ctms-sidebar-width Layout.vue body 上动态设置
@@ -654,3 +1009,221 @@ body {
.el-overlay { .el-overlay {
left: var(--ctms-sidebar-width, 0px) !important; 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;
}
}
+19 -5
View File
@@ -7,6 +7,20 @@
--unified-shell-padding-y: 10px; --unified-shell-padding-y: 10px;
--unified-title-color: #0f2345; --unified-title-color: #0f2345;
--unified-muted-color: #6f84a8; --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 { .ctms-page-shell {
@@ -85,7 +99,7 @@
.page>.page-header.unified-action-bar { .page>.page-header.unified-action-bar {
border: 0; border: 0;
border-radius: var(--unified-shell-radius); border-radius: var(--unified-shell-radius);
background: #ffffff; background: var(--unified-shell-bg);
box-shadow: none; box-shadow: none;
} }
@@ -156,15 +170,15 @@
} }
.unified-shell .el-table th.el-table__cell { .unified-shell .el-table th.el-table__cell {
background-color: #f7f9fc; background-color: var(--unified-table-header-bg);
color: #34506f; color: var(--unified-table-header-color);
font-weight: 600; font-weight: 600;
height: 40px; height: 40px;
font-size: 13px; font-size: 13px;
} }
.unified-shell .el-table td.el-table__cell { .unified-shell .el-table td.el-table__cell {
border-bottom-color: #edf2f8; border-bottom-color: var(--unified-row-divider);
padding: 7px 0; padding: 7px 0;
} }
@@ -178,7 +192,7 @@
} }
.unified-shell .el-tabs__nav-wrap::after { .unified-shell .el-tabs__nav-wrap::after {
background-color: #edf2f8; background-color: var(--unified-row-divider);
} }
.unified-shell .el-tabs__item { .unified-shell .el-tabs__item {
+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" :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 label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError"> <el-form-item label="服务器地址" :error="urlError">
<el-input <el-input
@@ -33,10 +46,6 @@
/> />
</el-form-item> </el-form-item>
<div class="hint">
允许 HTTPS 服务地址本地开发可使用 http://localhost http://127.0.0.1
</div>
<div class="actions"> <div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button> <el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save"> <el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
@@ -51,7 +60,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime"; import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
@@ -65,13 +74,46 @@ const serverUrl = ref(currentServerUrl || "");
const urlError = ref(""); const urlError = ref("");
const saving = ref(false); const saving = ref(false);
const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null); 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 canCancel = computed(() => Boolean(currentServerUrl));
const HEALTH_TIMEOUT_MS = 10_000; 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 checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString(); const healthUrl = new URL("health", baseUrl).toString();
const controller = new AbortController(); const controller = new AbortController();
const startedAt = performance.now();
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
const details = () => ({
serverUrl: baseUrl,
healthUrl,
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
});
try { try {
const response = await fetch(healthUrl, { const response = await fetch(healthUrl, {
method: "GET", method: "GET",
@@ -79,14 +121,21 @@ const checkHealth = async (baseUrl: string) => {
signal: controller.signal, signal: controller.signal,
}); });
if (!response.ok) { 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) { } catch (error) {
if (error instanceof DOMException && error.name === "AbortError") { if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("连接超时,请确认服务端地址和网络状态"); throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
} }
if (error instanceof TypeError) { if (error instanceof TypeError) {
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置"); throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
} }
throw error; throw error;
} finally { } finally {
@@ -95,10 +144,25 @@ const checkHealth = async (baseUrl: string) => {
}; };
const clearSessionForServerChange = async () => { const clearSessionForServerChange = async () => {
await auth.logout(); await auth.logout({ rememberCurrentStudy: false });
studyStore.clearCurrentStudy(); 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 () => { const save = async () => {
urlError.value = ""; urlError.value = "";
connectionStatus.value = null; connectionStatus.value = null;
@@ -110,8 +174,9 @@ const save = async () => {
saving.value = true; saving.value = true;
try { try {
await checkHealth(normalized.url); const health = await checkHealth(normalized.url);
const previous = getDesktopServerUrl(); const previous = getDesktopServerUrl();
if (!(await confirmServerChange(previous, normalized.url))) return;
const result = setDesktopServerUrl(normalized.url); const result = setDesktopServerUrl(normalized.url);
if (!result.ok) { if (!result.ok) {
urlError.value = result.reason; urlError.value = result.reason;
@@ -125,15 +190,32 @@ const save = async () => {
title: "连接已确认", title: "连接已确认",
message: result.url, message: result.url,
}; };
ElMessage.success("服务器连接已确认"); connectionDiagnostic.value = {
healthUrl: health.healthUrl,
checkedAt: formatDiagnosticTime(),
durationMs: health.durationMs,
httpStatus: health.httpStatus,
};
router.replace("/login"); router.replace("/login");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "无法连接服务器的 /health"; const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
connectionStatus.value = { connectionStatus.value = {
type: "error", type: "error",
title: "连接检查失败", title: "连接检查失败",
message, message,
}; };
connectionDiagnostic.value = {
healthUrl: details.healthUrl || new URL("health", normalized.url).toString(),
checkedAt: formatDiagnosticTime(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
};
urlError.value = message; urlError.value = message;
} finally { } finally {
saving.value = false; saving.value = false;
@@ -147,6 +229,7 @@ const goBack = () => {
<style scoped> <style scoped>
.desktop-settings-page { .desktop-settings-page {
box-sizing: border-box;
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -157,7 +240,9 @@ const goBack = () => {
.settings-panel { .settings-panel {
width: min(100%, 520px); width: min(100%, 520px);
max-height: calc(100vh - 64px);
padding: 32px; padding: 32px;
overflow: auto;
border: 1px solid #d9e2ef; border: 1px solid #d9e2ef;
border-radius: 8px; border-radius: 8px;
background: #ffffff; background: #ffffff;
@@ -215,11 +300,36 @@ h1 {
margin-bottom: 18px; margin-bottom: 18px;
} }
.hint { .connection-diagnostic {
margin-top: -8px; 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; color: #64748b;
font-size: 13px; font-size: 12px;
line-height: 1.6; }
.diagnostic-grid strong {
min-width: 0;
color: #1e293b;
font-weight: 700;
}
.diagnostic-grid code {
min-width: 0;
overflow-wrap: anywhere;
} }
.actions { .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> <template>
<div class="page ctms-page-shell page--flush medical-consult-page"> <div class="page ctms-page-shell page--flush medical-consult-page" :class="{ 'medical-consult-page--desktop': isDesktop }">
<div class="page-bg-dots"></div> <div v-if="!isDesktop" class="page-bg-dots"></div>
<section class="faq-hero"> <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"> <div class="hero-tools">
<el-autocomplete <el-autocomplete
v-model="keyword" v-model="keyword"
@@ -19,7 +23,13 @@
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
</template> </template>
</el-autocomplete> </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> </div>
</section> </section>
@@ -34,7 +44,7 @@
</el-col> </el-col>
<el-col :span="canReadCategories ? 19 : 24" class="faq-content-col"> <el-col :span="canReadCategories ? 19 : 24" class="faq-content-col">
<div class="faq-main unified-shell"> <div class="faq-main unified-shell">
<div class="list-toolbar"> <div v-if="!isDesktop" class="list-toolbar">
<PermissionAction action="faq.create"> <PermissionAction action="faq.create">
<el-button type="primary" class="new-consult-btn" @click="openForm()"> <el-button type="primary" class="new-consult-btn" @click="openForm()">
<el-icon class="el-icon--left"><Plus /></el-icon> <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 FaqCategoryPanel from "../components/FaqCategoryPanel.vue";
import FaqList from "../components/FaqList.vue"; import FaqList from "../components/FaqList.vue";
import FaqItemForm from "../components/FaqItemForm.vue"; import FaqItemForm from "../components/FaqItemForm.vue";
import { isTauriRuntime } from "../runtime";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import PermissionAction from "../components/PermissionAction.vue"; import PermissionAction from "../components/PermissionAction.vue";
@@ -87,6 +98,7 @@ import { TEXT } from "../locales";
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const categories = ref<any[]>([]); const categories = ref<any[]>([]);
const faqs = ref<any[]>([]); const faqs = ref<any[]>([]);
@@ -104,6 +116,14 @@ const { can } = usePermission();
const canReadCategories = computed(() => can("faq.category.read")); const canReadCategories = computed(() => can("faq.category.read"));
const canUpdateFaq = computed(() => can("faq.update")); const canUpdateFaq = computed(() => can("faq.update"));
const canDeleteFaq = computed(() => can("faq.delete")); 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 () => { const loadCategories = async () => {
try { try {
@@ -247,7 +267,7 @@ onMounted(async () => {
color: #0f172a; color: #0f172a;
font-size: 32px; font-size: 32px;
font-weight: 900; font-weight: 900;
letter-spacing: -0.02em; letter-spacing: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -361,6 +381,228 @@ onMounted(async () => {
flex: 1; 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) { @media (max-width: 1080px) {
.hero-tools { .hero-tools {
flex-wrap: wrap; flex-wrap: wrap;
@@ -376,5 +618,18 @@ onMounted(async () => {
.faq-main { .faq-main {
padding: 18px 16px 0; 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> </style>
-2
View File
@@ -422,8 +422,6 @@ onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
</script> </script>
<style scoped> <style scoped>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Outfit:wght@600;800&display=swap");
/* /*
根容器 根容器
*/ */
+35 -25
View File
@@ -8,7 +8,7 @@ describe("Login protocol agreement", () => {
it("requires protocol agreement before calling login", () => { it("requires protocol agreement before calling login", () => {
const source = readLoginView(); const source = readLoginView();
const protocolGuardIndex = source.indexOf("!form.agreeProtocol"); 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('v-model="form.agreeProtocol"');
expect(source).toContain("我已阅读并同意"); expect(source).toContain("我已阅读并同意");
@@ -18,21 +18,25 @@ describe("Login protocol agreement", () => {
expect(protocolGuardIndex).toBeLessThan(loginCallIndex); 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(); const source = readLoginView();
expect(source).toContain('autocomplete="username"'); expect(source).toContain('autocomplete="username"');
expect(source).toContain('name="username"'); expect(source).toContain('name="username"');
expect(source).toContain('name="ctms-login-password"'); expect(source).toContain('name="password"');
expect(source).toContain('autocomplete="new-password"'); expect(source).toContain('autocomplete="current-password"');
expect(source).not.toContain('v-model="form.rememberPassword"'); expect(source).toContain('v-model="form.rememberPassword"');
expect(source).not.toContain("记住密码"); expect(source).toContain("记住密码");
expect(source).not.toContain("tryLoadBrowserCredential"); expect(source).toContain("getSavedLoginCredential");
expect(source).not.toContain("tryStoreBrowserCredential"); expect(source).toContain("saveLoginCredential");
expect(source).toContain("clearLoginCredential");
expect(source).not.toContain("navigator.credentials"); expect(source).not.toContain("navigator.credentials");
expect(source).not.toContain("PasswordCredential"); expect(source).not.toContain("PasswordCredential");
expect(source).not.toContain('localStorage.setItem("ctms_saved_password"'); const localStorageSetItem = "localStorage" + ".setItem";
expect(source).not.toContain("localStorage.setItem('ctms_saved_password'"); 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", () => { it("opens a CTMS-specific protocol dialog from the protocol text", () => {
@@ -55,25 +59,26 @@ describe("Login protocol agreement", () => {
expect(source).toContain("confirmProtocol"); expect(source).toContain("confirmProtocol");
}); });
it("avoids browser password caching", () => { it("does not write remembered passwords to browser storage", () => {
const source = readLoginView(); const source = readLoginView();
expect(source).toContain('name="ctms-login-password"'); expect(source).not.toMatch(/localStorage\.setItem\([^)]*password/i);
expect(source).toContain('autocomplete="new-password"'); expect(source).not.toMatch(/sessionStorage\.setItem\([^)]*password/i);
expect(source).not.toContain('autocomplete="current-password"');
}); });
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 source = readLoginView();
const loginCallIndex = source.indexOf("auth.login(form.email, form.password)"); const loginCallIndex = source.indexOf("auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin })");
const userKeyIndex = source.indexOf("const userKey = auth.user?.email || form.email"); const clearIndex = source.indexOf("studyStore.clearCurrentStudy()");
const restoreIndex = source.indexOf("studyStore.restoreStudyForUser(userKey"); const desktopEntryRouteIndex = source.indexOf('router.push("/desktop/project-entry")');
const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")'); const projectOverviewRouteIndex = source.indexOf('router.push("/project/overview")');
expect(userKeyIndex).toBeGreaterThan(loginCallIndex); expect(source).toContain("const isDesktopLogin = showDesktopServerSettings");
expect(restoreIndex).toBeGreaterThan(loginCallIndex); expect(loginCallIndex).toBeGreaterThan(-1);
expect(restoreIndex).toBeLessThan(projectOverviewRouteIndex); expect(clearIndex).toBeGreaterThan(loginCallIndex);
expect(source).toContain("preferActive: !!auth.user?.is_admin"); 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", () => { it("shows persistent logout reason notices on the login card", () => {
@@ -96,12 +101,17 @@ describe("Login protocol agreement", () => {
expect(source).toContain("max-width: calc(100vw - 40px);"); expect(source).toContain("max-width: calc(100vw - 40px);");
}); });
it("uses only server-configured email domains on web and desktop", () => { it("falls back to full email input when no server-configured domains exist", () => {
const source = readLoginView(); const source = readLoginView();
expect(source).toContain('fetchEmailDomains()'); expect(source).toContain("fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true })");
expect(source).toContain(':disabled="availableEmailDomains.length === 0"');
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)"); 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)");
expect(source).toContain('v-if="hasConfiguredEmailDomains"');
expect(source).toContain("未检测到邮箱后缀配置,请输入完整邮箱地址。");
expect(source).toContain("form.email = local;");
expect(source).toContain("if (domains.length === 0)");
expect(source).toContain("configuredEmailDomains.value.includes(domain)"); expect(source).toContain("configuredEmailDomains.value.includes(domain)");
expect(source).not.toContain("preservedEmailDomain"); expect(source).not.toContain("preservedEmailDomain");
expect(source).not.toContain("showDomainSelect"); expect(source).not.toContain("showDomainSelect");
+154 -19
View File
@@ -145,19 +145,19 @@
<!-- 登录表单 --> <!-- 登录表单 -->
<el-form ref="formRef" :model="form" :rules="rules" @keyup.enter="onSubmit" label-position="top" class="login-form"> <el-form ref="formRef" :model="form" :rules="rules" @keyup.enter="onSubmit" label-position="top" class="login-form">
<el-form-item label="账号" prop="email"> <el-form-item label="账号" prop="email">
<div class="email-unified-box"> <div class="email-unified-box" :class="{ 'email-unified-box--manual': manualEmailInput }">
<input <input
id="email" id="email"
v-model="form.emailLocal" v-model="form.emailLocal"
type="text" :type="manualEmailInput ? 'email' : 'text'"
placeholder="请输入账号" :placeholder="emailInputPlaceholder"
name="username" name="username"
autocomplete="username" autocomplete="username"
class="email-local-field" class="email-local-field"
@paste="handleAccountPaste" @paste="handleAccountPaste"
/> />
<span class="email-at-sign">@</span> <span v-if="hasConfiguredEmailDomains" class="email-at-sign">@</span>
<div class="email-domain-wrapper"> <div v-if="hasConfiguredEmailDomains" class="email-domain-wrapper">
<select <select
v-model="form.emailDomain" v-model="form.emailDomain"
class="email-domain-field" class="email-domain-field"
@@ -169,17 +169,21 @@
<svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg> <svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
</div> </div>
</div> </div>
<p v-if="manualEmailInput" class="email-manual-hint">未检测到邮箱后缀配置请输入完整邮箱地址</p>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
<el-input <el-input
id="password" v-model="form.password" type="password" id="password" v-model="form.password" type="password"
placeholder="请输入密码" show-password size="large" 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-input>
</el-form-item> </el-form-item>
<div class="login-options"> <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"> <el-checkbox v-model="form.agreeProtocol" class="protocol-checkbox">
<span class="protocol-text"> <span class="protocol-text">
我已阅读并同意 我已阅读并同意
@@ -255,7 +259,14 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth"; import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales"; 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 { import {
consumeLogoutReason, consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED, LOGOUT_REASON_AUTH_EXPIRED,
@@ -269,7 +280,7 @@ const router = useRouter();
const AGREE_PROTOCOL_KEY = "ctms_agree_protocol"; const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
const formRef = ref<FormInstance>(); 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 configuredEmailDomains = ref<string[]>([]);
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
@@ -290,13 +301,24 @@ const desktopServerUrl = ref(getDesktopServerUrl());
const refreshDesktopServerUrl = () => { const refreshDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl(); desktopServerUrl.value = getDesktopServerUrl();
void loadRememberedCredential(true);
}; };
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, ""); const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
const availableEmailDomains = computed(() => configuredEmailDomains.value); const availableEmailDomains = computed(() => configuredEmailDomains.value);
const hasConfiguredEmailDomains = computed(() =>
availableEmailDomains.value.length > 0 && availableEmailDomains.value.includes(form.emailDomain)
);
const manualEmailInput = computed(() => !hasConfiguredEmailDomains.value);
const emailInputPlaceholder = computed(() => manualEmailInput.value ? "请输入完整邮箱" : "请输入账号");
const syncEmailFromParts = () => { const syncEmailFromParts = () => {
const local = form.emailLocal.trim().toLowerCase(); const local = form.emailLocal.trim().toLowerCase();
if (manualEmailInput.value) {
form.emailDomain = "";
form.email = local;
return;
}
const domain = normalizeDomain(form.emailDomain); const domain = normalizeDomain(form.emailDomain);
form.emailDomain = domain; form.emailDomain = domain;
form.email = local && domain ? `${local}@${domain}` : ""; form.email = local && domain ? `${local}@${domain}` : "";
@@ -304,6 +326,12 @@ const syncEmailFromParts = () => {
const applyEmailValue = (email: string) => { const applyEmailValue = (email: string) => {
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
if (availableEmailDomains.value.length === 0) {
form.emailLocal = normalized;
form.emailDomain = "";
syncEmailFromParts();
return;
}
const separator = normalized.lastIndexOf("@"); const separator = normalized.lastIndexOf("@");
if (separator > 0) { if (separator > 0) {
form.emailLocal = normalized.slice(0, separator); form.emailLocal = normalized.slice(0, separator);
@@ -320,17 +348,33 @@ const applyEmailValue = (email: string) => {
const loadEmailDomains = async () => { const loadEmailDomains = async () => {
try { try {
const { data } = await fetchEmailDomains(); const { data } = await fetchEmailDomains({ disableNetworkRetry: true, suppressErrorMessage: true });
configuredEmailDomains.value = Array.from(new Set( const domains = Array.from(new Set(
data.items.map(normalizeDomain).filter(Boolean) (Array.isArray(data.items) ? data.items : [])
.map(item => typeof item === "string" ? normalizeDomain(item) : "")
.filter(Boolean)
)); ));
if (!configuredEmailDomains.value.includes(form.emailDomain)) { configuredEmailDomains.value = domains;
form.emailDomain = configuredEmailDomains.value[0] || "";
if (domains.length === 0) {
form.emailDomain = "";
syncEmailFromParts();
return;
}
if (form.emailLocal.includes("@")) {
applyEmailValue(form.emailLocal);
return;
}
if (!domains.includes(form.emailDomain)) {
form.emailDomain = domains[0];
} }
} catch { } catch {
configuredEmailDomains.value = []; configuredEmailDomains.value = [];
form.emailDomain = ""; form.emailDomain = "";
} }
syncEmailFromParts();
}; };
const handleAccountPaste = (event: ClipboardEvent) => { const handleAccountPaste = (event: ClipboardEvent) => {
@@ -340,6 +384,43 @@ const handleAccountPaste = (event: ClipboardEvent) => {
applyEmailValue(pasted); 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 () => { onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl); window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
await loadEmailDomains(); await loadEmailDomains();
@@ -353,6 +434,7 @@ onMounted(async () => {
} }
applyEmailValue(localStorage.getItem("ctms_last_login_email") || ""); applyEmailValue(localStorage.getItem("ctms_last_login_email") || "");
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true"; form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
await loadRememberedCredential();
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -367,16 +449,22 @@ const confirmProtocol = () => { form.agreeProtocol = true; protocolDialogVisible
const onSubmit = async () => { const onSubmit = async () => {
if (!formRef.value) return; if (!formRef.value) return;
syncEmailFromParts();
const valid = await formRef.value.validate(); const valid = await formRef.value.validate();
if (!valid) return; if (!valid) return;
if (!form.agreeProtocol) { ElMessage.warning("请先阅读并同意用户协议"); return; } if (!form.agreeProtocol) { ElMessage.warning("请先阅读并同意用户协议"); return; }
loginError.value = null; // loginError.value = null; //
loading.value = true; loading.value = true;
try { try {
await auth.login(form.email, form.password);
const studyStore = useStudyStore(); const studyStore = useStudyStore();
const userKey = auth.user?.email || form.email; const isDesktopLogin = showDesktopServerSettings;
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin }); await auth.login(form.email, form.password, { restoreStudy: !isDesktopLogin });
await syncRememberedCredential();
if (isDesktopLogin) {
studyStore.clearCurrentStudy();
router.push("/desktop/project-entry");
return;
}
if (studyStore.currentStudy) { if (studyStore.currentStudy) {
router.push("/project/overview"); router.push("/project/overview");
} else { } else {
@@ -406,8 +494,6 @@ const onSubmit = async () => {
</script> </script>
<style scoped> <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");
/* /*
根容器 根容器
*/ */
@@ -424,6 +510,7 @@ const onSubmit = async () => {
.login-split-container { .login-split-container {
display: flex; display: flex;
min-width: 0;
width: 100vw; width: 100vw;
min-height: 100vh; min-height: 100vh;
} }
@@ -433,6 +520,7 @@ const onSubmit = async () => {
*/ */
.login-left-brand { .login-left-brand {
flex: 1; flex: 1;
min-width: 0;
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%); background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
padding: 60px 80px; padding: 60px 80px;
position: relative; position: relative;
@@ -681,6 +769,7 @@ const onSubmit = async () => {
*/ */
.login-right-form { .login-right-form {
flex: 1; flex: 1;
min-width: 0;
background: #ffffff; background: #ffffff;
padding: 60px 80px; padding: 60px 80px;
position: relative; position: relative;
@@ -814,6 +903,7 @@ const onSubmit = async () => {
} }
.desktop-server-action { .desktop-server-action {
white-space: nowrap;
color: #2563eb; color: #2563eb;
font-weight: 700; font-weight: 700;
text-decoration: none; text-decoration: none;
@@ -850,7 +940,7 @@ const onSubmit = async () => {
.ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; } .ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
.ln-icon svg { width: 100%; height: 100%; } .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 strong { font-size: 12px; font-weight: 700; }
.ln-body span { font-size: 11px; opacity: 0.9; } .ln-body span { font-size: 11px; opacity: 0.9; }
@@ -948,6 +1038,10 @@ const onSubmit = async () => {
border-bottom-color: #2563eb; border-bottom-color: #2563eb;
} }
.email-unified-box--manual .email-local-field {
width: 100%;
}
.email-local-field, .email-local-field,
.email-domain-field { .email-domain-field {
min-width: 0; min-width: 0;
@@ -969,6 +1063,13 @@ const onSubmit = async () => {
color: #94a3b8; color: #94a3b8;
} }
.email-manual-hint {
margin: 8px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.email-at-sign { .email-at-sign {
flex-shrink: 0; flex-shrink: 0;
padding: 0 4px; padding: 0 4px;
@@ -1015,25 +1116,37 @@ const onSubmit = async () => {
/* 协议区 */ /* 协议区 */
.login-options { .login-options {
display: flex;
flex-direction: column;
gap: 10px;
margin: 4px 0 24px; 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) { .protocol-checkbox :deep(.el-checkbox__input .el-checkbox__inner) {
border-color: #cbd5e1; border-color: #cbd5e1;
border-radius: 4px; 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) { .protocol-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
background: #2563eb; background: #2563eb;
border-color: #2563eb; border-color: #2563eb;
} }
.remember-password-checkbox :deep(.el-checkbox__label),
.protocol-checkbox :deep(.el-checkbox__label) { .protocol-checkbox :deep(.el-checkbox__label) {
padding-left: 8px; padding-left: 8px;
white-space: normal; white-space: normal;
line-height: 1.6; line-height: 1.6;
} }
.remember-password-text,
.protocol-text { .protocol-text {
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
@@ -1281,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) { @media (max-width: 480px) {
.login-brand-header { .login-brand-header {
margin-bottom: 28px; margin-bottom: 28px;
+18 -210
View File
@@ -1,5 +1,4 @@
<template> <template>
<div class="page">
<div class="profile-layout"> <div class="profile-layout">
<aside class="profile-aside"> <aside class="profile-aside">
<div class="avatar-panel"> <div class="avatar-panel">
@@ -64,52 +63,12 @@
</el-form-item> </el-form-item>
</section> </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"> <div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button> <el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div> </div>
</el-form> </el-form>
</main> </main>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -118,25 +77,9 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue"; import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth"; import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth"; 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 { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
const emit = defineEmits<{ const emit = defineEmits<{
"close-request": []; "close-request": [];
@@ -144,31 +87,6 @@ const emit = defineEmits<{
saved: []; saved: [];
}>(); }>();
const auth = useAuthStore(); 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 formRef = ref<FormInstance>();
const submitting = ref(false); const submitting = ref(false);
const form = reactive({ const form = reactive({
@@ -192,7 +110,6 @@ const hasUnsavedChanges = computed(
form.clinical_department !== savedProfile.value.clinical_department || form.clinical_department !== savedProfile.value.clinical_department ||
Boolean(form.current_password || form.password || form.confirmPassword) Boolean(form.current_password || form.password || form.confirmPassword)
); );
const rules: FormRules<typeof form> = { const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }], full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
clinical_department: [{ required: true, message: requiredMessage(TEXT.common.fields.clinicalDepartment), 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; 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 () => { const onSubmit = async () => {
if (!formRef.value) return; if (!formRef.value) return;
await formRef.value.validate(async (valid) => { await formRef.value.validate(async (valid) => {
@@ -345,7 +196,7 @@ const onSubmit = async () => {
}; };
const selectAndUploadAvatar = async () => { const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({ const [file] = await pickFilesWithFeedback({
multiple: false, multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"], accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar, title: TEXT.modules.profile.uploadAvatar,
@@ -368,27 +219,32 @@ const selectAndUploadAvatar = async () => {
onMounted(() => { onMounted(() => {
loadProfile(); loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
}); });
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true }); watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
</script> </script>
<style scoped> <style scoped>
.page {
background: #fff;
}
.profile-layout { .profile-layout {
display: grid; display: grid;
grid-template-columns: 260px minmax(0, 1fr); 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 { .profile-aside {
min-height: 0;
padding: 48px 32px; padding: 48px 32px;
overflow: hidden;
border-right: 1px solid #e5ebf2; border-right: 1px solid #e5ebf2;
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%); background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
} }
.avatar-panel { .avatar-panel {
@@ -442,8 +298,13 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
} }
.profile-main { .profile-main {
min-height: 0;
padding: 42px 48px 36px; padding: 42px 48px 36px;
overflow: hidden;
overflow-wrap: anywhere;
background: #fff; background: #fff;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
} }
.profile-header { .profile-header {
@@ -483,59 +344,6 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2; 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 { .section-heading {
margin-bottom: 18px; margin-bottom: 18px;
padding-left: 112px; padding-left: 112px;
-2
View File
@@ -750,8 +750,6 @@ onUnmounted(() => {
</script> </script>
<style scoped> <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, type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow"; } from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig"; import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime"; import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type SetupStepKey = type SetupStepKey =
| "project-info" | "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 blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_"); const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`; const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
await saveFile({ await saveFileWithFeedback({
suggestedName: filename, suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8", mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob, data: blob,
@@ -378,8 +378,8 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission"; import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue"; import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import { openFile, pickFiles, saveFile } from "../../runtime";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh"; import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const route = useRoute(); const route = useRoute();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -524,7 +524,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
})); }));
const triggerFileInput = async () => { const triggerFileInput = async () => {
const [file] = await pickFiles({ const [file] = await pickFilesWithFeedback({
multiple: false, multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"], accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本", title: "选择文档版本",
@@ -813,7 +813,7 @@ const downloadVersion = async (version: DocumentVersion) => {
const contentType = response.headers?.["content-type"] || "application/octet-stream"; const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`; const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType }); 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); } } 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 response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream"; const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`; const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFile({ await openFileWithFeedback({
suggestedName: filename, suggestedName: filename,
mimeType: contentType, mimeType: contentType,
data: new Blob([response.data], { type: contentType }), data: new Blob([response.data], { type: contentType }),
@@ -48,4 +48,55 @@ describe("DocumentList permissions", () => {
expect(source).toContain("const displayValue = displayDateTime(value)"); expect(source).toContain("const displayValue = displayDateTime(value)");
expect(source).not.toContain('replace("T", " ").replace("Z", "")'); 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"');
});
}); });
+366 -5
View File
@@ -1,5 +1,5 @@
<template> <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="main-content-flat unified-shell">
<div class="filter-container unified-action-bar bar--flush"> <div class="filter-container unified-action-bar bar--flush">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
@@ -29,12 +29,17 @@
</el-form> </el-form>
</div> </div>
<section class="unified-section document-table-section section--flush-x section--flush-top section--flush-bottom"> <section class="unified-section document-table-section section--flush-x section--flush-top section--flush-bottom">
<div class="document-workbench" :class="{ 'is-desktop': isDesktop }">
<div class="document-table-pane" :tabindex="isDesktop ? 0 : undefined" @keydown.enter.prevent="openSelectedDocument">
<el-table <el-table
:data="sortedItems" :data="sortedItems"
v-loading="loading" v-loading="loading"
@row-click="handleRowClick" @row-click="handleDocumentRowClick"
@row-dblclick="openDocumentDetail"
@row-contextmenu="openDocumentContextMenu"
:row-class-name="documentRowClass" :row-class-name="documentRowClass"
class="ctms-table" class="ctms-table"
highlight-current-row
table-layout="fixed" table-layout="fixed"
> >
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip> <el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip>
@@ -76,7 +81,7 @@
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right"> <el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.modules.fileVersionManagement.columns.actions" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-actions"> <div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)"> <el-button v-if="canUpdate" link type="primary" size="small" :disabled="isInactiveSite(row.site_id)" @click.stop="openEdit(row)">
@@ -92,9 +97,64 @@
<el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" /> <el-empty :description="TEXT.modules.fileVersionManagement.emptyDescription" />
</template> </template>
</el-table> </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>
<div v-else class="preview-empty">
<span>选择一行查看文档摘要</span>
</div>
</aside>
</div>
</section> </section>
</div> </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 <el-drawer
v-if="editorVisible" v-if="editorVisible"
v-model="editorVisible" v-model="editorVisible"
@@ -179,6 +239,7 @@ import type { Site } from "../../types/api";
import { displayDateTime, displayEnum, displayText } from "../../utils/display"; import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh"; import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -192,6 +253,9 @@ const editorVisible = ref(false);
const saving = ref(false); const saving = ref(false);
const editingDocumentId = ref(""); const editingDocumentId = ref("");
const editorFormRef = ref<FormInstance>(); const editorFormRef = ref<FormInstance>();
const isDesktop = isTauriRuntime();
const selectedDocumentId = ref("");
const documentContextMenu = ref({ visible: false, x: 0, y: 0 });
let desktopRefreshCleanup: (() => void) | undefined; let desktopRefreshCleanup: (() => void) | undefined;
const trialId = computed(() => (route.params.trialId as string) || ""); 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 canDelete = computed(() => can("documents.delete"));
const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false; const isInactiveSite = (siteId?: string | null) => !!siteId && siteActiveMap.value[siteId] === false;
const documentRowClass = ({ row }: { row: DocumentSummary }) => 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(() => const filteredItems = computed(() =>
items.value.filter((item) => { items.value.filter((item) => {
if (filters.scope_type && item?.scope_type !== filters.scope_type) return false; if (filters.scope_type && item?.scope_type !== filters.scope_type) return false;
@@ -240,6 +310,9 @@ const filteredItems = computed(() =>
const sortedItems = computed(() => const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_id)) - Number(isInactiveSite(b?.site_id))) [...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({ const editorForm = reactive({
doc_no: "", doc_no: "",
@@ -402,11 +475,60 @@ const submitEditor = async () => {
const goDetail = (id: string) => router.push(`/documents/${id}`); const goDetail = (id: string) => router.push(`/documents/${id}`);
const handleRowClick = (row: DocumentSummary) => { const selectDocument = (row: DocumentSummary) => {
if (!row?.id) return; if (!row?.id) return;
selectedDocumentId.value = row.id;
};
const handleDocumentRowClick = (row: DocumentSummary) => {
if (!row?.id) return;
if (isDesktop) {
selectDocument(row);
return;
}
goDetail(row.id); 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) => { const confirmDelete = async (row: DocumentSummary) => {
if (!canDelete.value) { if (!canDelete.value) {
ElMessage.warning("权限不足"); 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> </script>
<style scoped> <style scoped>
@@ -466,6 +604,31 @@ watch(
gap: 0; 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 { .filter-form {
display: flex; display: flex;
width: 100%; width: 100%;
@@ -551,6 +714,162 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02); 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 { .ctms-table {
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
@@ -583,6 +902,10 @@ watch(
transition: background 0.1s ease; transition: background 0.1s ease;
} }
.ctms-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.ctms-table :deep(.font-semibold) { .ctms-table :deep(.font-semibold) {
font-weight: 600; font-weight: 600;
color: #0a0a0a; color: #0a0a0a;
@@ -628,6 +951,44 @@ watch(
.ctms-table :deep(.cell-actions .el-button + .el-button) { .ctms-table :deep(.cell-actions .el-button + .el-button) {
margin-left: 0; 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>
<style> <style>
@@ -35,4 +35,19 @@ describe("ContractFees.vue", () => {
expect(source).not.toContain('router.push("/fees/contracts/new")'); expect(source).not.toContain('router.push("/fees/contracts/new")');
expect(source).not.toContain("`/fees/contracts/${contractId.value}`"); 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> <template>
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush" :class="{ 'fee-contracts-page--desktop': isDesktop }">
<div class="overview"> <div class="overview fee-overview">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6"> <el-col :xs="24" :sm="12" :md="6">
<KpiCard <KpiCard
@@ -53,7 +53,7 @@
<StateLoading v-else-if="loading" :rows="6" /> <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"> <div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item"> <div class="filter-item">
@@ -208,9 +208,11 @@ import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue"; import KpiCard from "../../components/KpiCard.vue";
import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue"; import ContractFeeEditorDrawer from "./ContractFeeEditorDrawer.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const { can } = usePermission(); const { can } = usePermission();
const canCreate = computed(() => can("fees.contract.create")); const canCreate = computed(() => can("fees.contract.create"));
const canUpdate = computed(() => can("fees.contract.update")); const canUpdate = computed(() => can("fees.contract.update"));
@@ -522,6 +524,76 @@ onMounted(async () => {
letter-spacing: 0.02em; 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>
<style> <style>
@@ -85,4 +85,37 @@ describe("DrugShipments project permissions", () => {
expect(source).toContain('status === "EXCEPTION"'); expect(source).toContain('status === "EXCEPTION"');
expect(source).not.toContain('remark: [{ required: true'); 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"');
});
}); });
+405 -5
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page" :class="{ 'shipment-page--desktop': isDesktop }" @click="closeShipmentContextMenu">
<div v-if="study.currentStudy" class="page-inner"> <div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== --> <!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card"> <div class="table-card">
@@ -47,14 +47,24 @@
</el-button> </el-button>
</div> </div>
</div> </div>
<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 <el-table
v-loading="loading" v-loading="loading"
:data="sortedItems" :data="sortedItems"
class="shipment-table" class="shipment-table"
style="width: 100%" style="width: 100%"
table-layout="fixed" table-layout="fixed"
highlight-current-row
:row-class-name="shipmentRowClass" :row-class-name="shipmentRowClass"
@row-click="onRowClick" @row-click="handleShipmentRowClick"
@row-dblclick="openShipmentDetail"
@row-contextmenu="openShipmentContextMenu"
> >
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip> <el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
@@ -96,7 +106,7 @@
<span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span> <span class="cell-muted cell-nowrap">{{ row.remark || TEXT.common.fallback }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="canUpdate || canDelete" :label="TEXT.common.labels.actions" fixed="right"> <el-table-column v-if="!isDesktop && (canUpdate || canDelete)" :label="TEXT.common.labels.actions" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-actions"> <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="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">{{ TEXT.common.actions.edit }}</el-button>
@@ -123,9 +133,92 @@
</template> </template>
</el-table> </el-table>
</div> </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"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedShipment.center_id)"
@click="removeSelectedShipment"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
<div v-else class="preview-empty">
<span>选择一行查看发运摘要</span>
</div>
</aside>
</div>
</div>
</div> </div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <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 <el-drawer
v-if="drawerVisible" v-if="drawerVisible"
@@ -290,6 +383,7 @@ import { displayDate, displayEnum } from "../../utils/display";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue"; import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard"; import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { isTauriRuntime } from "../../runtime";
type ShipmentDirection = "SEND" | "RETURN"; type ShipmentDirection = "SEND" | "RETURN";
type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION"; type ShipmentStatus = "PENDING" | "IN_TRANSIT" | "SIGNED" | "EXCEPTION";
@@ -322,6 +416,10 @@ const drawerVisible = ref(false);
const editingId = ref(""); const editingId = ref("");
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null); 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({ const filters = reactive({
center_id: study.currentSite?.id || "", center_id: study.currentSite?.id || "",
direction: "" as "" | ShipmentDirection, direction: "" as "" | ShipmentDirection,
@@ -363,6 +461,9 @@ const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !can
const sortedItems = computed(() => const sortedItems = computed(() =>
[...items.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id))) [...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 requiresShipmentDetails = (status: ShipmentStatus) => status !== "PENDING";
const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED"; const requiresReceiveDate = (status: ShipmentStatus) => status === "SIGNED";
const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION"; const requiresRemark = (status: ShipmentStatus) => status === "EXCEPTION";
@@ -564,12 +665,69 @@ const handleSearch = () => {
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false; const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const shipmentRowClass = ({ row }: { row: ShipmentRow }) => 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; 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}`); 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) => { const statusType = (status: string) => {
switch (status) { switch (status) {
case "PENDING": return "info"; 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 () => { onMounted(async () => {
await loadSites(); await loadSites();
await load(); await load();
@@ -648,12 +822,26 @@ onMounted(async () => {
background: transparent; background: transparent;
} }
/* 桌面端:撑满父容器高度 */
.shipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner { .page-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
/* 桌面端:page-inner 撑满 .page */
.shipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Page Header ==================== */ /* ==================== Page Header ==================== */
.create-btn { .create-btn {
height: 34px; height: 34px;
@@ -733,6 +921,167 @@ onMounted(async () => {
0 2px 8px rgba(0, 0, 0, 0.02); 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 { .shipment-table {
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
@@ -771,6 +1120,10 @@ onMounted(async () => {
transition: background 0.1s ease; transition: background 0.1s ease;
} }
.shipment-table :deep(.el-table__body tr.row-selected > td) {
background: #eaf1f8 !important;
}
.cell-nowrap { .cell-nowrap {
white-space: nowrap; white-space: nowrap;
} }
@@ -1035,6 +1388,15 @@ onMounted(async () => {
gap: 12px; gap: 12px;
align-items: stretch; 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) { @media (max-width: 640px) {
@@ -1042,6 +1404,44 @@ onMounted(async () => {
width: 100%; 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>
<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);");
});
});
+364 -224
View File
@@ -5,7 +5,7 @@
<div class="etmf-toolbar"> <div class="etmf-toolbar">
<div class="etmf-toolbar-main"> <div class="etmf-toolbar-main">
<div class="etmf-filter-item"> <div class="etmf-filter-item">
<el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable class="filter-select-comp" @change="loadNodeDocuments"> <el-select v-model="filters.siteId" :placeholder="TEXT.common.fields.site" clearable filterable size="small" class="filter-select-comp" @change="loadNodeDocuments">
<template #prefix> <template #prefix>
<el-icon><OfficeBuilding /></el-icon> <el-icon><OfficeBuilding /></el-icon>
</template> </template>
@@ -14,7 +14,7 @@
</el-select> </el-select>
</div> </div>
<div class="etmf-filter-item"> <div class="etmf-filter-item">
<el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable class="filter-select-comp"> <el-select v-model="filters.status" :placeholder="TEXT.common.fields.status" clearable size="small" class="filter-select-comp">
<template #prefix> <template #prefix>
<el-icon><CircleCheck /></el-icon> <el-icon><CircleCheck /></el-icon>
</template> </template>
@@ -23,25 +23,23 @@
</el-select> </el-select>
</div> </div>
</div> </div>
<!-- 内联状态摘要徽章 -->
<div class="etmf-status-inline">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-badge" :class="`etmf-status-badge--${card.key}`">
<span class="status-badge-value">{{ card.value }}</span>
<span class="status-badge-label">{{ card.label }}</span>
</div>
</div>
<div class="etmf-toolbar-actions"> <div class="etmf-toolbar-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button> <el-button size="small" :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button>
<el-button :icon="Refresh" @click="load">{{ TEXT.common.actions.refresh }}</el-button> <el-button v-if="canCreate && selectedNode" size="small" type="primary" @click="openDocumentDialog">
<el-button v-if="canCreate" @click="openNodeDialog">
<el-icon class="el-icon--left"><FolderAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newNode }}
</el-button>
<el-button v-if="canCreate && selectedNode" type="primary" @click="openDocumentDialog">
<el-icon class="el-icon--left"><DocumentAdd /></el-icon> <el-icon class="el-icon--left"><DocumentAdd /></el-icon>
{{ TEXT.modules.etmf.actions.newDocument }} {{ TEXT.modules.etmf.actions.newDocument }}
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="etmf-status-strip">
<div v-for="card in overviewCards" :key="card.key" class="etmf-status-card" :class="`etmf-status-card--${card.key}`">
<div class="status-card-value">{{ card.value }}</div>
<div class="status-card-label">{{ card.label }}</div>
</div>
</div>
</div> </div>
<section class="unified-section section--flush etmf-workspace"> <section class="unified-section section--flush etmf-workspace">
@@ -49,7 +47,6 @@
<div class="panel-heading panel-heading--compact"> <div class="panel-heading panel-heading--compact">
<div> <div>
<div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div> <div class="panel-title">{{ TEXT.modules.etmf.treeTitle }}</div>
<div class="panel-subtitle">{{ totalNodeCount }} 个目录节点</div>
</div> </div>
</div> </div>
<div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree"> <div v-if="!filteredTree.length && !treeLoading" class="etmf-empty-block etmf-empty-block--tree">
@@ -86,69 +83,30 @@
<main class="etmf-document-panel"> <main class="etmf-document-panel">
<div class="panel-heading"> <div class="panel-heading">
<div> <div class="panel-heading-left">
<div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div> <div class="panel-title">{{ selectedNode?.name || TEXT.modules.etmf.noNodeSelected }}</div>
<div class="panel-subtitle"> <span v-if="selectedNode" class="panel-subtitle">{{ selectedNode.code }} · {{ scopeLabel(selectedNode.scope_type) }}</span>
{{ selectedNode ? `${selectedNode.code} · ${scopeLabel(selectedNode.scope_type)}` : TEXT.modules.etmf.selectNodeHint }}
</div>
</div> </div>
<div v-if="selectedNode" class="document-panel-meta"> <div v-if="selectedNode" class="document-panel-meta">
<span>{{ selectedNode.document_count }} 份文件</span> <span>{{ selectedNode.document_count }} 份文件</span>
<span>{{ selectedNode.effective_document_count }} 生效版本</span> <span>{{ selectedNode.effective_document_count }} 生效</span>
<el-tag effect="plain" :type="statusType(selectedNode.status)"> <el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
{{ statusLabel(selectedNode.status) }} {{ statusLabel(selectedNode.status) }}
</el-tag> </el-tag>
</div> </div>
</div> </div>
<el-table <section v-if="selectedNode" class="document-status-summary">
:data="documents" <div class="status-summary-head">
v-loading="documentLoading" <span class="status-summary-label">{{ TEXT.modules.etmf.detailTitle }}</span>
class="ctms-table etmf-document-table" <el-tag size="small" effect="plain" :type="statusType(selectedNode.status)">
table-layout="fixed" {{ statusLabel(selectedNode.status) }}
@row-click="goDocument" </el-tag>
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" width="140">
<template #default="{ row }">
<el-tag effect="plain" type="info">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" width="120">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" width="150">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div class="empty-icon">
<el-icon><DocumentAdd /></el-icon>
</div>
<div class="empty-title">{{ selectedNode ? TEXT.modules.etmf.emptyDocuments : TEXT.modules.etmf.selectNodeHint }}</div>
<div class="empty-desc">{{ selectedNode ? "当前目录下还没有归档文件。" : "选择目录后可查看文件、版本与中心范围。" }}</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div>
</template>
</el-table>
</main>
<aside class="etmf-node-detail">
<div class="panel-title">{{ TEXT.modules.etmf.detailTitle }}</div>
<template v-if="selectedNode">
<dl class="node-meta">
<div>
<dt>{{ TEXT.modules.etmf.fields.code }}</dt>
<dd>{{ selectedNode.code }}</dd>
</div> </div>
<span class="status-summary-note" :class="`status-summary-note--${selectedNode.status.toLowerCase()}`">
{{ statusDescription(selectedNode.status) }}
</span>
<dl class="status-summary-list">
<div> <div>
<dt>{{ TEXT.modules.etmf.fields.scope }}</dt> <dt>{{ TEXT.modules.etmf.fields.scope }}</dt>
<dd>{{ scopeLabel(selectedNode.scope_type) }}</dd> <dd>{{ scopeLabel(selectedNode.scope_type) }}</dd>
@@ -166,18 +124,44 @@
<dd>{{ selectedNode.effective_document_count }}</dd> <dd>{{ selectedNode.effective_document_count }}</dd>
</div> </div>
</dl> </dl>
<div class="node-status-note" :class="`node-status-note--${selectedNode.status.toLowerCase()}`"> </section>
{{ statusDescription(selectedNode.status) }}
<el-table
:data="documents"
v-loading="documentLoading"
class="ctms-table etmf-document-table"
table-layout="fixed"
@row-click="goDocument"
>
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" show-overflow-tooltip min-width="1" />
<el-table-column prop="doc_type" :label="TEXT.modules.fileVersionManagement.columns.docType" min-width="1">
<template #default="{ row }">
<el-tag effect="plain" type="info" size="small">{{ displayText(row.doc_type, TEXT.enums.documentType) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.site" min-width="1" show-overflow-tooltip>
<template #default="{ row }">{{ displaySite(row.site_id) }}</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.currentVersion" min-width="1">
<template #default="{ row }">
<span v-if="row.current_effective_version">{{ row.current_effective_version.version_no }}</span>
<span v-else class="text-secondary">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" min-width="1">
<template #default="{ row }">{{ formatDate(row.updated_at) }}</template>
</el-table-column>
<template #empty>
<div class="etmf-empty-block etmf-empty-block--documents">
<div v-if="selectedNode" class="empty-title">{{ TEXT.modules.etmf.emptyDocuments }}</div>
<div v-if="selectedNode" class="empty-desc">当前目录下还没有归档文件</div>
<el-button v-if="selectedNode && canCreate" size="small" type="primary" @click.stop="openDocumentDialog">
{{ TEXT.modules.etmf.actions.newDocument }}
</el-button>
</div> </div>
</template> </template>
<div v-else class="etmf-empty-block etmf-empty-block--detail"> </el-table>
<div class="empty-icon"> </main>
<el-icon><CircleCheck /></el-icon>
</div>
<div class="empty-title">等待选择目录</div>
<div class="empty-desc">{{ TEXT.modules.etmf.selectNodeHint }}</div>
</div>
</aside>
</section> </section>
</div> </div>
@@ -522,151 +506,233 @@ onMounted(load);
</script> </script>
<style scoped> <style scoped>
.etmf-action-bar { /* ==================== 高度链:让工作区撑满页面剩余空间 ==================== */
display: grid; /* .page 本身占满路由容器给它的全部高度 */
gap: 12px; .page {
padding: 14px 20px; display: flex;
background: #f8fafc; flex-direction: column;
border-bottom: 1px solid var(--ctms-border-light); height: 100%;
min-height: 0;
} }
/* unified-shellmain-content-flat)是直接子元素,flex:1 让它撑满 .page */
.page :deep(.main-content-flat) {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
/* ==================== 顶部单行紧凑工具栏 ==================== */
.etmf-action-bar {
padding: 8px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04);
flex-shrink: 0;
}
.etmf-toolbar { .etmf-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 14px; gap: 10px;
width: 100%; width: 100%;
min-width: 0;
} }
.etmf-toolbar-main, .etmf-toolbar-main,
.etmf-toolbar-actions { .etmf-toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
min-width: 0; flex-shrink: 0;
}
.etmf-toolbar-actions {
flex-wrap: wrap;
justify-content: flex-end;
} }
.etmf-filter-item { .etmf-filter-item {
width: 240px; width: 180px;
min-width: 180px; min-width: 140px;
} }
.etmf-filter-item :deep(.el-select) { .etmf-filter-item :deep(.el-select) {
width: 100%; width: 100%;
} }
.etmf-status-strip { /* 内联状态徽章条 */
display: grid; .etmf-status-inline {
grid-template-columns: repeat(4, minmax(120px, 1fr)); display: flex;
gap: 10px; align-items: center;
gap: 8px;
flex: 1;
justify-content: center;
min-width: 0;
overflow: hidden;
padding: 0 8px;
} }
.etmf-status-card { .etmf-status-badge {
min-height: 64px; display: inline-flex;
display: grid; align-items: center;
align-content: center; gap: 8px;
gap: 2px; padding: 5px 14px 5px 10px;
padding: 10px 14px;
border: 1px solid #e4eaf2;
border-radius: 8px; border-radius: 8px;
background: #fff; background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03); border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.07);
white-space: nowrap;
position: relative;
overflow: hidden;
transition: box-shadow 0.15s;
} }
.status-card-value { /* 左侧彩色竖线 */
font-size: 22px; .etmf-status-badge::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 8px 0 0 8px;
background: #c8d8ef;
}
.etmf-status-badge:hover {
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
}
.status-badge-value {
font-size: 17px;
font-weight: 900;
color: #1e2a3a;
line-height: 1; line-height: 1;
font-weight: 750; letter-spacing: -0.03em;
color: #172033; min-width: 1ch;
text-align: right;
} }
.status-card-label { .status-badge-label {
font-size: 12px; font-size: 11px;
color: #68758a; font-weight: 600;
color: #8a9ab0;
letter-spacing: 0.01em;
line-height: 1.2;
} }
.etmf-status-card--missing .status-card-value { /* 目录 — 蓝色 */
color: #c24141; .etmf-status-badge--nodes::before { background: #4f7ecf; }
} .etmf-status-badge--nodes .status-badge-value { color: #1e3a6e; }
.etmf-status-card--uploaded .status-card-value { /* 缺失 — 红色 */
color: #b7791f; .etmf-status-badge--missing {
background: linear-gradient(90deg, #fff8f8 0%, #fff 50%);
border-color: rgba(224, 82, 82, 0.2);
} }
.etmf-status-badge--missing::before { background: #e05252; }
.etmf-status-badge--missing .status-badge-value { color: #c03434; }
.etmf-status-badge--missing .status-badge-label { color: #c06060; }
.etmf-status-card--effective .status-card-value { /* 已上传 — 琥珀色 */
color: #24764b; .etmf-status-badge--uploaded {
background: linear-gradient(90deg, #fffbf2 0%, #fff 50%);
border-color: rgba(212, 146, 10, 0.2);
} }
.etmf-status-badge--uploaded::before { background: #d4920a; }
.etmf-status-badge--uploaded .status-badge-value { color: #a06b06; }
.etmf-status-badge--uploaded .status-badge-label { color: #b08030; }
/* 已生效 — 绿色 */
.etmf-status-badge--effective {
background: linear-gradient(90deg, #f4fdf8 0%, #fff 50%);
border-color: rgba(45, 172, 110, 0.2);
}
.etmf-status-badge--effective::before { background: #2dac6e; }
.etmf-status-badge--effective .status-badge-value { color: #1a7a4e; }
.etmf-status-badge--effective .status-badge-label { color: #4a9a70; }
/* 旧状态卡片已替换为内联徽章,保留空占位避免其他引用报错 */
.etmf-workspace { .etmf-workspace {
display: grid; display: grid;
grid-template-columns: minmax(280px, 340px) minmax(520px, 1fr) minmax(260px, 320px); grid-template-columns: 280px minmax(0, 1fr);
min-height: calc(100vh - 260px); min-height: 0;
flex: 1;
border-top: 0; border-top: 0;
background: #fff; background: #fff;
overflow: hidden;
} }
.etmf-tree-panel, .etmf-tree-panel,
.etmf-document-panel, .etmf-document-panel {
.etmf-node-detail {
min-width: 0; min-width: 0;
padding: 16px; min-height: 0;
padding: 12px 14px;
overflow-y: auto;
} }
.etmf-tree-panel { .etmf-tree-panel {
border-right: 1px solid var(--ctms-border-light); border-right: 1px solid #e4e8ef;
background: linear-gradient(180deg, #fbfcfe 0%, #f7f9fc 100%); background: linear-gradient(180deg, #f9fbff 0%, #f4f7fd 100%);
} }
.etmf-document-panel { .etmf-document-panel {
border-right: 1px solid var(--ctms-border-light);
background: #fff; background: #fff;
overflow-x: hidden;
} }
.panel-heading { .panel-heading {
min-height: 48px;
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 10px;
margin-bottom: 12px; margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(79, 126, 207, 0.08);
} }
.panel-heading--compact { .panel-heading--compact {
min-height: 38px; margin-bottom: 8px;
margin-bottom: 10px; }
.panel-heading-left {
display: flex;
align-items: baseline;
gap: 0;
min-width: 0;
overflow: hidden;
} }
.panel-title { .panel-title {
font-size: 15px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: var(--ctms-text-main); color: var(--ctms-text-main);
letter-spacing: -0.01em;
} }
.panel-subtitle { .panel-subtitle {
margin-top: 4px; font-size: 11px;
font-size: 12px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-weight: 500;
margin-left: 6px;
} }
.etmf-tree { .etmf-tree {
--el-tree-node-hover-bg-color: #eef4ff; --el-tree-node-hover-bg-color: rgba(79, 126, 207, 0.07);
background: transparent; background: transparent;
padding-top: 2px; padding-top: 2px;
} }
.etmf-tree :deep(.el-tree-node__content) { .etmf-tree :deep(.el-tree-node__content) {
height: 34px; height: 30px;
border-radius: 7px; border-radius: 6px;
margin: 2px 0; margin: 1px 0;
transition: background 0.15s ease;
} }
.etmf-tree :deep(.is-current > .el-tree-node__content) { .etmf-tree :deep(.is-current > .el-tree-node__content) {
background: #eaf2ff; background: linear-gradient(90deg, rgba(79, 126, 207, 0.12) 0%, rgba(79, 126, 207, 0.04) 100%);
box-shadow: inset 3px 0 0 #4f7ecf; box-shadow: inset 3px 0 0 #4f7ecf;
} }
@@ -674,16 +740,21 @@ onMounted(load);
width: 100%; width: 100%;
min-width: 0; min-width: 0;
display: grid; display: grid;
grid-template-columns: max-content minmax(0, 1fr) 28px max-content; grid-template-columns: max-content minmax(0, 1fr) 22px max-content;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
padding-right: 8px; padding-right: 6px;
} }
.tree-node-code { .tree-node-code {
font-size: 12px; font-size: 10px;
font-weight: 700; font-weight: 800;
color: #315f9f; color: #4f7ecf;
background: rgba(79, 126, 207, 0.1);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.02em;
flex-shrink: 0;
} }
.tree-node-name { .tree-node-name {
@@ -691,159 +762,228 @@ onMounted(load);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: var(--ctms-text-main); color: var(--ctms-text-main);
font-size: 12px;
} }
.tree-node-count { .tree-node-count {
height: 20px; height: 18px;
min-width: 22px; min-width: 20px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 999px; border-radius: 999px;
background: #edf2f7; background: linear-gradient(135deg, #e8edf5, #dce4f0);
color: #526174; color: #526174;
font-size: 12px; font-size: 10px;
font-weight: 650; font-weight: 700;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08);
} }
.document-panel-meta { .document-panel-meta {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: nowrap;
justify-content: flex-end; justify-content: flex-end;
gap: 8px; gap: 6px;
font-size: 12px; font-size: 11px;
font-weight: 500;
color: #69778d; color: #69778d;
flex-shrink: 0;
}
.document-status-summary {
display: flex;
align-items: center;
gap: 10px;
margin: 0 0 10px;
padding: 8px 12px;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 8px;
background: linear-gradient(135deg, #f9fbff 0%, #f3f7fd 100%);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04);
}
.status-summary-head {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-summary-label {
color: var(--ctms-text-main);
font-size: 12px;
font-weight: 700;
}
.status-summary-note {
font-size: 11px;
color: var(--ctms-text-secondary);
line-height: 1.4;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-summary-note--effective { color: #1a5c3a; }
.status-summary-note--missing { color: #7a2020; }
.status-summary-note--uploaded { color: #7a4e0a; }
.status-summary-list {
display: flex;
gap: 6px;
margin: 0;
flex-shrink: 0;
}
.status-summary-list div {
display: flex;
align-items: baseline;
gap: 3px;
min-width: 0;
padding: 3px 8px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(79, 126, 207, 0.1);
}
.status-summary-list dt {
color: var(--ctms-text-secondary);
font-size: 10px;
font-weight: 600;
order: 2;
}
.status-summary-list dd {
min-width: 0;
margin: 0;
color: var(--ctms-text-main);
font-size: 13px;
font-weight: 800;
order: 1;
} }
.etmf-document-table { .etmf-document-table {
width: 100%; width: 100%;
} }
.node-meta { /* 文档表格行悬停效果 */
display: grid; .etmf-document-table :deep(.el-table__body tr:hover > td) {
gap: 12px; background: rgba(79, 126, 207, 0.04) !important;
margin: 16px 0; cursor: pointer;
} }
.node-meta div { /* 表格头部紧凑化 */
display: grid; .etmf-document-table :deep(th.el-table__cell) {
grid-template-columns: 96px minmax(0, 1fr); padding: 8px 12px;
gap: 10px; font-size: 11px;
font-weight: 700;
color: #526174;
background: #f8fafc;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
} }
.node-meta dt { .etmf-document-table :deep(td.el-table__cell) {
color: var(--ctms-text-secondary); padding: 8px 12px;
}
.node-meta dd {
min-width: 0;
margin: 0;
font-weight: 600;
color: var(--ctms-text-main);
overflow-wrap: anywhere;
}
.node-status-note {
border-left: 3px solid #8aa0bd;
background: #f6f8fb;
padding: 10px 12px;
font-size: 13px; font-size: 13px;
line-height: 1.5;
color: var(--ctms-text-main);
} }
.node-status-note--effective { /* 隐藏表格底部分隔线(el-table inner-wrapper 的 ::before 伪元素) */
border-left-color: #2f8f5b; .etmf-document-table :deep(.el-table__inner-wrapper::before) {
display: none;
} }
.node-status-note--missing { /* 隐藏横向滚动条 */
border-left-color: #c84646; .etmf-document-table :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
} }
.node-status-note--uploaded { .etmf-document-table :deep(.el-scrollbar__wrap) {
border-left-color: #c58b20; overflow-x: hidden;
} }
.etmf-empty-block { .etmf-empty-block {
min-height: 168px; min-height: 120px;
display: grid; display: grid;
place-items: center; place-items: center;
align-content: center; align-content: center;
gap: 8px; gap: 8px;
padding: 22px; padding: 20px;
text-align: center; text-align: center;
color: #7b8797; color: #7b8797;
} }
.etmf-empty-block--tree { .etmf-empty-block--tree {
min-height: 280px; min-height: 200px;
border: 1px dashed #d8e0eb; border-radius: 10px;
border-radius: 8px; background: linear-gradient(135deg, #f4f7fd 0%, #eef3fb 100%);
background: rgba(255, 255, 255, 0.68);
} }
.etmf-empty-block--documents { .etmf-empty-block--documents {
min-height: 420px; min-height: 300px;
}
.etmf-empty-block--detail {
min-height: 280px;
} }
.empty-icon { .empty-icon {
width: 44px; width: 40px;
height: 44px; height: 40px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 10px; border-radius: 10px;
background: #eef3f9; background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #6b7c91; color: #4f7ecf;
font-size: 22px; font-size: 18px;
box-shadow: 0 3px 8px rgba(79, 126, 207, 0.15);
} }
.empty-title { .empty-title {
font-size: 15px; font-size: 13px;
font-weight: 700; font-weight: 700;
color: #2f3a4c; color: #2a3550;
letter-spacing: -0.01em;
} }
.empty-desc { .empty-desc {
max-width: 320px; max-width: 280px;
font-size: 13px; font-size: 12px;
line-height: 1.55; line-height: 1.5;
color: #7b8797; color: #7b8797;
} }
@media (max-width: 1180px) { @media (max-width: 900px) {
.etmf-toolbar { .etmf-toolbar {
align-items: stretch; flex-wrap: wrap;
flex-direction: column;
} }
.etmf-toolbar-actions { .etmf-status-inline {
justify-content: flex-start; display: none;
} }
.etmf-workspace { .etmf-workspace {
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); grid-template-columns: 240px minmax(0, 1fr);
} }
.etmf-node-detail { .status-summary-list {
grid-column: 1 / -1; display: none;
border-top: 1px solid var(--ctms-border-light); }
.document-status-summary {
flex-wrap: wrap;
} }
} }
@media (max-width: 760px) { @media (max-width: 700px) {
.etmf-status-strip { .etmf-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr)); flex-direction: column;
align-items: stretch;
} }
.etmf-toolbar-main { .etmf-toolbar-main {
flex-direction: column; flex-wrap: wrap;
align-items: stretch;
} }
.etmf-filter-item { .etmf-filter-item {
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./MaterialEquipment.vue"), "utf8");
describe("MaterialEquipment desktop list workflow", () => {
it("keeps desktop equipment browsing in a master detail workflow", () => {
const source = readSource();
expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="equipment-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="equipment-preview-pane"');
expect(source).toContain('ref="equipmentTablePaneRef"');
expect(source).toContain("selectedEquipment");
expect(source).toContain('@row-click="handleEquipmentRowClick"');
expect(source).toContain('@row-dblclick="openEquipmentDetail"');
expect(source).toContain('@keydown.enter.prevent="openSelectedEquipment"');
expect(source).toContain("selectEquipment(row);");
expect(source).toContain("equipmentTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain('router.push({ name: "MaterialEquipmentDetail"');
expect(source).toContain('"row-selected"');
expect(source).not.toContain('@row-click="onRowClick"');
});
it("routes desktop equipment actions through preview and context menu controls", () => {
const source = readSource();
expect(source).toContain('@row-contextmenu="openEquipmentContextMenu"');
expect(source).toContain("equipmentContextMenu");
expect(source).toContain('class="equipment-context-menu"');
expect(source).toContain('@click="openSelectedEquipment"');
expect(source).toContain('@click="openSelectedEquipmentEditor"');
expect(source).toContain('@click="removeSelectedEquipment"');
expect(source).toContain('@click="copySelectedEquipmentName"');
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain('v-if="!isDesktop && (canUpdate || canDelete)"');
});
});
+428 -34
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page" :class="{ 'equipment-page--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-inner"> <div v-if="study.currentStudy" class="page-inner">
<!-- ==================== 表格卡片含筛选栏 ==================== --> <!-- ==================== 表格卡片含筛选栏 ==================== -->
<div class="table-card"> <div class="table-card">
@@ -24,6 +24,13 @@
</el-button> </el-button>
</div> </div>
</div> </div>
<div class="equipment-workbench" :class="{ 'is-desktop': isDesktop }">
<div
ref="equipmentTablePaneRef"
class="equipment-table-pane"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedEquipment"
>
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="rows" :data="rows"
@@ -31,7 +38,9 @@
style="width: 100%" style="width: 100%"
table-layout="fixed" table-layout="fixed"
:row-class-name="equipmentRowClass" :row-class-name="equipmentRowClass"
@row-click="onRowClick" @row-click="handleEquipmentRowClick"
@row-dblclick="openEquipmentDetail"
@row-contextmenu="openEquipmentContextMenu"
> >
<el-table-column prop="name" label="设备名称" show-overflow-tooltip> <el-table-column prop="name" label="设备名称" show-overflow-tooltip>
<template #default="{ row }"> <template #default="{ row }">
@@ -60,7 +69,7 @@
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="130" fixed="right"> <el-table-column v-if="!isDesktop && (canUpdate || canDelete)" label="操作" width="130" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-actions"> <div class="cell-actions">
<el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button> <el-button v-if="canUpdate" link type="primary" size="small" @click.stop="openEdit(row)">编辑</el-button>
@@ -78,6 +87,56 @@
</template> </template>
</el-table> </el-table>
</div> </div>
<aside v-if="isDesktop" class="equipment-preview-pane">
<template v-if="selectedEquipment">
<div class="preview-head">
<div>
<div class="preview-kicker">当前设备</div>
<div class="preview-title">{{ selectedEquipment.name || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSelectedEquipment">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>规格型号</dt>
<dd>{{ selectedEquipment.specModel || TEXT.common.fallback }}</dd>
<dt>单位</dt>
<dd>{{ selectedEquipment.unit || TEXT.common.fallback }}</dd>
<dt>品牌</dt>
<dd>{{ selectedEquipment.brand || TEXT.common.fallback }}</dd>
<dt>产地</dt>
<dd>{{ selectedEquipment.origin || TEXT.common.fallback }}</dd>
<dt>校准</dt>
<dd>{{ selectedEquipment.needCalibration ? "需要校准" : "无需校准" }}</dd>
<dt>周期</dt>
<dd>{{ selectedEquipment.needCalibration ? `${selectedEquipment.calibrationCycleDays || TEXT.common.fallback}` : TEXT.common.fallback }}</dd>
</dl>
<div class="preview-actions">
<el-button v-if="canUpdate" size="small" @click="openSelectedEquipmentEditor">编辑</el-button>
<el-button v-if="canDelete" size="small" type="danger" plain @click="removeSelectedEquipment">删除</el-button>
</div>
</template>
<div v-else class="preview-empty">
<div class="empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
</div>
<span>选择一行查看详情摘要</span>
</div>
</aside>
</div>
</div>
<div
v-if="isDesktop && equipmentContextMenu.visible && selectedEquipment"
class="equipment-context-menu"
:style="{ left: `${equipmentContextMenu.x}px`, top: `${equipmentContextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedEquipment">打开详情</button>
<button v-if="canUpdate" type="button" @click="openSelectedEquipmentEditor">编辑</button>
<button type="button" @click="copySelectedEquipmentName">复制设备名称</button>
<button v-if="canDelete" type="button" class="danger" @click="removeSelectedEquipment">删除</button>
</div>
</div> </div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
@@ -174,7 +233,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from "vue"; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus"; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue"; import { Plus } from "@element-plus/icons-vue";
@@ -192,6 +251,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles"; import { isSystemAdmin } from "../../utils/roles";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard"; import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { isTauriRuntime } from "../../runtime";
interface EquipmentRow { interface EquipmentRow {
id: string; id: string;
@@ -209,12 +269,16 @@ type FormModel = Omit<EquipmentRow, "id">;
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
const router = useRouter(); const router = useRouter();
const isDesktop = isTauriRuntime();
const filters = reactive({ name: "" }); const filters = reactive({ name: "" });
const rows = ref<EquipmentRow[]>([]); const rows = ref<EquipmentRow[]>([]);
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const drawerVisible = ref(false); const drawerVisible = ref(false);
const editingId = ref(""); const editingId = ref("");
const selectedEquipmentId = ref("");
const equipmentContextMenu = ref({ visible: false, x: 0, y: 0 });
const equipmentTablePaneRef = ref<HTMLElement | null>(null);
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null); const attachmentPanelRef = ref<InstanceType<typeof AttachmentList> | null>(null);
@@ -239,6 +303,7 @@ const canCreate = computed(() => isAdmin.value || isApiPermissionAllowed(study.c
const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"])); const canUpdate = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:update"]));
const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"])); const canDelete = computed(() => isAdmin.value || isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.["material_equipments:delete"]));
const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value)); const isFormReadOnly = computed(() => (editingId.value ? !canUpdate.value : !canCreate.value));
const selectedEquipment = computed(() => rows.value.find((item) => item.id === selectedEquipmentId.value) || null);
const rules: FormRules<FormModel> = { const rules: FormRules<FormModel> = {
name: [{ required: true, message: "请输入设备名称", trigger: "blur" }], name: [{ required: true, message: "请输入设备名称", trigger: "blur" }],
@@ -326,11 +391,76 @@ const openEdit = (row: EquipmentRow) => {
drawerVisible.value = true; drawerVisible.value = true;
}; };
const equipmentRowClass = ({ row }: { row: EquipmentRow }) => (row?.id ? "clickable-row" : ""); const equipmentRowClass = ({ row }: { row: EquipmentRow }) =>
[
row?.id ? "clickable-row" : "",
isDesktop && row?.id === selectedEquipmentId.value ? "row-selected" : "",
]
.filter(Boolean)
.join(" ");
const onRowClick = (row: EquipmentRow) => { const selectEquipment = (row: EquipmentRow) => {
if (!row?.id) return; if (!row?.id) return;
router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: row.id } }); closeEquipmentContextMenu();
selectedEquipmentId.value = row.id;
equipmentTablePaneRef.value?.focus({ preventScroll: true });
};
const handleEquipmentRowClick = (row: EquipmentRow) => {
if (!row?.id) return;
if (isDesktop) {
selectEquipment(row);
return;
}
openEquipmentDetail(row);
};
const openEquipmentDetail = (row?: EquipmentRow | null) => {
const target = row?.id ? row : selectedEquipment.value;
if (target?.id) router.push({ name: "MaterialEquipmentDetail", params: { equipmentId: target.id } });
};
const openSelectedEquipment = () => {
closeEquipmentContextMenu();
openEquipmentDetail(selectedEquipment.value);
};
const openSelectedEquipmentEditor = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) openEdit(target);
};
const removeSelectedEquipment = () => {
const target = selectedEquipment.value;
closeEquipmentContextMenu();
if (target) {
void removeRow(target);
}
};
const openEquipmentContextMenu = (row: EquipmentRow, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault();
selectEquipment(row);
equipmentContextMenu.value = {
visible: true,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - 172)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 142)),
};
};
const closeEquipmentContextMenu = () => {
if (!equipmentContextMenu.value.visible) return;
equipmentContextMenu.value = { visible: false, x: 0, y: 0 };
};
const copySelectedEquipmentName = async () => {
const name = selectedEquipment.value?.name;
closeEquipmentContextMenu();
if (!name || !navigator.clipboard) return;
await navigator.clipboard.writeText(name);
ElMessage.success("设备名称已复制");
}; };
const saveForm = async () => { const saveForm = async () => {
@@ -405,10 +535,25 @@ watch(
filters.name = ""; filters.name = "";
loadRows(); loadRows();
drawerVisible.value = false; drawerVisible.value = false;
selectedEquipmentId.value = "";
}, },
{ immediate: true } { immediate: true }
); );
watch(
() => rows.value.map((item) => item.id).join("|"),
() => {
if (!isDesktop) return;
if (!rows.value.length) {
selectedEquipmentId.value = "";
return;
}
if (!rows.value.some((item) => item.id === selectedEquipmentId.value)) {
selectedEquipmentId.value = rows.value[0].id;
}
},
);
watch( watch(
() => form.needCalibration, () => form.needCalibration,
(need) => { (need) => {
@@ -416,6 +561,14 @@ watch(
if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30; if (need && !form.calibrationCycleDays) form.calibrationCycleDays = 30;
} }
); );
onMounted(() => {
if (isDesktop) document.addEventListener("click", closeEquipmentContextMenu);
});
onBeforeUnmount(() => {
if (isDesktop) document.removeEventListener("click", closeEquipmentContextMenu);
});
</script> </script>
<style scoped> <style scoped>
@@ -427,20 +580,35 @@ watch(
background: transparent; background: transparent;
} }
/* 桌面端:撑满父容器高度 */
.equipment-page--desktop {
height: 100%;
min-height: 0;
padding: 0;
}
.page-inner { .page-inner {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
/* 桌面端:page-inner 撑满 .page */
.equipment-page--desktop > .page-inner {
flex: 1;
min-height: 0;
gap: 0;
}
/* ==================== Table Toolbar ==================== */ /* ==================== Table Toolbar ==================== */
.table-card-toolbar { .table-card-toolbar {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 14px 20px; padding: 14px 20px;
border-bottom: 1px solid #f0f0f0; border-bottom: 1px solid rgba(79, 126, 207, 0.1);
gap: 12px; gap: 12px;
background: linear-gradient(135deg, #f8faff 0%, #f2f6ff 100%);
} }
.toolbar-filters { .toolbar-filters {
@@ -466,16 +634,30 @@ watch(
.filter-label { .filter-label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 700;
color: #8a8a8a; color: #7a8ca8;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: 0.04em;
} }
.filter-input { .filter-input {
width: 200px; width: 200px;
} }
.filter-input :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(79, 126, 207, 0.2);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition: border-color 0.2s, box-shadow 0.2s;
}
.filter-input :deep(.el-input__wrapper:hover),
.filter-input :deep(.el-input__wrapper.is-focus) {
border-color: rgba(79, 126, 207, 0.5);
box-shadow: 0 0 0 3px rgba(79, 126, 207, 0.1);
}
.filter-actions { .filter-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -484,21 +666,35 @@ watch(
} }
.filter-btn { .filter-btn {
border-radius: 6px; border-radius: 8px;
} }
.filter-summary { .filter-summary {
display: inline-flex;
align-items: center;
padding: 2px 10px;
background: rgba(79, 126, 207, 0.08);
border: 1px solid rgba(79, 126, 207, 0.15);
border-radius: 20px;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 700;
color: #a3a3a3; color: #4f7ecf;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.01em;
} }
.create-btn { .create-btn {
height: 34px; height: 34px;
border-radius: 8px; border-radius: 8px;
padding: 0 16px; padding: 0 18px;
font-weight: 500; font-weight: 600;
box-shadow: 0 2px 6px rgba(64, 128, 220, 0.25);
transition: box-shadow 0.2s, transform 0.15s;
}
.create-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(64, 128, 220, 0.35);
} }
/* ==================== Table Card ==================== */ /* ==================== Table Card ==================== */
@@ -511,6 +707,58 @@ watch(
0 2px 8px rgba(0, 0, 0, 0.02); 0 2px 8px rgba(0, 0, 0, 0.02);
} }
/* 桌面端:table-card 填满、无圆角 */
.equipment-page--desktop .table-card {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
box-shadow: none;
}
.equipment-workbench {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.equipment-workbench.is-desktop {
display: grid;
grid-template-columns: minmax(0, 1fr) 304px;
/* 桌面端:不限定固定高度,由父容器 flex 撑满 */
flex: 1;
min-height: 0;
}
.equipment-table-pane {
min-width: 0;
outline: none;
}
/* 禁用表格横向滚动:阻断滚动行为 + 隐藏滚动条元素 */
.equipment-table-pane :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.equipment-table-pane :deep(.el-scrollbar__bar.is-horizontal) {
display: none;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 1px solid #e3e9f1;
}
.equipment-preview-pane {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 14px;
padding: 16px;
background: linear-gradient(180deg, #f5f8ff 0%, #eef3fb 100%);
border-left: 1px solid rgba(79, 126, 207, 0.1);
}
.equipment-table { .equipment-table {
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
@@ -543,6 +791,10 @@ watch(
transition: background 0.1s ease; transition: background 0.1s ease;
} }
.equipment-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */ /* ==================== Cell Helpers ==================== */
.cell-nowrap { .cell-nowrap {
white-space: nowrap; white-space: nowrap;
@@ -573,21 +825,151 @@ watch(
.status-pill { .status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 2px 12px; padding: 2px 10px;
font-size: 12px; font-size: 11px;
font-weight: 700; font-weight: 700;
border-radius: 20px; border-radius: 20px;
line-height: 1.6; line-height: 1.7;
letter-spacing: 0.02em;
} }
.status-pill--yes { .status-pill--yes {
background: #dcfce7; background: linear-gradient(135deg, #d1fae5, #a7f3d0);
color: #16a34a; color: #065f46;
border: 1px solid rgba(16, 185, 129, 0.2);
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.15);
} }
.status-pill--no { .status-pill--no {
background: #f5f5f5; background: #f1f5f9;
color: #737373; color: #64748b;
border: 1px solid rgba(100, 116, 139, 0.15);
}
/* ==================== Desktop Preview ==================== */
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(79, 126, 207, 0.12);
}
.preview-kicker {
display: inline-flex;
align-items: center;
gap: 5px;
color: #4f7ecf;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
background: rgba(79, 126, 207, 0.1);
padding: 2px 8px;
border-radius: 20px;
}
.preview-title {
margin-top: 8px;
color: #0f172a;
font-size: 17px;
font-weight: 800;
word-break: break-word;
letter-spacing: -0.01em;
line-height: 1.3;
}
.preview-list {
display: block;
margin: 0;
border: 1px solid rgba(79, 126, 207, 0.1);
border-radius: 10px;
overflow: hidden;
background: #fff;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);
}
/* dt/dd float 布局:dt 左浮动作为标签列,dd 跟随在右侧 */
.preview-list dt {
float: left;
clear: left;
width: 64px;
padding: 9px 6px 9px 14px;
color: #7a8ca8;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
line-height: 1.5;
}
.preview-list dd {
margin-left: 0;
padding: 9px 14px 9px 78px;
border-bottom: 1px solid rgba(79, 126, 207, 0.07);
color: #1a2540;
font-size: 13px;
font-weight: 700;
overflow-wrap: anywhere;
min-width: 0;
}
.preview-list dd:last-child {
border-bottom: none;
}
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding-top: 4px;
}
.preview-empty {
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #94a3b8;
font-size: 13px;
text-align: center;
}
.equipment-context-menu {
position: fixed;
z-index: 3000;
min-width: 152px;
padding: 5px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.equipment-context-menu button {
display: block;
width: 100%;
min-height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.equipment-context-menu button:hover:not(:disabled) {
background: #eef4ff;
}
.equipment-context-menu button.danger {
color: #dc2626;
} }
/* ==================== Empty State ==================== */ /* ==================== Empty State ==================== */
@@ -597,30 +979,33 @@ watch(
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 12px;
} }
.empty-icon { .empty-icon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 48px; width: 52px;
height: 48px; height: 52px;
border-radius: 50%; border-radius: 14px;
background: #f5f5f5; background: linear-gradient(135deg, #e8f0fc, #dce8f8);
color: #c4c4c4; color: #4f7ecf;
box-shadow: 0 4px 12px rgba(79, 126, 207, 0.15);
} }
.table-empty span { .table-empty span {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
color: #a3a3a3; color: #8aa0bd;
} }
/* ==================== Drawer Editor ==================== */ /* ==================== Drawer Editor ==================== */
:deep(.equipment-editor-drawer > .el-drawer__header) { :deep(.equipment-editor-drawer > .el-drawer__header) {
margin-bottom: 0; margin-bottom: 0;
padding: 20px 24px 8px; padding: 20px 24px 16px;
background: linear-gradient(135deg, #f8faff 0%, #f0f5ff 100%);
border-bottom: 1px solid rgba(79, 126, 207, 0.1);
} }
:deep(.equipment-editor-drawer > .el-drawer__body) { :deep(.equipment-editor-drawer > .el-drawer__body) {
@@ -634,10 +1019,10 @@ watch(
.editor-title { .editor-title {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 800;
color: #0a0a0a; color: #0a0a0a;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.01em; letter-spacing: -0.02em;
} }
.equipment-form { .equipment-form {
@@ -746,6 +1131,15 @@ watch(
/* ==================== Responsive ==================== */ /* ==================== Responsive ==================== */
@media (max-width: 960px) { @media (max-width: 960px) {
.equipment-workbench.is-desktop {
grid-template-columns: 1fr;
}
.equipment-workbench.is-desktop .equipment-table-pane {
border-right: 0;
}
.equipment-preview-pane {
display: none;
}
.field-grid--2, .field-grid--2,
.field-grid--3 { .field-grid--3 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+38 -11
View File
@@ -462,6 +462,32 @@ watch(
.page { .page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 撑满父容器(desktop-route-shell 已经是 height: 100%*/
height: 100%;
min-height: 0;
}
/* 让白色卡片壳撑满 .page 的剩余高度,并去掉圆角 */
.page > .unified-shell {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-radius: 0;
}
/* 让表格 section 撑满卡片壳的剩余高度 */
.page > .unified-shell > .unified-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* el-table 本身填满 section */
.page > .unified-shell > .unified-section :deep(.el-table),
.page > .unified-shell > .unified-section :deep(.el-table__inner-wrapper) {
height: 100%;
} }
.ctms-page-content-grid { .ctms-page-content-grid {
@@ -480,7 +506,7 @@ watch(
.card-title { .card-title {
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
color: #0f2345; color: var(--unified-title-color);
} }
.card-subtitle { .card-subtitle {
@@ -520,11 +546,11 @@ watch(
border-radius: 50%; border-radius: 50%;
border: 3px solid #2f84c6; border: 3px solid #2f84c6;
box-sizing: border-box; box-sizing: border-box;
background: #ffffff; background: var(--ctms-bg-card);
} }
.plan-time-label { .plan-time-label {
color: #4b5563; color: var(--ctms-text-secondary);
font-size: 11px; font-size: 11px;
} }
@@ -546,10 +572,10 @@ watch(
} }
.time-editor-group { .time-editor-group {
border: 1px solid #e8eef6; border: 1px solid var(--ctms-border-color);
border-radius: 10px; border-radius: 10px;
padding: 14px 16px 10px; padding: 14px 16px 10px;
background: #fbfcfe; background: var(--ctms-bg-muted);
} }
.time-editor-group + .time-editor-group { .time-editor-group + .time-editor-group {
@@ -566,7 +592,7 @@ watch(
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
margin-bottom: 12px; margin-bottom: 12px;
color: #1a3560; color: var(--ctms-text-main);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -600,14 +626,14 @@ watch(
.duration-label { .duration-label {
font-size: 13px; font-size: 13px;
color: #6b7280; color: var(--ctms-text-secondary);
font-weight: 500; font-weight: 500;
} }
.duration-value { .duration-value {
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: #1a3560; color: var(--ctms-text-main);
} }
.time-editor-footer { .time-editor-footer {
@@ -628,7 +654,7 @@ watch(
.time-editor-form :deep(.el-form-item__label) { .time-editor-form :deep(.el-form-item__label) {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #4a6283; color: var(--ctms-text-regular);
padding-bottom: 4px; padding-bottom: 4px;
} }
@@ -655,7 +681,7 @@ watch(
.status-detail { .status-detail {
padding-left: 14px; padding-left: 14px;
color: #6b7280; color: var(--ctms-text-secondary);
font-size: 11px; font-size: 11px;
} }
@@ -684,9 +710,10 @@ watch(
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #8a97ab; color: var(--ctms-text-secondary);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
</style> </style>
+587 -5
View File
@@ -1,8 +1,44 @@
<template> <template>
<div class="page ctms-page-shell page--flush"> <div class="page ctms-page-shell page--flush" :class="{ 'project-overview--desktop': isDesktop }">
<div v-if="study.currentStudy" class="page-body"> <div v-if="study.currentStudy" class="page-body">
<div class="overview-container"> <div class="overview-container">
<section class="overview-card"> <section v-if="isDesktop" class="desktop-attention-section">
<div class="desktop-attention-board">
<div class="attention-card attention-card--stages">
<div class="attention-title">阶段状态</div>
<div class="stage-status-grid">
<div v-for="item in stageStatusSummary" :key="item.key" class="stage-status-item">
<span class="legend-dot" :class="item.dotClass"></span>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</div>
</div>
</div>
<div class="attention-card">
<div class="attention-title">当前推进</div>
<div v-if="activeStageItems.length" class="attention-list">
<div v-for="item in activeStageItems" :key="item.key" class="attention-list-row">
<strong>{{ item.centerName }}</strong>
<span>{{ item.stageLabel }}</span>
</div>
</div>
<div v-else class="desktop-empty-note">暂无进行中阶段</div>
</div>
<div class="attention-card">
<div class="attention-title">需关注</div>
<div class="attention-list">
<div v-for="item in attentionItems" :key="item" class="attention-list-row attention-list-row--plain">
<span>{{ item }}</span>
</div>
</div>
</div>
</div>
</section>
<div class="overview-workbench">
<section class="overview-card overview-card--progress">
<div class="card-header"> <div class="card-header">
<div class="card-header-left"> <div class="card-header-left">
<span class="card-icon card-icon--progress"> <span class="card-icon card-icon--progress">
@@ -17,6 +53,7 @@
</template> </template>
刷新 刷新
</el-button> </el-button>
<span v-if="isDesktop" class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>
<div class="progress-legend"> <div class="progress-legend">
<span class="legend-item"><span class="legend-dot completed"></span>已完成</span> <span class="legend-item"><span class="legend-dot completed"></span>已完成</span>
<span class="legend-item"><span class="legend-dot active"></span>进行中</span> <span class="legend-item"><span class="legend-dot active"></span>进行中</span>
@@ -44,7 +81,7 @@
</div> </div>
</section> </section>
<section class="overview-card"> <section class="overview-card overview-card--enrollment">
<div class="card-header"> <div class="card-header">
<div class="card-header-left"> <div class="card-header-left">
<span class="card-icon card-icon--enrollment"> <span class="card-icon card-icon--enrollment">
@@ -55,12 +92,46 @@
<div class="card-subtitle">{{ enrollmentSummary }}</div> <div class="card-subtitle">{{ enrollmentSummary }}</div>
</div> </div>
</div> </div>
<el-radio-group v-model="chartMode" size="small" class="mode-switch"> <el-radio-group v-if="!isDesktop" v-model="chartMode" size="small" class="mode-switch">
<el-radio-button label="center">按中心</el-radio-button> <el-radio-button label="center">按中心</el-radio-button>
<el-radio-button label="month">按月份</el-radio-button> <el-radio-button label="month">按月份</el-radio-button>
</el-radio-group> </el-radio-group>
</div> </div>
<StateLoading v-if="isDesktop && loading" :rows="4" />
<div v-else-if="isDesktop" class="enrollment-snapshot">
<div class="enrollment-meter">
<div class="meter-head">
<span>达成率</span>
<strong>{{ enrollmentCompletionLabel }}</strong>
</div>
<div class="meter-track">
<span class="meter-fill" :style="{ width: enrollmentCompletionWidth }"></span>
</div>
<div class="meter-foot">
<span>已入组 {{ overview?.summary.total_actual || 0 }}</span>
<span>目标 {{ overview?.summary.total_target || 0 }}</span>
</div>
</div>
<div class="enrollment-center-list">
<div v-for="row in enrollmentCenterRows" :key="row.key" class="enrollment-center-row">
<div class="enrollment-center-meta">
<span>{{ row.label }}</span>
<strong>{{ row.actual }} / {{ row.target }}</strong>
</div>
<div class="mini-progress-track">
<span class="mini-progress-fill" :style="{ width: row.percentWidth }"></span>
</div>
</div>
<div v-if="enrollmentCenterRows.length === 0" class="desktop-empty-note">
暂无中心入组数据
</div>
</div>
</div>
<EnrollmentBarChart <EnrollmentBarChart
v-else
:mode="chartMode" :mode="chartMode"
:items="chartItems" :items="chartItems"
:loading="loading" :loading="loading"
@@ -69,6 +140,7 @@
</section> </section>
</div> </div>
</div> </div>
</div>
<StateEmpty v-else :description="TEXT.common.empty.selectProject" /> <StateEmpty v-else :description="TEXT.common.empty.selectProject" />
</div> </div>
</template> </template>
@@ -80,18 +152,127 @@ import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { fetchProjectOverview } from "../../api/overview"; import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import { isTauriRuntime } from "../../runtime";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue"; import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue"; import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter"; import { STAGE_ORDER, adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel, type StageStatus } from "./project-overview/overview.adapter";
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false); const loading = ref(false);
const overview = ref<ProjectOverviewViewModel | null>(null); const overview = ref<ProjectOverviewViewModel | null>(null);
const chartMode = ref<"center" | "month">("center"); const chartMode = ref<"center" | "month">("center");
const centers = computed(() => overview.value?.centers || []); const centers = computed(() => overview.value?.centers || []);
const enrollmentCompletionRate = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return 0;
return Math.min(100, Math.round((summary.total_actual / summary.total_target) * 100));
});
const enrollmentCompletionLabel = computed(() => {
const summary = overview.value?.summary;
if (!summary?.total_target) return "未设目标";
return `${enrollmentCompletionRate.value}%`;
});
const enrollmentCompletionWidth = computed(() => `${enrollmentCompletionRate.value}%`);
const overviewUpdatedAtLabel = computed(() => {
if (!overview.value?.updated_at) return "-";
const date = new Date(overview.value.updated_at);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
});
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
};
const statusDotClassMap: Record<StageStatus, string> = {
COMPLETED: "completed",
IN_PROGRESS: "active",
NOT_STARTED: "pending",
BLOCKED: "blocked",
};
const stageStatusCounts = computed<Record<StageStatus, number>>(() => {
const counts: Record<StageStatus, number> = {
COMPLETED: 0,
IN_PROGRESS: 0,
NOT_STARTED: 0,
BLOCKED: 0,
};
centers.value.forEach((center) => {
STAGE_ORDER.forEach((stage) => {
counts[center[stage.key] || "NOT_STARTED"] += 1;
});
});
return counts;
});
const stageStatusSummary = computed(() =>
(["COMPLETED", "IN_PROGRESS", "NOT_STARTED", "BLOCKED"] as StageStatus[]).map((status) => ({
key: status,
label: statusLabelMap[status],
count: stageStatusCounts.value[status],
dotClass: statusDotClassMap[status],
}))
);
const activeStageItems = computed(() =>
centers.value
.flatMap((center, centerIndex) =>
STAGE_ORDER
.filter((stage) => center[stage.key] === "IN_PROGRESS")
.map((stage) => ({
key: `${center.center_id || centerIndex}-${stage.key}`,
centerName: center.center_name || TEXT.common.fallback,
stageLabel: stage.label,
}))
)
.slice(0, 4)
);
const attentionItems = computed(() => {
const items: string[] = [];
const targetlessCenters = centers.value.filter((center) => !center.enrollment_target).length;
const inactiveCenters = centers.value.filter((center) => center.is_active === false).length;
if (stageStatusCounts.value.BLOCKED > 0) {
items.push(`${stageStatusCounts.value.BLOCKED} 个阶段阻塞/延期`);
}
if (targetlessCenters > 0) {
items.push(`${targetlessCenters} 个中心未设置入组目标`);
}
if (overview.value && overview.value.months.length === 0) {
items.push("暂无月度入组趋势数据");
}
if (inactiveCenters > 0) {
items.push(`${inactiveCenters} 个中心已停用`);
}
if (items.length === 0) {
items.push("暂无需要额外关注的总览风险");
}
return items.slice(0, 4);
});
const enrollmentCenterRows = computed(() =>
centers.value.slice(0, 4).map((center, index) => {
const target = center.enrollment_target || 0;
const actual = center.enrollment_actual || 0;
const percent = target > 0 ? Math.min(100, Math.round((actual / target) * 100)) : 0;
return {
key: center.center_id || center.center_name || `center-${index}`,
label: center.center_name || TEXT.common.fallback,
actual,
target,
percentWidth: `${percent}%`,
};
})
);
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] => const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
siteList.map((site: any) => ({ siteList.map((site: any) => ({
@@ -218,6 +399,12 @@ watch(
gap: 10px; gap: 10px;
} }
.overview-workbench {
display: flex;
flex-direction: column;
gap: 10px;
}
.card-header-right { .card-header-right {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -230,6 +417,13 @@ watch(
border-radius: 8px; border-radius: 8px;
} }
.overview-updated-at {
color: var(--ctms-text-secondary);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.overview-card { .overview-card {
background: var(--ctms-bg-card); background: var(--ctms-bg-card);
border: 1px solid var(--ctms-border-color); border: 1px solid var(--ctms-border-color);
@@ -389,6 +583,385 @@ watch(
border-radius: 8px; border-radius: 8px;
} }
.project-overview--desktop {
min-height: 0;
}
.project-overview--desktop .page-body {
min-height: 0;
gap: 8px;
}
.project-overview--desktop .overview-container {
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
gap: 10px;
}
.project-overview--desktop .overview-workbench {
display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);
align-items: stretch;
overflow: hidden;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.project-overview--desktop .overview-card {
min-width: 0;
border: 0;
border-radius: 0;
padding: 12px 14px;
background: transparent;
box-shadow: none;
}
.project-overview--desktop .overview-card:hover {
box-shadow: none;
}
.project-overview--desktop .overview-card--enrollment {
border-left: 1px solid #d9e2ec;
}
.project-overview--desktop .card-header {
margin-bottom: 8px;
}
.project-overview--desktop .card-header-left {
gap: 8px;
}
.project-overview--desktop .card-icon {
width: 24px;
height: 24px;
border-radius: 5px;
}
.project-overview--desktop .card-title {
font-size: 13px;
}
.project-overview--desktop .card-subtitle,
.project-overview--desktop .progress-legend {
font-size: 11px;
}
.project-overview--desktop .progress-legend {
gap: 8px;
}
.project-overview--desktop .legend-dot {
width: 8px;
height: 8px;
}
.enrollment-snapshot {
display: flex;
flex-direction: column;
gap: 14px;
}
.enrollment-meter {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
border-radius: 6px;
background: #f8fafc;
}
.meter-head,
.meter-foot,
.enrollment-center-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.meter-head span,
.meter-foot,
.enrollment-center-meta span {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.meter-head strong {
color: #15344f;
font-size: 18px;
line-height: 1;
}
.meter-track,
.mini-progress-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: #e8eef5;
}
.meter-fill,
.mini-progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: #3f5d75;
}
.enrollment-center-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.enrollment-center-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.enrollment-center-meta strong {
color: #1f2f45;
font-size: 12px;
}
.project-overview--desktop .progress-list {
max-height: min(360px, calc(100vh - 300px));
overflow: auto;
padding-right: 2px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.project-overview--desktop .progress-list::-webkit-scrollbar {
width: 8px;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-track {
background: transparent;
}
.project-overview--desktop .progress-list::-webkit-scrollbar-thumb {
border: 2px solid #ffffff;
border-radius: 999px;
background: #c8d4e1;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: minmax(126px, 156px) minmax(0, 1fr);
gap: 8px;
padding: 6px;
border-radius: 5px;
}
.project-overview--desktop :deep(.center-name) {
font-size: 13px;
}
.project-overview--desktop :deep(.center-enrollment) {
font-size: 11px;
}
.project-overview--desktop :deep(.center-timeline) {
padding: 4px 0 0;
}
.project-overview--desktop :deep(.timeline-segment) {
gap: 6px;
}
.project-overview--desktop :deep(.stage-node) {
min-width: 52px;
gap: 4px;
}
.project-overview--desktop :deep(.stage-dot) {
width: 13px;
height: 13px;
}
.project-overview--desktop :deep(.stage-in_progress .stage-dot) {
width: 16px;
height: 16px;
}
.project-overview--desktop :deep(.stage-label) {
max-width: 62px;
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
.project-overview--desktop :deep(.stage-in_progress .stage-label) {
padding: 1px 6px;
}
.project-overview--desktop :deep(.stage-connector) {
min-width: 30px;
}
.project-overview--desktop :deep(.chart-scroll) {
overflow-x: auto;
overflow-y: hidden;
}
.project-overview--desktop :deep(.chart-plot) {
border-radius: 6px;
background: #f8fafc;
}
.project-overview--desktop :deep(.chart-empty-shell) {
min-height: 112px;
border-radius: 6px;
padding: 14px;
}
.project-overview--desktop .overview-empty-panel {
min-height: 104px;
padding: 12px;
border-radius: 6px;
}
.desktop-attention-board {
display: grid;
grid-template-columns: minmax(220px, 0.75fr) minmax(260px, 1fr) minmax(260px, 1fr);
gap: 10px;
}
.desktop-attention-section {
display: flex;
flex-direction: column;
}
.attention-card {
min-width: 0;
min-height: 126px;
padding: 12px 14px;
border: 1px solid #d9e2ec;
border-radius: 6px;
background: #ffffff;
}
.attention-title {
margin-bottom: 10px;
color: #0f172a;
font-size: 13px;
font-weight: 800;
}
.stage-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.stage-status-item,
.attention-list-row {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 8px;
border-radius: 6px;
background: #f8fafc;
}
.stage-status-item span:not(.legend-dot),
.attention-list-row span {
min-width: 0;
overflow: hidden;
color: #53677f;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stage-status-item strong {
margin-left: auto;
color: #15253a;
font-size: 13px;
}
.attention-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attention-list-row strong {
min-width: 0;
overflow: hidden;
color: #15253a;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.attention-list-row--plain {
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
}
.attention-list-row--plain span {
white-space: normal;
}
.desktop-empty-note {
min-height: 32px;
display: flex;
align-items: center;
color: #7b8da3;
font-size: 12px;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-workbench),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-card) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .overview-card--enrollment) {
border-left-color: #26364a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-meter),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row) {
background: #111a2a;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-title),
:global([data-ctms-theme="dark"] .project-overview--desktop .stage-status-item strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .attention-list-row strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .enrollment-center-meta strong),
:global([data-ctms-theme="dark"] .project-overview--desktop .meter-head strong) {
color: #e5edf7;
}
:global([data-ctms-theme="dark"] .project-overview--desktop .progress-list::-webkit-scrollbar-thumb) {
border-color: #172033;
background: #3b4b60;
}
@media (max-width: 1240px) {
.project-overview--desktop .overview-workbench,
.desktop-attention-board {
grid-template-columns: 1fr;
}
.project-overview--desktop .overview-card--enrollment {
border-top: 1px solid #d9e2ec;
border-left: 0;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.overview-container { .overview-container {
padding: 8px; padding: 8px;
@@ -406,5 +979,14 @@ watch(
.card-header-right { .card-header-right {
justify-content: flex-start; justify-content: flex-start;
} }
.project-overview--desktop .overview-container {
grid-template-columns: 1fr;
padding-top: 8px;
}
.project-overview--desktop :deep(.center-row) {
grid-template-columns: 1fr;
}
} }
</style> </style>
@@ -466,7 +466,7 @@ import {
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime"; import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { useAuthStore } from "../../store/auth"; import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api"; import type { Site } from "../../types/api";
@@ -978,7 +978,14 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream"; const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx"; const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType }); const blob = new Blob([response.data], { type: contentType });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob }); await saveFileWithFeedback(
{ suggestedName: filename, mimeType: contentType, data: blob },
{
kind: "export",
title: "导出文件",
completedDetail: "导出文件已保存",
},
);
ElMessage.success("导出成功"); ElMessage.success("导出成功");
} catch (e: any) { } catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed); ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -1010,7 +1017,7 @@ const importIssueFile = async (file: File) => {
}; };
const selectImportFile = async () => { const selectImportFile = async () => {
const [file] = await pickFiles({ const [file] = await pickFilesWithFeedback({
multiple: false, multiple: false,
accept: ["xlsx", "csv"], accept: ["xlsx", "csv"],
title: "导入监查访视问题", title: "导入监查访视问题",
@@ -5,15 +5,17 @@ import { resolve } from "node:path";
const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8"); const readSubjectManagementSource = () => readFileSync(resolve(__dirname, "./SubjectManagement.vue"), "utf8");
describe("SubjectManagement project permissions", () => { describe("SubjectManagement project permissions", () => {
it("hides create and delete actions when the current role lacks backend operation permissions", () => { it("hides create, edit, and delete actions when the current role lacks backend operation permissions", () => {
const source = readSubjectManagementSource(); const source = readSubjectManagementSource();
expect(source).toContain("isSystemAdmin"); expect(source).toContain("isSystemAdmin");
expect(source).toContain("isApiPermissionAllowed"); expect(source).toContain("isApiPermissionAllowed");
expect(source).toContain("canUseApiPermission"); expect(source).toContain("canUseApiPermission");
expect(source).toContain('canUseApiPermission("subjects:create")'); expect(source).toContain('canUseApiPermission("subjects:create")');
expect(source).toContain('canUseApiPermission("subjects:update")');
expect(source).toContain('canUseApiPermission("subjects:delete")'); expect(source).toContain('canUseApiPermission("subjects:delete")');
expect(source).toContain('v-if="canCreateSubject"'); expect(source).toContain('v-if="canCreateSubject"');
expect(source).toContain('v-if="canUpdateSubject"');
expect(source).toContain('v-if="canDeleteSubject"'); expect(source).toContain('v-if="canDeleteSubject"');
}); });
}); });
@@ -24,6 +26,8 @@ describe("SubjectManagement drawer editor", () => {
expect(source).toContain("SubjectEditorDrawer"); expect(source).toContain("SubjectEditorDrawer");
expect(source).toContain("subjectDrawerVisible"); expect(source).toContain("subjectDrawerVisible");
expect(source).toContain("editingSubjectId");
expect(source).toContain(':subject-id="editingSubjectId || undefined"');
expect(source).not.toContain('router.push("/subjects/new")'); expect(source).not.toContain('router.push("/subjects/new")');
}); });
}); });
@@ -32,10 +36,49 @@ describe("SubjectManagement desktop list workflow", () => {
it("selects rows for preview and opens details on explicit desktop actions", () => { it("selects rows for preview and opens details on explicit desktop actions", () => {
const source = readSubjectManagementSource(); const source = readSubjectManagementSource();
expect(source).toContain("@row-click=\"selectSubject\""); expect(source).toContain("const isDesktop = isTauriRuntime();");
expect(source).toContain('class="subject-workbench"');
expect(source).toContain(":class=\"{ 'is-desktop': isDesktop }\"");
expect(source).toContain('class="subject-preview-pane"');
expect(source).toContain('ref="subjectTablePaneRef"');
expect(source).toContain("selectedSubject");
expect(source).toContain("@row-click=\"handleSubjectRowClick\"");
expect(source).toContain("@row-dblclick=\"openSubjectDetail\""); expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
expect(source).toContain("@keydown.enter.prevent=\"openSelectedSubject\"");
expect(source).toContain("selectSubject(row);");
expect(source).toContain("subjectTablePaneRef.value?.focus({ preventScroll: true });");
expect(source).toContain("return;");
expect(source).toContain("goDetail(row.id);");
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\""); expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
expect(source).toContain("if (!isDesktop || !row?.id) return;");
expect(source).toContain("subject-preview-pane"); expect(source).toContain("subject-preview-pane");
expect(source).toContain('class="preview-actions"');
expect(source).toContain("@click=\"openSelectedSubjectEditor\"");
expect(source).toContain("@click=\"removeSelectedSubject\"");
expect(source).not.toContain('<el-button size="small" @click="copySelectedSubjectNo">复制编号</el-button>');
expect(source).toContain("subject-context-menu"); expect(source).toContain("subject-context-menu");
expect(source).toContain('v-if="!isDesktop && canDeleteSubject"');
expect(source).toContain('"row-selected"');
});
it("does not render unused table selection controls", () => {
const source = readSubjectManagementSource();
expect(source).not.toContain('type="selection"');
expect(source).not.toContain("@selection-change");
expect(source).not.toContain("selectedRows");
expect(source).not.toContain("selection-count");
});
it("keeps desktop footer controls at the bottom of the workbench", () => {
const source = readSubjectManagementSource();
expect(source).toContain("subject-page--desktop");
expect(source).toContain(".subject-page--desktop .table-card");
expect(source).toContain(".subject-workbench.is-desktop");
expect(source).toContain(".subject-table-pane");
expect(source).toContain(".pagination-wrap");
expect(source).toContain(".preview-actions");
expect(source.match(/margin-top: auto;/g)?.length).toBeGreaterThanOrEqual(2);
}); });
}); });
+132 -38
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page" :class="{ 'subject-page--desktop': isDesktop }">
<div class="table-card"> <div class="table-card">
<div class="table-card-toolbar"> <div class="table-card-toolbar">
<div class="toolbar-filters"> <div class="toolbar-filters">
@@ -24,7 +24,6 @@
</div> </div>
</div> </div>
<div class="toolbar-right"> <div class="toolbar-right">
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} </span>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn"> <el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon> <el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }} {{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -32,8 +31,13 @@
</div> </div>
</div> </div>
<div class="subject-workbench"> <div class="subject-workbench" :class="{ 'is-desktop': isDesktop }">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject"> <div
class="subject-table-pane"
ref="subjectTablePaneRef"
:tabindex="isDesktop ? 0 : undefined"
@keydown.enter.prevent="openSelectedSubject"
>
<el-table <el-table
:data="pagedItems" :data="pagedItems"
v-loading="loading" v-loading="loading"
@@ -41,13 +45,11 @@
class="subject-table" class="subject-table"
:row-class-name="subjectRowClass" :row-class-name="subjectRowClass"
highlight-current-row highlight-current-row
@selection-change="onSelectionChange" @row-click="handleSubjectRowClick"
@row-click="selectSubject"
@row-dblclick="openSubjectDetail" @row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu" @row-contextmenu="openSubjectContextMenu"
table-layout="fixed" table-layout="fixed"
> >
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip> <el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope"> <template #default="scope">
<div class="subject-info-cell"> <div class="subject-info-cell">
@@ -73,7 +75,7 @@
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate"> <el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template> <template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column> </el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right"> <el-table-column v-if="!isDesktop && canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope"> <template #default="scope">
<div class="cell-actions"> <div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)"> <el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
@@ -104,7 +106,7 @@
</div> </div>
</div> </div>
<aside class="subject-preview-pane"> <aside v-if="isDesktop" class="subject-preview-pane">
<template v-if="selectedSubject"> <template v-if="selectedSubject">
<div class="preview-head"> <div class="preview-head">
<div> <div>
@@ -130,6 +132,26 @@
{{ badge.label }} {{ badge.label }}
</span> </span>
</div> </div>
<div class="preview-actions">
<el-button
v-if="canUpdateSubject"
size="small"
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="openSelectedSubjectEditor"
>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button
v-if="canDeleteSubject"
size="small"
type="danger"
plain
:disabled="isInactiveSite(selectedSubject.site_id)"
@click="removeSelectedSubject"
>
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template> </template>
<div v-else class="preview-empty"> <div v-else class="preview-empty">
<div class="empty-icon"> <div class="empty-icon">
@@ -142,7 +164,7 @@
</div> </div>
<div <div
v-if="contextMenu.visible" v-if="isDesktop && contextMenu.visible && selectedSubject"
class="subject-context-menu" class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }" :style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop @click.stop
@@ -154,13 +176,17 @@
type="button" type="button"
class="danger" class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)" :disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)" @click="removeSelectedSubject"
> >
删除 删除
</button> </button>
</div> </div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" /> <SubjectEditorDrawer
v-model="subjectDrawerVisible"
:subject-id="editingSubjectId || undefined"
@success="handleSubjectEditorSuccess"
/>
</div> </div>
</template> </template>
@@ -179,15 +205,18 @@ import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue"; import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh"; import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { isTauriRuntime } from "../../runtime";
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const isDesktop = isTauriRuntime();
const loading = ref(false); const loading = ref(false);
const subjectDrawerVisible = ref(false); const subjectDrawerVisible = ref(false);
const editingSubjectId = ref("");
const subjectTablePaneRef = ref<HTMLElement | null>(null);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const selectedSubjectId = ref(""); const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 }); const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]); const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({}); const siteMap = ref<Record<string, string>>({});
@@ -215,6 +244,7 @@ const canUseApiPermission = (operationKey: string) => {
return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]); return isApiPermissionAllowed(study.currentPermissions?.[projectRole.value]?.[operationKey]);
}; };
const canCreateSubject = computed(() => canUseApiPermission("subjects:create")); const canCreateSubject = computed(() => canUseApiPermission("subjects:create"));
const canUpdateSubject = computed(() => canUseApiPermission("subjects:update"));
const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete")); const canDeleteSubject = computed(() => canUseApiPermission("subjects:delete"));
const loadSites = async () => { const loadSites = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
@@ -251,14 +281,26 @@ const load = async () => {
} }
}; };
const goNew = () => { subjectDrawerVisible.value = true; }; const goNew = () => {
editingSubjectId.value = "";
subjectDrawerVisible.value = true;
};
const goDetail = (id: string) => router.push(`/subjects/${id}`); const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { load(); }; const handleSubjectEditorSuccess = () => { load(); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null); const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => { const selectSubject = (row: any) => {
contextMenu.value.visible = false; closeSubjectContextMenu();
if (!row?.id) return; if (!row?.id) return;
selectedSubjectId.value = row.id; selectedSubjectId.value = row.id;
subjectTablePaneRef.value?.focus({ preventScroll: true });
};
const handleSubjectRowClick = (row: any) => {
if (!row?.id) return;
if (isDesktop) {
selectSubject(row);
return;
}
goDetail(row.id);
}; };
const openSubjectDetail = (row: any) => { const openSubjectDetail = (row: any) => {
if (!row?.id) return; if (!row?.id) return;
@@ -268,21 +310,19 @@ const openSelectedSubject = () => {
contextMenu.value.visible = false; contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id); if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
}; };
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => { const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
if (!isDesktop || !row?.id) return;
event.preventDefault(); event.preventDefault();
if (!row?.id) return; selectSubject(row);
selectedSubjectId.value = row.id;
contextMenu.value = { contextMenu.value = {
visible: true, visible: true,
x: event.clientX, x: Math.max(8, Math.min(event.clientX, window.innerWidth - 152)),
y: event.clientY, y: Math.max(8, Math.min(event.clientY, window.innerHeight - 122)),
}; };
}; };
const closeSubjectContextMenu = () => { const closeSubjectContextMenu = () => {
contextMenu.value.visible = false; if (!contextMenu.value.visible) return;
contextMenu.value = { visible: false, x: 0, y: 0 };
}; };
const copySelectedSubjectNo = async () => { const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false; contextMenu.value.visible = false;
@@ -291,6 +331,28 @@ const copySelectedSubjectNo = async () => {
await navigator.clipboard?.writeText(subjectNo); await navigator.clipboard?.writeText(subjectNo);
ElMessage.success("编号已复制"); ElMessage.success("编号已复制");
}; };
const openSelectedSubjectEditor = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (!target) return;
if (!canUpdateSubject.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(target.site_id)) {
ElMessage.warning("中心已停用");
return;
}
editingSubjectId.value = target.id;
subjectDrawerVisible.value = true;
};
const removeSelectedSubject = () => {
const target = selectedSubject.value;
closeSubjectContextMenu();
if (target) {
void remove(target);
}
};
const remove = async (row: any) => { const remove = async (row: any) => {
contextMenu.value.visible = false; contextMenu.value.visible = false;
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
@@ -349,6 +411,7 @@ watch(() => filteredItems.value.length, (total) => {
}); });
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; }); watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => { watch(pagedItems, (rows) => {
if (!isDesktop) return;
if (!rows.length) { if (!rows.length) {
selectedSubjectId.value = ""; selectedSubjectId.value = "";
return; return;
@@ -359,7 +422,7 @@ watch(pagedItems, (rows) => {
}); });
onMounted(async () => { onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu); if (isDesktop) document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => { desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites(); loadSites();
load(); load();
@@ -369,7 +432,7 @@ onMounted(async () => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu); if (isDesktop) document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.(); desktopRefreshCleanup?.();
}); });
</script> </script>
@@ -383,25 +446,53 @@ onBeforeUnmount(() => {
background: #ffffff; background: #ffffff;
} }
.subject-page--desktop {
height: 100%;
min-height: 0;
}
.table-card { .table-card {
background: #ffffff; background: #ffffff;
overflow: hidden; overflow: hidden;
} }
.subject-page--desktop .table-card {
flex: 1;
display: flex;
min-height: 0;
flex-direction: column;
}
.subject-workbench { .subject-workbench {
min-width: 0;
min-height: 0;
}
.subject-workbench.is-desktop {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 300px; grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px; flex: 1;
min-height: 0;
} }
.subject-table-pane { .subject-table-pane {
display: flex;
flex-direction: column;
min-width: 0; min-width: 0;
border-right: 1px solid #edf1f7; min-height: 0;
outline: none; outline: none;
} }
.subject-workbench.is-desktop .subject-table-pane {
border-right: 1px solid #edf1f7;
}
.subject-preview-pane { .subject-preview-pane {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0; min-width: 0;
min-height: 0;
padding: 16px; padding: 16px;
background: #fbfcff; background: #fbfcff;
} }
@@ -431,13 +522,6 @@ onBeforeUnmount(() => {
flex-shrink: 0; flex-shrink: 0;
} }
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item { .filter-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -473,6 +557,8 @@ onBeforeUnmount(() => {
/* ==================== Table ==================== */ /* ==================== Table ==================== */
.subject-table { .subject-table {
flex: 1;
min-height: 0;
--el-table-border-color: transparent; --el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb; --el-table-row-hover-bg-color: #f8f9fb;
} }
@@ -546,7 +632,7 @@ onBeforeUnmount(() => {
display: grid; display: grid;
grid-template-columns: 72px minmax(0, 1fr); grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px; gap: 10px 12px;
margin: 16px 0 0; margin: 0;
} }
.preview-list dt { .preview-list dt {
@@ -566,10 +652,17 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 16px; }
.preview-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
} }
.preview-empty { .preview-empty {
flex: 1;
min-height: 360px; min-height: 360px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -651,6 +744,7 @@ onBeforeUnmount(() => {
.pagination-wrap { .pagination-wrap {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
margin-top: auto;
padding: 12px 20px; padding: 12px 20px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
@@ -680,8 +774,8 @@ onBeforeUnmount(() => {
/* ==================== Responsive ==================== */ /* ==================== Responsive ==================== */
@media (max-width: 960px) { @media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; } .subject-workbench.is-desktop { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; } .subject-workbench.is-desktop .subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; } .subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; } .toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; } .filter-input, .filter-select { width: 100%; }
@@ -1,5 +1,5 @@
<template> <template>
<div class="enrollment-chart"> <div class="enrollment-chart" :class="{ 'enrollment-chart--compact': compact }">
<StateLoading v-if="loading" :rows="5" /> <StateLoading v-if="loading" :rows="5" />
<div v-else-if="items.length === 0" class="chart-empty-shell"> <div v-else-if="items.length === 0" class="chart-empty-shell">
<div class="chart-empty-body"> <div class="chart-empty-body">
@@ -9,7 +9,7 @@
</div> </div>
<div v-else class="chart-body"> <div v-else class="chart-body">
<div class="chart-scroll"> <div class="chart-scroll">
<div class="chart-plot"> <div class="chart-plot" :style="chartPlotStyle">
<svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet"> <svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
<defs> <defs>
<linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1"> <linearGradient :id="gradientTargetId" x1="0" y1="0" x2="0" y2="1">
@@ -105,23 +105,40 @@ const props = withDefaults(
items: EnrollmentBarItem[]; items: EnrollmentBarItem[];
loading?: boolean; loading?: boolean;
emptyText?: string; emptyText?: string;
compact?: boolean;
}>(), }>(),
{ {
loading: false, loading: false,
emptyText: "暂无入组数据", emptyText: "暂无入组数据",
compact: false,
} }
); );
const compact = computed(() => props.compact);
const showTarget = computed(() => props.mode === "center"); const showTarget = computed(() => props.mode === "center");
const chartWidth = 960; const baseChartWidth = computed(() => (compact.value ? 560 : 960));
const chartHeight = 260; const chartHeight = computed(() => (compact.value ? 240 : 260));
const axisPadding = { const axisPadding = computed(() => ({
top: 28, top: compact.value ? 24 : 28,
right: 32, right: compact.value ? 24 : 32,
bottom: 48, bottom: compact.value ? 42 : 48,
left: 56, left: compact.value ? 46 : 56,
}; }));
const chartWidth = computed(() => {
if (!compact.value) return baseChartWidth.value;
const itemWidth = showTarget.value ? 72 : 64;
return Math.max(
baseChartWidth.value,
props.items.length * itemWidth + axisPadding.value.left + axisPadding.value.right,
);
});
const chartPlotStyle = computed(() => ({
"--chart-min-width": `${chartWidth.value}px`,
"--chart-aspect": `${chartWidth.value} / ${chartHeight.value}`,
}));
const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`; const gradientTargetId = `enroll-target-${Math.random().toString(36).slice(2, 8)}`;
const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`; const gradientActualId = `enroll-actual-${Math.random().toString(36).slice(2, 8)}`;
@@ -146,36 +163,37 @@ const yTicks = computed(() => {
}); });
const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1)); const axisMax = computed(() => Math.max(1, yTicks.value[0]?.value ?? 1));
const plotWidth = computed(() => chartWidth - axisPadding.left - axisPadding.right); const plotWidth = computed(() => chartWidth.value - axisPadding.value.left - axisPadding.value.right);
const plotHeight = computed(() => chartHeight - axisPadding.top - axisPadding.bottom); const plotHeight = computed(() => chartHeight.value - axisPadding.value.top - axisPadding.value.bottom);
const axisLeft = axisPadding.left; const axisLeft = computed(() => axisPadding.value.left);
const axisRight = chartWidth - axisPadding.right; const axisRight = computed(() => chartWidth.value - axisPadding.value.right);
const axisTop = axisPadding.top; const axisTop = computed(() => axisPadding.value.top);
const axisBottom = chartHeight - axisPadding.bottom; const axisBottom = computed(() => chartHeight.value - axisPadding.value.bottom);
const labelY = axisBottom + 20; const labelY = computed(() => axisBottom.value + (compact.value ? 18 : 20));
const valueGap = 10; const valueGap = computed(() => (compact.value ? 8 : 10));
const barRadius = 6; const barRadius = computed(() => (compact.value ? 5 : 6));
const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value)); const bandWidth = computed(() => (props.items.length ? plotWidth.value / props.items.length : plotWidth.value));
const barWidth = computed(() => Math.min(52, bandWidth.value * 0.5)); const barWidth = computed(() => Math.min(compact.value ? 44 : 52, bandWidth.value * 0.5));
const barCenter = (index: number) => axisLeft + bandWidth.value * index + bandWidth.value / 2; const barCenter = (index: number) => axisLeft.value + bandWidth.value * index + bandWidth.value / 2;
const barLeft = (index: number) => barCenter(index) - barWidth.value / 2; const barLeft = (index: number) => barCenter(index) - barWidth.value / 2;
const barHeight = (value: number) => { const barHeight = (value: number) => {
if (value <= 0) return 0; if (value <= 0) return 0;
return (value / axisMax.value) * plotHeight.value; return (value / axisMax.value) * plotHeight.value;
}; };
const barTop = (value: number) => axisTop + plotHeight.value - barHeight(value); const barTop = (value: number) => axisTop.value + plotHeight.value - barHeight(value);
const tickY = (value: number) => axisTop + ((axisMax.value - value) / axisMax.value) * plotHeight.value; const tickY = (value: number) => axisTop.value + ((axisMax.value - value) / axisMax.value) * plotHeight.value;
const valueY = (item: EnrollmentBarItem) => { const valueY = (item: EnrollmentBarItem) => {
const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual; const anchor = showTarget.value ? Math.max(item.actual, item.target || 0) : item.actual;
return barTop(anchor) - valueGap; return barTop(anchor) - valueGap.value;
}; };
const truncateLabel = (label: string) => { const truncateLabel = (label: string) => {
if (label.length <= 8) return label; const limit = compact.value ? 7 : 8;
return `${label.slice(0, 7)}...`; if (label.length <= limit) return label;
return `${label.slice(0, limit - 1)}...`;
}; };
const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0); const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(value || 0);
@@ -228,7 +246,8 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
border-radius: 10px; border-radius: 10px;
background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%); background: linear-gradient(180deg, #fafbfd 0%, #f6f8fb 100%);
position: relative; position: relative;
aspect-ratio: 960 / 260; width: 100%;
aspect-ratio: var(--chart-aspect);
min-height: 200px; min-height: 200px;
} }
@@ -297,6 +316,33 @@ const formatNumber = (value: number) => new Intl.NumberFormat("zh-CN").format(va
font-weight: 500; font-weight: 500;
} }
.enrollment-chart--compact .chart-plot {
width: max(100%, var(--chart-min-width));
min-height: 0;
border-radius: 6px;
}
.enrollment-chart--compact .chart-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.enrollment-chart--compact .tick-label {
font-size: 10px;
}
.enrollment-chart--compact .bar-value {
font-size: 11px;
}
.enrollment-chart--compact .bar-value-actual {
font-size: 12px;
}
.enrollment-chart--compact .bar-label {
font-size: 10px;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.bar-label { .bar-label {
font-size: 11px; font-size: 11px;