Compare commits
2 Commits
628ff8828b
...
9cac75e85c
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cac75e85c | |||
| c923f887a0 |
@@ -3,18 +3,34 @@ name: Client Quality Gates
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
|
- "AGENTS.md"
|
||||||
- "frontend/**"
|
- "frontend/**"
|
||||||
- ".github/workflows/client-quality-gates.yml"
|
- ".github/workflows/client-quality-gates.yml"
|
||||||
- "docs/branch-governance.md"
|
- "docs/branch-governance.md"
|
||||||
|
- "docs/desktop-project-plan.md"
|
||||||
|
- "docs/desktop-phase-1-design.md"
|
||||||
|
- "docs/desktop-phase-2-design.md"
|
||||||
|
- "docs/audits/desktop-release-stabilization-checklist.md"
|
||||||
- "docs/guides/client-release.md"
|
- "docs/guides/client-release.md"
|
||||||
|
- "docs/guides/branch-maintenance-sop-zh.md"
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
- main
|
- main
|
||||||
- release
|
- release
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
paths:
|
paths:
|
||||||
|
- "AGENTS.md"
|
||||||
- "frontend/**"
|
- "frontend/**"
|
||||||
- ".github/workflows/client-quality-gates.yml"
|
- ".github/workflows/client-quality-gates.yml"
|
||||||
|
- "docs/branch-governance.md"
|
||||||
|
- "docs/desktop-project-plan.md"
|
||||||
|
- "docs/desktop-phase-1-design.md"
|
||||||
|
- "docs/desktop-phase-2-design.md"
|
||||||
|
- "docs/audits/desktop-release-stabilization-checklist.md"
|
||||||
|
- "docs/guides/client-release.md"
|
||||||
|
- "docs/guides/branch-maintenance-sop-zh.md"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
web:
|
web:
|
||||||
@@ -37,12 +53,38 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Resolve build metadata
|
||||||
|
id: build-metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||||
|
channel="release"
|
||||||
|
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
|
||||||
|
channel="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
channel="dev"
|
||||||
|
fi
|
||||||
|
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Check release build metadata
|
||||||
|
run: npm run release:env:check
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
- name: Check synchronized client version
|
- name: Check synchronized client version
|
||||||
run: npm run version:check
|
run: npm run version:check
|
||||||
|
|
||||||
- name: Check runtime boundary
|
- name: Check runtime boundary
|
||||||
run: npm run runtime:check
|
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
|
- name: Type check
|
||||||
run: npm run type-check
|
run: npm run type-check
|
||||||
|
|
||||||
@@ -52,8 +94,8 @@ jobs:
|
|||||||
- name: Build Web artifact
|
- name: Build Web artifact
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
VITE_BUILD_CHANNEL: ${{ github.ref_name }}
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
desktop:
|
desktop:
|
||||||
name: macOS Desktop
|
name: macOS Desktop
|
||||||
@@ -78,8 +120,40 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build macOS application
|
- name: Resolve build metadata
|
||||||
run: npm run desktop:build -- --bundles app
|
id: build-metadata
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||||
|
channel="release"
|
||||||
|
elif [[ "${GITHUB_REF_NAME}" == "dev" || "${GITHUB_REF_NAME}" == "main" || "${GITHUB_REF_NAME}" == "release" ]]; then
|
||||||
|
channel="${GITHUB_REF_NAME}"
|
||||||
|
else
|
||||||
|
channel="dev"
|
||||||
|
fi
|
||||||
|
echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "commit=${GITHUB_SHA}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Check release build metadata
|
||||||
|
run: npm run release:env:check
|
||||||
env:
|
env:
|
||||||
VITE_BUILD_CHANNEL: ${{ github.ref_name }}
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
VITE_BUILD_COMMIT: ${{ github.sha }}
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|
||||||
|
- 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: Build unsigned macOS application smoke artifact
|
||||||
|
run: npm run desktop:build:app
|
||||||
|
env:
|
||||||
|
VITE_BUILD_CHANNEL: ${{ steps.build-metadata.outputs.channel }}
|
||||||
|
VITE_BUILD_COMMIT: ${{ steps.build-metadata.outputs.commit }}
|
||||||
|
|||||||
@@ -2,7 +2,17 @@
|
|||||||
|
|
||||||
处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知和桌面端安全边界。
|
处理 CTMS 桌面端任务前,必须先阅读 `docs/desktop-project-plan.md`。桌面端任务包括但不限于 Tauri、macOS、Windows、桌面打包、桌面存储、文件集成、系统通知和桌面端安全边界。
|
||||||
|
|
||||||
桌面端仅限该计划书中的第一阶段和第二阶段。除非先明确修改计划书,否则不要实现离线功能、本地业务数据存储、内嵌后端服务或离线同步。
|
桌面端当前阶段边界以 `docs/desktop-project-plan.md` 为准。除非先明确修改计划书,否则不要实现离线功能、本地业务数据存储、内嵌后端服务、离线同步或新的阶段性桌面产品线。
|
||||||
|
|
||||||
|
`docs/desktop-project-plan.md` 是当前桌面端进展和工作边界的事实来源。本文件只保留执行约束,不重复维护审查结论或优化方向;不得重新按第一阶段空白项目初始化 Tauri,也不得绕过现有 `frontend/src/runtime/` 适配层直接在业务模块中使用 Tauri API。如实际代码状态与计划书不一致,先更新计划书再实现。
|
||||||
|
|
||||||
|
处理前端或桌面端实现时,优先保持以下边界:
|
||||||
|
|
||||||
|
- 共享业务代码通过 `frontend/src/runtime/index.ts` 获取平台能力。
|
||||||
|
- Tauri API 仅允许出现在 `frontend/src/runtime/`、`frontend/src-tauri/` 或有明确记录的窄入口中。
|
||||||
|
- 新增或调整 Tauri command、capability、CSP、updater、凭据、文件、通知能力时,必须同步评估 `frontend/scripts/verify-desktop-release.mjs`、`npm run runtime:check` 和桌面发布检查清单是否需要更新。
|
||||||
|
- token、附件下载凭据和敏感业务信息不得写入 URL、日志、系统通知正文或明文浏览器存储。
|
||||||
|
- Windows 仍只作为第二阶段兼容性验证目标;未获明确批准前不发布正式 Windows 安装包。
|
||||||
|
|
||||||
## 分支与发布治理
|
## 分支与发布治理
|
||||||
|
|
||||||
@@ -17,10 +27,30 @@
|
|||||||
- CTMS 网页端和桌面端属于同一个产品,共用 `dev`、`main`、`release` 分支,不创建 `web-dev`、`desktop-dev`、`web-release`、`desktop-release` 等长期平行分支。
|
- CTMS 网页端和桌面端属于同一个产品,共用 `dev`、`main`、`release` 分支,不创建 `web-dev`、`desktop-dev`、`web-release`、`desktop-release` 等长期平行分支。
|
||||||
- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`。
|
- 默认晋级路径为短期任务分支进入 `dev`,再由 `dev` 晋级到 `main`,最后由 `main` 发布到 `release`。
|
||||||
- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。
|
- Agent 创建分支时默认使用 `codex/<任务名称>`;分支必须从最新 `dev` 创建,并在合并到 `dev` 后删除。
|
||||||
- `codex/ctms-desktop` 仅是当前桌面端临时集成分支。Tauri 基线合并到 `dev` 后,不得继续将其作为长期桌面主线。
|
- `codex/ctms-desktop` 是历史桌面端临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
|
||||||
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main` 和 `dev`。
|
- 生产热修复从 `release` 创建,合并到 `release` 后必须依次回合并到 `main` 和 `dev`。
|
||||||
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set` 和 `version:check` 命令。
|
- 网页端和桌面端必须使用同一个语义化版本号、正式标签和源代码提交。修改客户端版本时使用 `frontend/package.json` 中的 `version:set` 和 `version:check` 命令。
|
||||||
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
|
- 平台差异必须收敛在 `frontend/src/runtime/` 之后,不能通过长期分支或复制业务代码维护桌面差异。
|
||||||
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
|
- 未经用户明确要求,不执行提交、推送、合并、变基、打标签、删除分支或强制更新远程分支。
|
||||||
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
|
- 执行用户明确要求的 Git 操作前,先检查工作区和目标分支,只暂存本次任务相关文件,不覆盖或撤销用户已有改动。
|
||||||
|
- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行任何分支切换、提交、推送或变基前必须先说明目标基线,并等待用户明确指令。
|
||||||
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`。
|
- 分支治理规则发生变化时,必须同步更新上述治理文档,不能只修改 `AGENTS.md`。
|
||||||
|
|
||||||
|
## 常用质量门禁
|
||||||
|
|
||||||
|
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run version:check
|
||||||
|
npm run release:env:check
|
||||||
|
npm run runtime:check
|
||||||
|
npm run desktop:release:check
|
||||||
|
npm run ui:contract
|
||||||
|
npm run type-check
|
||||||
|
npm run test:unit
|
||||||
|
npm run build
|
||||||
|
npm run desktop:build:app
|
||||||
|
```
|
||||||
|
|
||||||
|
正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, 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
|
||||||
@@ -130,8 +130,10 @@ def ensure_user_active(db_user) -> None:
|
|||||||
|
|
||||||
@router.get("/email-domains", response_model=EmailDomainsResponse)
|
@router.get("/email-domains", response_model=EmailDomainsResponse)
|
||||||
async def read_email_domains(
|
async def read_email_domains(
|
||||||
|
response: Response,
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> EmailDomainsResponse:
|
) -> EmailDomainsResponse:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
rows = await email_service.list_email_settings(db)
|
rows = await email_service.list_email_settings(db)
|
||||||
return EmailDomainsResponse(items=[row.register_domain for row in rows])
|
return EmailDomainsResponse(items=[row.register_domain for row in rows])
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,22 @@ async def add_email_settings(SessionLocal, domain: str) -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_email_domains_come_only_from_email_service_settings(client_and_db):
|
||||||
|
client, SessionLocal = client_and_db
|
||||||
|
|
||||||
|
empty_response = await client.get("/api/v1/auth/email-domains")
|
||||||
|
assert empty_response.status_code == 200
|
||||||
|
assert empty_response.json() == {"items": []}
|
||||||
|
assert empty_response.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
await add_email_settings(SessionLocal, "example.com")
|
||||||
|
configured_response = await client.get("/api/v1/auth/email-domains")
|
||||||
|
assert configured_response.status_code == 200
|
||||||
|
assert configured_response.json() == {"items": ["example.com"]}
|
||||||
|
assert configured_response.headers["cache-control"] == "no-store"
|
||||||
|
|
||||||
|
|
||||||
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
|
async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict:
|
||||||
key_resp = await client.get("/api/v1/auth/login-key")
|
key_resp = await client.get("/api/v1/auth/login-key")
|
||||||
assert key_resp.status_code == 200
|
assert key_resp.status_code == 200
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ services:
|
|||||||
set -e
|
set -e
|
||||||
lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')"
|
lock_hash="$$(sha256sum package-lock.json | awk '{print $$1}')"
|
||||||
marker="node_modules/.ctms-package-lock.sha256"
|
marker="node_modules/.ctms-package-lock.sha256"
|
||||||
if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ]; then
|
missing_runtime_deps=0
|
||||||
|
for dep in @tauri-apps/api @tauri-apps/plugin-dialog @tauri-apps/plugin-fs @tauri-apps/plugin-opener; do
|
||||||
|
if [ ! -d "node_modules/$$dep" ]; then
|
||||||
|
missing_runtime_deps=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ ! -f "$$marker" ] || [ "$$(cat "$$marker")" != "$$lock_hash" ] || [ "$$missing_runtime_deps" = "1" ]; then
|
||||||
npm ci
|
npm ci
|
||||||
printf '%s' "$$lock_hash" > "$$marker"
|
printf '%s' "$$lock_hash" > "$$marker"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# CTMS Desktop Release Stabilization Checklist
|
||||||
|
|
||||||
|
状态: `active`
|
||||||
|
适用范围: Web 与 macOS Desktop 统一客户端发布
|
||||||
|
最后更新: `2026-07-01`
|
||||||
|
|
||||||
|
本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。
|
||||||
|
|
||||||
|
## 1. 发布链路门禁
|
||||||
|
|
||||||
|
发布候选提交必须从同一 Git 提交构建 Web 与 Desktop 制品,并完成以下检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm ci
|
||||||
|
npm run version:check
|
||||||
|
npm run release:env:check
|
||||||
|
npm run runtime:check
|
||||||
|
npm run desktop:release:check
|
||||||
|
npm run ui:contract
|
||||||
|
npm run type-check
|
||||||
|
npm run test:unit
|
||||||
|
npm run build
|
||||||
|
npm run desktop:build:app
|
||||||
|
```
|
||||||
|
|
||||||
|
正式发布还必须确认:
|
||||||
|
|
||||||
|
- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。
|
||||||
|
- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=<release tag commit>` 由 CI 注入,且 `npm run release:env:check` 通过。
|
||||||
|
- [ ] macOS app 已签名和公证。
|
||||||
|
- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。
|
||||||
|
- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY`、`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 和 Apple 签名/公证变量后,以 `REQUIRE_DESKTOP_SIGNING=true` 再次执行 `npm run release:env:check`,随后执行 `npm run desktop:build -- --bundles app`。
|
||||||
|
- [ ] 正式 updater feed 执行 `npm run desktop:update-feed:check -- --feed <latest.json> --artifacts-dir <artifact-dir>`。
|
||||||
|
- [ ] 不可变制品先上传,`latest.json` 最后原子替换;若 feed 校验未通过,不替换线上 `latest.json`。
|
||||||
|
- [ ] Web 与 Desktop 制品记录同一产品版本、Git 标签和完整提交 SHA。
|
||||||
|
|
||||||
|
## 2. 安全边界复审
|
||||||
|
|
||||||
|
自动门禁 `npm run desktop:release:check` 覆盖以下静态约束:
|
||||||
|
|
||||||
|
- [ ] Tauri bundle 启用 `app`、`dmg` 和 updater artifacts。
|
||||||
|
- [ ] updater public key 已配置。
|
||||||
|
- [ ] CSP 禁止 wildcard source、`unsafe-eval`、宽泛 HTTP API 访问和 `object-src`。
|
||||||
|
- [ ] Tauri capability 不包含 shell 权限、持久文件系统 scope 或宽泛目录读写。
|
||||||
|
- [ ] 文件系统与 opener scope 只允许 `$TEMP/ctms-desktop/**`。
|
||||||
|
- [ ] 单实例插件先于其他桌面插件注册。
|
||||||
|
- [ ] Tauri command 白名单仅包含凭据和更新命令。
|
||||||
|
- [ ] 前端源码不通过 query string 传递 token。
|
||||||
|
- [ ] `ctms_token` 只允许由 `secureSessionStorage` 处理。
|
||||||
|
- [ ] 系统通知只能通过 `frontend/src/runtime/notifications.ts` 发送,标题和正文保持通用。
|
||||||
|
- [ ] CI release 候选 workflow 包含 version/runtime/desktop/ui/type/unit/build/desktop app smoke 门禁。
|
||||||
|
|
||||||
|
人工复审还必须确认:
|
||||||
|
|
||||||
|
- [ ] token 不出现在 URL、日志、系统通知正文、下载链接或持久化业务缓存中。
|
||||||
|
- [ ] 桌面端通知正文只显示通用内容,不包含项目、文件或版本详情。
|
||||||
|
- [ ] 服务端权限、审计和业务数据持久化仍由 FastAPI 后端裁决。
|
||||||
|
- [ ] Web 运行时不直接导入 Tauri API。
|
||||||
|
|
||||||
|
## 3. 端到端回归矩阵
|
||||||
|
|
||||||
|
| 场景 | Web | macOS Desktop | 预期 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 登录与项目恢复 | 必测 | 必测 | 登录成功后恢复可访问项目;401 后重新登录 |
|
||||||
|
| 服务器地址未配置 | 不适用 | 必测 | 自动进入服务器设置,不进入业务页 |
|
||||||
|
| 服务器地址切换 | 不适用 | 必测 | 清除当前会话和项目上下文,要求重新登录 |
|
||||||
|
| 服务端不可达 | 必测 | 必测 | 显示可恢复错误,不进入离线模式 |
|
||||||
|
| 附件上传 | 必测 | 必测 | Web 使用浏览器文件选择,Desktop 使用原生选择 |
|
||||||
|
| 附件下载/保存/打开 | 必测 | 必测 | 使用 Authorization header;无 `?token=` |
|
||||||
|
| 临时文件清理 | 不适用 | 必测 | 启动时清理 `$TEMP/ctms-desktop/**` |
|
||||||
|
| 系统通知开启 | 不适用 | 必测 | 用户主动开启后请求 OS 权限并创建订阅 |
|
||||||
|
| 系统通知拒绝 | 不适用 | 必测 | 开关回退,提示系统权限未开启 |
|
||||||
|
| 通知领取与 ack | 不适用 | 必测 | 显示成功后 ack;失败等待租约重试 |
|
||||||
|
| 单实例重复启动 | 不适用 | 必测 | 恢复、显示并聚焦主窗口 |
|
||||||
|
| 自动更新检查 | 不适用 | 必测 | release 通道按当前 CTMS origin 派生清单 |
|
||||||
|
| 更新稍后提醒 | 不适用 | 必测 | 同版本 24 小时内不重复提示 |
|
||||||
|
| 更新安装失败 | 不适用 | 必测 | 不打断业务录入,显示可排障错误 |
|
||||||
|
|
||||||
|
## 4. 桌面体验验收
|
||||||
|
|
||||||
|
- [ ] 登录页显示当前桌面服务器地址,长 URL 不撑破登录面板。
|
||||||
|
- [ ] 服务器设置页显示当前服务器、连接检查状态、HTTP 错误、超时和网络失败原因。
|
||||||
|
- [ ] 个人中心显示客户端类型、版本、平台、构建通道、提交、服务器和能力状态。
|
||||||
|
- [ ] 个人中心可复制诊断信息,内容不包含 token 或业务敏感数据。
|
||||||
|
- [ ] 通知开关显示 OS 权限状态。
|
||||||
|
- [ ] 手动检查更新能反馈“已是最新版本”、未启用更新或检查失败。
|
||||||
|
- [ ] 关键弹窗、表单、按钮在最小窗口尺寸 `1180x760` 下不重叠、不溢出。
|
||||||
|
- [ ] 更新弹窗只显示版本、发布日期和通用 release notes,不展示 token、下载链接或业务详情。
|
||||||
|
|
||||||
|
## 5. 不允许项
|
||||||
|
|
||||||
|
- [ ] 不实现离线登录、离线浏览、离线队列或离线同步。
|
||||||
|
- [ ] 不在桌面端保存 CTMS 业务数据副本。
|
||||||
|
- [ ] 不内嵌 FastAPI、PostgreSQL、SQLite 或本地业务 API 镜像。
|
||||||
|
- [ ] 不绕过后端做本地权限裁决或本地审计回放。
|
||||||
|
|
||||||
|
## 6. 2026-07-01 收尾验证记录
|
||||||
|
|
||||||
|
本轮收尾验证在 `/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop` 的 detached HEAD `c923f887` 上执行,包含当前工作区文档与 CI 门禁调整。
|
||||||
|
|
||||||
|
已通过的自动门禁:
|
||||||
|
|
||||||
|
- `cd frontend && npm run version:check`
|
||||||
|
- `cd frontend && npm run release:env:check`
|
||||||
|
- `cd frontend && npm run runtime:check`
|
||||||
|
- `cd frontend && npm run desktop:release:check`
|
||||||
|
- `cd frontend && npm run ui:contract`
|
||||||
|
- `cd frontend && npm run type-check`
|
||||||
|
- `cd frontend && npm run test:unit`
|
||||||
|
- `cd frontend && npm run build`
|
||||||
|
- `cd frontend && npm run desktop:build:app`
|
||||||
|
- `cd frontend && node --check scripts/verify-desktop-update-feed.mjs`
|
||||||
|
|
||||||
|
验证结论:
|
||||||
|
|
||||||
|
- Tauri 运行时边界、release 静态安全门禁、构建元数据预检、版本一致性和 UI 合约均通过。
|
||||||
|
- Web 生产构建和未签名 macOS `.app` smoke 构建均可重复执行。
|
||||||
|
- 当前 CI 已补齐 `npm run release:env:check` 和 `npm run ui:contract`,tag 构建会将 `VITE_BUILD_CHANNEL` 规范为 `release` 并校验 tag 与版本号一致。
|
||||||
|
- updater feed 校验脚本已完成语法检查;正式 `latest.json` 需要在签名 updater artifacts 生成后执行实物校验。
|
||||||
|
|
||||||
|
仍需正式发布前人工确认:
|
||||||
|
|
||||||
|
- macOS 签名、公证、Apple Developer 凭据和组织 updater 私钥。
|
||||||
|
- 签名后的 updater artifacts、`.sig`、checksum manifest 和 `latest.json` 在真实发布目录内通过 `npm run desktop:update-feed:check`。
|
||||||
|
- 不可变制品上传完成后,再原子替换线上 `latest.json`。
|
||||||
|
- Desktop 端到端人工回归矩阵、最小窗口体验验收和系统通知/自动更新真实环境验证。
|
||||||
@@ -89,11 +89,13 @@ Rules:
|
|||||||
`desktop-release` branches.
|
`desktop-release` branches.
|
||||||
- Web and Desktop changes both follow `feature/*` -> `dev` -> `main` ->
|
- Web and Desktop changes both follow `feature/*` -> `dev` -> `main` ->
|
||||||
`release`.
|
`release`.
|
||||||
- A platform-specific feature branch is allowed while work is in progress, for
|
- A platform-specific feature or agent task branch is allowed while work is in
|
||||||
example `feature/desktop-file-picker`, but it must merge back into `dev`.
|
progress, for example `feature/desktop-file-picker` or
|
||||||
- `codex/ctms-desktop` is a temporary desktop integration branch. After the
|
`codex/desktop-menu-polish`, but it must merge back into `dev`.
|
||||||
Tauri baseline is accepted into `dev`, new desktop work must use short-lived
|
- `codex/ctms-desktop` was the temporary desktop integration branch for the
|
||||||
feature branches from the current `dev`.
|
Tauri baseline. It is no longer a current desktop mainline. Do not commit,
|
||||||
|
rebase, or push new desktop work to it unless explicitly cleaning up the
|
||||||
|
historical branch after its accepted changes are present on `dev`.
|
||||||
- Platform differences belong behind `frontend/src/runtime/`. Shared business
|
- Platform differences belong behind `frontend/src/runtime/`. Shared business
|
||||||
modules must not import Tauri APIs directly.
|
modules must not import Tauri APIs directly.
|
||||||
- A release tag identifies one product source state. Web and Desktop artifacts
|
- A release tag identifies one product source state. Web and Desktop artifacts
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
|
|
||||||
- 技术路线固定为 Tauri。
|
- 技术路线固定为 Tauri。
|
||||||
- 第一开发目标是 macOS 桌面端。
|
- 第一开发目标是 macOS 桌面端。
|
||||||
- Windows 需要作为长期适配约束保留,但不是第一阶段交付目标。
|
- Windows 仍只作为第二阶段兼容性验证目标,未获明确批准前不发布正式安装包。
|
||||||
- 当前只做第一阶段和第二阶段。
|
- 当前桌面端工作只允许在第一、二阶段边界内做修复、稳定化、体验收口和发布准备,不新增第三阶段能力。
|
||||||
- 不做离线功能。
|
- 不做离线功能。
|
||||||
- 不在桌面 App 内嵌本地后端服务。
|
- 不在桌面 App 内嵌本地后端服务。
|
||||||
- 不在桌面 App 内嵌或分发本地数据库来保存 CTMS 业务数据。
|
- 不在桌面 App 内嵌或分发本地数据库来保存 CTMS 业务数据。
|
||||||
@@ -30,83 +30,41 @@
|
|||||||
|
|
||||||
桌面端应复用现有前端代码和 API 契约。任何共享适配层都应保持小而明确,并可测试。
|
桌面端应复用现有前端代码和 API 契约。任何共享适配层都应保持小而明确,并可测试。
|
||||||
|
|
||||||
## 第一阶段:macOS 在线桌面客户端
|
## 已完成阶段边界
|
||||||
|
|
||||||
第一阶段交付一个面向现有 CTMS 服务的 macOS 桌面壳。
|
第一阶段 macOS 在线桌面壳和第二阶段原生能力主体改造已经形成。详细历史方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md) 和 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。
|
||||||
|
|
||||||
详细方案见 [`desktop-phase-1-design.md`](desktop-phase-1-design.md)。
|
后续不再按第一阶段空白项目初始化 Tauri,也不再扩展第二阶段以外的新桌面产品能力。当前允许推进的工作仅包括:
|
||||||
|
|
||||||
范围:
|
- 修复既有 Tauri、运行时适配层、文件、通知、凭据、更新、菜单/快捷键和打包问题。
|
||||||
|
- 稳定 Web 与 Desktop 共用业务代码,确保平台差异继续收敛在 `frontend/src/runtime/` 后面。
|
||||||
|
- 完成 macOS 正式发布前的签名、公证、updater 签名、制品发布、CI 门禁和人工回归。
|
||||||
|
- 做 Windows 第二阶段兼容性验证,但不发布正式 Windows 安装包。
|
||||||
|
|
||||||
- 在现有前端工程中加入 Tauri 项目结构。
|
仍然不允许:
|
||||||
- 将当前 Vue/Vite 应用运行在 Tauri 桌面窗口中。
|
|
||||||
- 连接已有 CTMS 后端,支持 HTTP/HTTPS。
|
|
||||||
- 为桌面端提供可配置的服务端地址处理。
|
|
||||||
- 保持当前登录、角色权限、项目上下文和审计行为。
|
|
||||||
- 所有业务数据读写仍发生在服务端。
|
|
||||||
- 产出 macOS 开发构建,并明确后续签名、公证、正式发布路径。
|
|
||||||
|
|
||||||
不做:
|
- 离线登录、离线浏览、离线队列、离线同步或本地优先工作流。
|
||||||
|
- 本地 PostgreSQL、SQLite、IndexedDB 业务数据缓存或本地 API 镜像。
|
||||||
- 离线登录。
|
- 内嵌 Python/FastAPI 后端服务。
|
||||||
- 离线浏览项目。
|
- 绕过后端做本地权限裁决、本地审计缓存或审计回放。
|
||||||
- 本地 PostgreSQL、SQLite、IndexedDB 业务数据持久化或本地 API 镜像。
|
- 为桌面端复制或重写一套独立业务 UI。
|
||||||
- 桌面端重写 CTMS 页面。
|
|
||||||
- Windows 安装包交付。
|
|
||||||
- 自动更新实现,除非被明确提升到第二阶段任务。
|
|
||||||
|
|
||||||
退出标准:
|
|
||||||
|
|
||||||
- macOS App 可以启动 CTMS UI。
|
|
||||||
- macOS App 可以连接指定 CTMS 后端。
|
|
||||||
- 登录和常规在线流程与 Web 端行为一致。
|
|
||||||
- Web 构建仍可用,不被 Tauri API 强耦合。
|
|
||||||
- 桌面端运行时边界已文档化。
|
|
||||||
|
|
||||||
## 第二阶段:桌面端能力增强
|
|
||||||
|
|
||||||
第二阶段在不改变在线优先产品边界的前提下,增加原生桌面能力。
|
|
||||||
|
|
||||||
详细方案见 [`desktop-phase-2-design.md`](desktop-phase-2-design.md)。
|
|
||||||
|
|
||||||
范围:
|
|
||||||
|
|
||||||
- 在附件等场景中引入原生文件选择、下载、打开能力。
|
|
||||||
- 基于服务端在线数据提供系统通知能力。
|
|
||||||
- 使用 Tauri 兼容方式实现安全的桌面端 token/session 存储。
|
|
||||||
- 单实例启动行为。
|
|
||||||
- 在支持、审计或排障需要时暴露桌面 App 版本、平台、客户端类型等元数据。
|
|
||||||
- 为签名后的桌面版本设计并实现自动更新。
|
|
||||||
- 为 Windows 适配做准备,包括路径处理、安装器假设、CI 打包设计。
|
|
||||||
|
|
||||||
不做:
|
|
||||||
|
|
||||||
- 离线队列。
|
|
||||||
- 业务数据后台同步。
|
|
||||||
- 用于离线使用的本地业务数据缓存。
|
|
||||||
- 绕过后端的本地权限裁决。
|
|
||||||
- 本地审计日志缓存后再回放。
|
|
||||||
|
|
||||||
退出标准:
|
|
||||||
|
|
||||||
- 桌面专属能力隔离在适配层之后。
|
|
||||||
- Web 运行时不依赖桌面 API,仍可正常工作。
|
|
||||||
- 桌面端 session 存储和文件操作有清晰安全边界。
|
|
||||||
- macOS 打包路径可重复执行。
|
|
||||||
- Windows 打包要求在实现前已经文档化。
|
|
||||||
|
|
||||||
## 架构方向
|
## 架构方向
|
||||||
|
|
||||||
使用运行时适配层,不在业务页面里散落平台判断。
|
使用运行时适配层,不在业务页面里散落平台判断。
|
||||||
|
|
||||||
建议的适配层边界:
|
当前已采用并必须继续保持的适配层边界:
|
||||||
|
|
||||||
- `platform`:识别 Web、macOS 桌面端、Windows 桌面端。
|
- `platform`:识别 Web、macOS 桌面端、Windows 桌面端。
|
||||||
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
|
- `apiBaseUrl`:分别解析 Web 和桌面端的服务端 API 地址。
|
||||||
- `storage`:隔离浏览器存储与桌面安全存储。
|
- `desktopServerConfig`:管理桌面服务端地址配置和切换事件。
|
||||||
|
- `secureSessionStorage`:隔离浏览器 token 存储与桌面系统凭据库。
|
||||||
- `files`:隔离浏览器上传下载与原生文件能力。
|
- `files`:隔离浏览器上传下载与原生文件能力。
|
||||||
- `notifications`:隔离 Web 通知与桌面系统通知。
|
- `notifications`:隔离 Web 通知与桌面系统通知。
|
||||||
|
- `updates`:隔离桌面自动更新检查与安装入口。
|
||||||
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
|
- `appMetadata`:在可用时提供桌面 App 版本、平台、构建通道。
|
||||||
|
- `desktopMenu` 和 `desktopUiPreferences`:承接桌面菜单命令、最近访问和收藏等桌面体验状态。
|
||||||
|
- `clientRuntime`:作为业务侧获取平台能力的聚合入口。
|
||||||
|
|
||||||
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
|
业务模块应调用这些适配层,而不是直接调用 Tauri API。Tauri command 应保持窄职责,不包含 CTMS 业务规则。
|
||||||
|
|
||||||
@@ -116,7 +74,7 @@
|
|||||||
- 非本地服务连接优先使用 HTTPS。
|
- 非本地服务连接优先使用 HTTPS。
|
||||||
- 认证与授权决策保留在后端。
|
- 认证与授权决策保留在后端。
|
||||||
- 审计敏感决策保留在后端。
|
- 审计敏感决策保留在后端。
|
||||||
- 第二阶段开始处理敏感凭据时,必须使用明确批准的安全存储方案。
|
- 敏感凭据必须继续使用明确批准的安全存储方案。
|
||||||
- 不向前端暴露宽泛文件系统访问权限。
|
- 不向前端暴露宽泛文件系统访问权限。
|
||||||
- Tauri 权限保持最小化,并按功能精确授权。
|
- Tauri 权限保持最小化,并按功能精确授权。
|
||||||
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
|
- 每个新增 Tauri command 都需要被视为桌面端安全边界的一部分进行审查。
|
||||||
@@ -126,10 +84,50 @@
|
|||||||
桌面端工作在以下位置开发:
|
桌面端工作在以下位置开发:
|
||||||
|
|
||||||
- Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop`
|
- Worktree:`/Users/zcc/MyCTMS/ctms-dev/worktrees/ctms-desktop`
|
||||||
- 短期分支:`codex/<任务名称>`,必须从最新 `dev` 创建
|
- 短期分支:Agent 默认使用 `codex/<任务名称>`,也可按任务类型使用 `feature/*`、`fix/*`、`docs/*` 或 `release-prep/*`;所有普通桌面端工作都必须从最新 `dev` 创建
|
||||||
|
|
||||||
`codex/ctms-desktop` 仅是第一阶段 Tauri 基线临时集成分支。基线合入
|
当前代码状态:Tauri 基线和第二阶段原生能力已经形成,后续桌面端工作默认是第二阶段范围内的修复、稳定化、体验收口和发布准备。`codex/ctms-desktop` 是历史临时集成分支,不再作为当前工作线;不得继续向该分支提交、变基或推送新的桌面端工作,除非用户明确要求做收尾或删除分支。
|
||||||
`dev` 后,第二阶段工作不得继续把它作为长期桌面主线。
|
|
||||||
|
## 2026-07-01 审查结论与后续方向
|
||||||
|
|
||||||
|
本轮审查结论:桌面端已经完成从 Tauri 接入基线到第二阶段原生能力的主体改造,不再按空白桌面项目推进。当前重点不是扩展离线或本地业务能力,而是围绕既有在线桌面客户端做准发布稳定化。
|
||||||
|
|
||||||
|
已形成的能力边界:
|
||||||
|
|
||||||
|
- Tauri 工程、macOS App/DMG/updater artifacts 配置已经存在。
|
||||||
|
- `frontend/src/runtime/` 已作为平台能力统一入口,业务代码不得绕过该适配层直接使用 Tauri API。
|
||||||
|
- 桌面服务器地址、API baseURL、客户端元数据请求头、安全 session 存储、原生文件能力、系统通知、单实例、菜单/快捷键和自动更新入口已经形成。
|
||||||
|
- 后端已经包含桌面通知订阅/投递状态、相关 API 和客户端诊断请求头支持。
|
||||||
|
- 已有 `npm run version:check`、`npm run runtime:check`、`npm run desktop:release:check` 等门禁用于约束版本、运行时边界和桌面发布安全边界。
|
||||||
|
|
||||||
|
后续优化优先级:
|
||||||
|
|
||||||
|
1. 发布稳定化:补齐 macOS 签名、公证、组织 updater 私钥签名、release tag 构建变量注入、不可变制品上传和 `latest.json` 原子替换。
|
||||||
|
2. 端到端回归:按 `docs/audits/desktop-release-stabilization-checklist.md` 覆盖服务器配置、服务器切换清会话、Keychain/凭据库、附件上传下载、系统通知、单实例和自动更新失败恢复。
|
||||||
|
3. 安全复审:持续确认 token 不进入 URL、日志、系统通知正文、下载链接或明文持久化;Tauri command、capability、CSP 和 updater 改动必须同步评估发布门禁。
|
||||||
|
4. 桌面体验收口:重点检查登录、服务器设置、个人中心诊断信息、通知开关、更新弹窗和最小窗口 `1180x760` 下的布局稳定性。
|
||||||
|
5. CI 与发布流程:Web 与 Desktop 必须从同一提交、同一语义化版本号和同一正式标签构建;发布候选应执行本文档列出的相关质量门禁。
|
||||||
|
6. Windows 兼容验证:仅作为第二阶段兼容性目标,验证 Credential Manager、路径处理、通知/updater 编译、WebView2 和安装器假设;未获明确批准前不发布正式 Windows 安装包。
|
||||||
|
|
||||||
|
如果后续任务试图新增离线登录、本地业务数据存储、内嵌后端、本地业务队列、离线同步或绕过后端权限审计,应先修改并评审本计划书,不能直接实现。
|
||||||
|
|
||||||
|
## 当前质量门禁
|
||||||
|
|
||||||
|
前端或桌面端代码变更应按影响范围执行相关检查。发布、桌面端适配层、Tauri 配置或安全边界相关变更至少考虑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run version:check
|
||||||
|
npm run runtime:check
|
||||||
|
npm run desktop:release:check
|
||||||
|
npm run ui:contract
|
||||||
|
npm run type-check
|
||||||
|
npm run test:unit
|
||||||
|
npm run build
|
||||||
|
npm run desktop:build:app
|
||||||
|
```
|
||||||
|
|
||||||
|
正式桌面发布构建仍必须使用组织批准的 updater 签名私钥和 Apple 签名/公证流程;未签名或 ad-hoc 构建只能作为内部验证构建描述。文档-only 变更可以不执行完整代码门禁,但必须在结果说明中明确未运行。
|
||||||
|
|
||||||
## 每次开发前必须执行的检查
|
## 每次开发前必须执行的检查
|
||||||
|
|
||||||
@@ -140,5 +138,7 @@
|
|||||||
- 确认任务不会引入离线能力。
|
- 确认任务不会引入离线能力。
|
||||||
- 确认实现不会破坏 Web 运行时。
|
- 确认实现不会破坏 Web 运行时。
|
||||||
- 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。
|
- 确认 Tauri API 使用被隔离在适配层之后,除非有明确记录的理由。
|
||||||
|
- 确认不会重复初始化 Tauri 或绕过既有 `frontend/src/runtime/` 运行时边界。
|
||||||
|
- 涉及 Tauri 权限、CSP、updater、凭据、文件或通知能力时,确认桌面发布检查脚本和发布清单是否需要同步更新。
|
||||||
|
|
||||||
如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。
|
如果用户请求与本文档冲突,先停止实现并确认范围,不要直接推进。
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ windows-main
|
|||||||
| 生产热修复 | `hotfix/<问题名称>` | `hotfix/login-loop` |
|
| 生产热修复 | `hotfix/<问题名称>` | `hotfix/login-loop` |
|
||||||
| 文档调整 | `docs/<文档名称>` | `docs/release-sop` |
|
| 文档调整 | `docs/<文档名称>` | `docs/release-sop` |
|
||||||
| 发布准备 | `release-prep/<版本号>` | `release-prep/v1.2.0` |
|
| 发布准备 | `release-prep/<版本号>` | `release-prep/v1.2.0` |
|
||||||
| 临时集成 | `codex/<任务名称>` | `codex/ctms-desktop` |
|
| Agent 临时任务 | `codex/<任务名称>` | `codex/desktop-menu-polish` |
|
||||||
|
|
||||||
分支名称使用小写英文和连字符,不使用个人姓名、日期或模糊名称。
|
分支名称使用小写英文和连字符,不使用个人姓名、日期或模糊名称。
|
||||||
|
|
||||||
@@ -92,17 +92,23 @@ git merge origin/dev
|
|||||||
cd frontend
|
cd frontend
|
||||||
npm run version:check
|
npm run version:check
|
||||||
npm run runtime:check
|
npm run runtime:check
|
||||||
|
npm run desktop:release:check
|
||||||
|
npm run ui:contract
|
||||||
npm run type-check
|
npm run type-check
|
||||||
npm run test:unit
|
npm run test:unit
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run desktop:build:app
|
||||||
```
|
```
|
||||||
|
|
||||||
涉及 Tauri、macOS 打包或桌面适配层时,还必须在 macOS 执行:
|
涉及 Tauri、macOS 打包或桌面适配层时,还必须在 macOS 执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run desktop:build -- --bundles app
|
npm run desktop:build:app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
正式桌面发布构建仍必须设置 updater 签名私钥后执行
|
||||||
|
`npm run desktop:build -- --bundles app`。
|
||||||
|
|
||||||
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
|
后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。
|
||||||
|
|
||||||
### 4. 创建提交
|
### 4. 创建提交
|
||||||
@@ -174,33 +180,17 @@ git push origin --delete feature/<功能名称>
|
|||||||
|
|
||||||
工作树正在使用的分支不能直接删除,应先切换分支或移除对应工作树。
|
工作树正在使用的分支不能直接删除,应先切换分支或移除对应工作树。
|
||||||
|
|
||||||
## 五、当前桌面集成分支处理流程
|
## 五、历史桌面集成分支约束
|
||||||
|
|
||||||
`codex/ctms-desktop` 是临时桌面集成分支,不作为长期桌面主线。
|
`codex/ctms-desktop` 是第一阶段 Tauri 基线使用过的历史临时集成分支,不作为当前桌面端工作线,也不作为长期桌面主线。
|
||||||
|
|
||||||
处理步骤:
|
当前约束:
|
||||||
|
|
||||||
```bash
|
- 不得继续向 `codex/ctms-desktop` 提交、变基或推送新的桌面端工作。
|
||||||
git status
|
- 如本地或远程仍保留该分支,只能用于追溯历史或在确认已合入 `dev` 后删除。
|
||||||
git add <本次桌面端和治理优化相关文件>
|
- 后续桌面功能、修复、稳定化和发布准备必须从最新 `dev` 创建短期 `feature/*`、`fix/*`、`docs/*`、`release-prep/*` 或 `codex/*` 分支。
|
||||||
git diff --cached --check
|
- Agent 创建分支默认使用 `codex/<任务名称>`,并在任务合入 `dev` 后删除。
|
||||||
git commit -m "refactor(client): 统一网页端和桌面端发布流程"
|
- 如果工作区处于 detached HEAD 或包含尚未归属到分支的提交,执行分支切换、提交、推送或变基前必须先确认目标基线和处理方式。
|
||||||
git fetch origin
|
|
||||||
git rebase origin/dev
|
|
||||||
git push origin codex/ctms-desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
创建:
|
|
||||||
|
|
||||||
```text
|
|
||||||
codex/ctms-desktop -> dev
|
|
||||||
```
|
|
||||||
|
|
||||||
合并并验证 `dev` 后:
|
|
||||||
|
|
||||||
- 删除远程 `codex/ctms-desktop`
|
|
||||||
- 不再从该分支继续开发
|
|
||||||
- 后续桌面功能从最新 `dev` 创建短期 `feature/*` 分支
|
|
||||||
|
|
||||||
## 六、从 `dev` 晋级到 `main`
|
## 六、从 `dev` 晋级到 `main`
|
||||||
|
|
||||||
@@ -453,10 +443,13 @@ git log --oneline --decorate --graph --all -30
|
|||||||
- [ ] 网页端与桌面端版本一致
|
- [ ] 网页端与桌面端版本一致
|
||||||
- [ ] `npm run version:check` 通过
|
- [ ] `npm run version:check` 通过
|
||||||
- [ ] `npm run runtime:check` 通过
|
- [ ] `npm run runtime:check` 通过
|
||||||
|
- [ ] `npm run desktop:release:check` 通过
|
||||||
|
- [ ] `npm run ui:contract` 通过
|
||||||
- [ ] `npm run type-check` 通过
|
- [ ] `npm run type-check` 通过
|
||||||
- [ ] `npm run test:unit` 通过
|
- [ ] `npm run test:unit` 通过
|
||||||
- [ ] `npm run build` 通过
|
- [ ] `npm run build` 通过
|
||||||
- [ ] macOS 桌面端构建通过
|
- [ ] `npm run desktop:build:app` 通过
|
||||||
|
- [ ] 正式桌面发布构建已使用 updater 签名私钥执行
|
||||||
- [ ] 数据库迁移与回滚方案确认
|
- [ ] 数据库迁移与回滚方案确认
|
||||||
- [ ] 发布说明完成
|
- [ ] 发布说明完成
|
||||||
- [ ] `main -> release` 合并完成
|
- [ ] `main -> release` 合并完成
|
||||||
|
|||||||
@@ -56,6 +56,32 @@ This synchronizes:
|
|||||||
|
|
||||||
Manual edits that leave these files inconsistent fail CI.
|
Manual edits that leave these files inconsistent fail CI.
|
||||||
|
|
||||||
|
## Stabilization Gates
|
||||||
|
|
||||||
|
Client release candidates must pass the shared Web checks and the Desktop
|
||||||
|
release/security gate before promotion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run version:check
|
||||||
|
npm run release:env:check
|
||||||
|
npm run runtime:check
|
||||||
|
npm run desktop:release:check
|
||||||
|
npm run ui:contract
|
||||||
|
npm run type-check
|
||||||
|
npm run test:unit
|
||||||
|
npm run build
|
||||||
|
npm run desktop:build:app
|
||||||
|
```
|
||||||
|
|
||||||
|
`release:env:check` verifies build channel and commit metadata, and can be
|
||||||
|
made strict for signed Desktop builds with `REQUIRE_DESKTOP_SIGNING=true`.
|
||||||
|
`desktop:release:check` statically verifies the Tauri bundle, updater public
|
||||||
|
key, CSP, capability scopes, command allowlist, query-token ban, generic system
|
||||||
|
notification boundary, CI gate coverage, and secure session token boundary. The
|
||||||
|
full manual release, security, regression, and Desktop UX checklist lives in
|
||||||
|
`docs/audits/desktop-release-stabilization-checklist.md`.
|
||||||
|
|
||||||
## Promotion
|
## Promotion
|
||||||
|
|
||||||
1. Merge feature branches into `dev`.
|
1. Merge feature branches into `dev`.
|
||||||
@@ -97,8 +123,9 @@ The release pipeline must:
|
|||||||
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;
|
||||||
6. upload immutable artifacts first;
|
6. verify the feed with `npm run desktop:update-feed:check -- --feed <latest.json> --artifacts-dir <artifact-dir>`;
|
||||||
7. atomically replace `latest.json` last.
|
7. upload immutable artifacts first;
|
||||||
|
8. atomically replace `latest.json` last.
|
||||||
|
|
||||||
For Universal macOS artifacts, `latest.json` must provide both
|
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
|
||||||
@@ -126,13 +153,22 @@ Windows validation must cover:
|
|||||||
cd frontend
|
cd frontend
|
||||||
npm ci
|
npm ci
|
||||||
npm run version:check
|
npm run version:check
|
||||||
|
export VITE_BUILD_CHANNEL=release
|
||||||
|
export VITE_BUILD_COMMIT="$(git rev-parse HEAD)"
|
||||||
|
npm run release:env:check
|
||||||
npm run runtime:check
|
npm run runtime:check
|
||||||
|
npm run desktop:release:check
|
||||||
|
npm run ui:contract
|
||||||
npm run type-check
|
npm run type-check
|
||||||
npm run test:unit
|
npm run test:unit
|
||||||
npm run build
|
npm run build
|
||||||
|
npm run desktop:build:app
|
||||||
export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY"
|
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
|
||||||
|
npm run release:env:check
|
||||||
npm run desktop:build -- --bundles app
|
npm run desktop:build -- --bundles app
|
||||||
|
npm run desktop:update-feed:check -- --feed src-tauri/target/release/bundle/latest.json --artifacts-dir src-tauri/target/release/bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -10,10 +10,14 @@
|
|||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"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:bundle:dmg": "tauri build --bundles dmg",
|
"desktop:bundle:dmg": "tauri build --bundles dmg",
|
||||||
|
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.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",
|
||||||
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
|
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
|
||||||
|
"desktop:release:check": "node scripts/verify-desktop-release.mjs",
|
||||||
"test:unit": "vitest run --environment jsdom",
|
"test:unit": "vitest run --environment jsdom",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { extname, relative, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||||
|
const rootDir = resolve(frontendDir, "..");
|
||||||
|
const sourceDir = resolve(frontendDir, "src");
|
||||||
|
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||||
|
|
||||||
|
const fail = (message) => failures.push(message);
|
||||||
|
const assert = (condition, message) => {
|
||||||
|
if (!condition) fail(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const walk = async (directory) => {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
entries.map(async (entry) => {
|
||||||
|
const path = resolve(directory, entry.name);
|
||||||
|
return entry.isDirectory() ? walk(path) : path;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionIdentifier = (permission) =>
|
||||||
|
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
||||||
|
|
||||||
|
const assertPathScope = (permission, expectedPrefix, description) => {
|
||||||
|
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
||||||
|
assert(allow.length > 0, `${description} must define an explicit allow list.`);
|
||||||
|
for (const item of allow) {
|
||||||
|
const path = item?.path;
|
||||||
|
assert(
|
||||||
|
typeof path === "string" && path.startsWith(expectedPrefix),
|
||||||
|
`${description} may only allow paths under ${expectedPrefix}; found ${path ?? "<missing>"}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyTauriConfig = async () => {
|
||||||
|
const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json"));
|
||||||
|
const targets = tauriConfig.bundle?.targets;
|
||||||
|
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
|
||||||
|
const csp = tauriConfig.app?.security?.csp || "";
|
||||||
|
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
|
||||||
|
const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0];
|
||||||
|
|
||||||
|
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
|
||||||
|
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
|
||||||
|
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
||||||
|
assert(
|
||||||
|
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
||||||
|
"Tauri must create updater artifacts for signed desktop release builds.",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
||||||
|
"Tauri updater public key must be configured.",
|
||||||
|
);
|
||||||
|
assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self.");
|
||||||
|
assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src.");
|
||||||
|
assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval.");
|
||||||
|
assert(
|
||||||
|
!cspTokens.some((token) => token === "*" || token.includes("://*")),
|
||||||
|
"Tauri CSP must not use wildcard sources.",
|
||||||
|
);
|
||||||
|
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
|
||||||
|
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
|
||||||
|
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyCapabilities = async () => {
|
||||||
|
const capabilitiesDir = resolve(tauriDir, "capabilities");
|
||||||
|
const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json"));
|
||||||
|
assert(files.length > 0, "At least one Tauri capability file must exist.");
|
||||||
|
|
||||||
|
const bannedPermissions = new Set([
|
||||||
|
"shell:default",
|
||||||
|
"shell:allow-open",
|
||||||
|
"shell:allow-execute",
|
||||||
|
"fs:default",
|
||||||
|
"fs:allow-read-dir",
|
||||||
|
"fs:allow-read-text-file",
|
||||||
|
"fs:allow-write-text-file",
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const capability = await readJson(resolve(capabilitiesDir, file));
|
||||||
|
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
|
||||||
|
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
|
||||||
|
|
||||||
|
for (const identifier of identifiers) {
|
||||||
|
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
|
||||||
|
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
|
||||||
|
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
|
||||||
|
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
|
||||||
|
if (fsScope && typeof fsScope !== "string") {
|
||||||
|
assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path");
|
||||||
|
assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`);
|
||||||
|
if (openerScope && typeof openerScope !== "string") {
|
||||||
|
assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyRustBoundary = async () => {
|
||||||
|
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
|
||||||
|
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
|
||||||
|
for (const token of forbiddenRust) {
|
||||||
|
assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init");
|
||||||
|
const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init");
|
||||||
|
assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered.");
|
||||||
|
assert(
|
||||||
|
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
|
||||||
|
"Single-instance plugin must be registered before other desktop plugins.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
|
||||||
|
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
|
||||||
|
const allowedCommands = [
|
||||||
|
"credentials::credential_get",
|
||||||
|
"credentials::credential_set",
|
||||||
|
"credentials::credential_delete",
|
||||||
|
"updates::desktop_update_check",
|
||||||
|
"updates::desktop_update_install",
|
||||||
|
];
|
||||||
|
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
||||||
|
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
||||||
|
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
|
||||||
|
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifySourceSafety = async () => {
|
||||||
|
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
|
||||||
|
const files = [
|
||||||
|
...(await walk(sourceDir)),
|
||||||
|
...(await walk(resolve(tauriDir, "src"))),
|
||||||
|
].filter((path) => sourceExtensions.has(extname(path)));
|
||||||
|
|
||||||
|
for (const path of files) {
|
||||||
|
const source = await readFile(path, "utf8");
|
||||||
|
const file = relative(rootDir, path);
|
||||||
|
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
|
||||||
|
assert(
|
||||||
|
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
|
||||||
|
`${file}: token-related values must not be written to console logs.`,
|
||||||
|
);
|
||||||
|
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
|
||||||
|
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
|
||||||
|
}
|
||||||
|
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
|
||||||
|
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyNotificationBoundary = async () => {
|
||||||
|
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
|
||||||
|
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
|
||||||
|
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
|
||||||
|
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyUpdaterBoundary = async () => {
|
||||||
|
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
|
||||||
|
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
|
||||||
|
assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds.");
|
||||||
|
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyWorkflowGates = async () => {
|
||||||
|
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
|
||||||
|
const requiredCommands = [
|
||||||
|
"npm run version:check",
|
||||||
|
"npm run runtime:check",
|
||||||
|
"npm run desktop:release:check",
|
||||||
|
"npm run ui:contract",
|
||||||
|
"npm run type-check",
|
||||||
|
"npm run test:unit",
|
||||||
|
"npm run build",
|
||||||
|
"npm run desktop:build:app",
|
||||||
|
"npm run release:env:check",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const command of requiredCommands) {
|
||||||
|
assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`);
|
||||||
|
}
|
||||||
|
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.");
|
||||||
|
};
|
||||||
|
|
||||||
|
await verifyTauriConfig();
|
||||||
|
await verifyCapabilities();
|
||||||
|
await verifyRustBoundary();
|
||||||
|
await verifySourceSafety();
|
||||||
|
await verifyNotificationBoundary();
|
||||||
|
await verifyUpdaterBoundary();
|
||||||
|
await verifyWorkflowGates();
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log("Desktop release gate passed.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { access, readFile } from "node:fs/promises";
|
||||||
|
import { basename, resolve } from "node:path";
|
||||||
|
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 optionValue = (name) => {
|
||||||
|
const index = args.indexOf(name);
|
||||||
|
return index >= 0 ? args[index + 1] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedPath = resolve(
|
||||||
|
frontendDir,
|
||||||
|
optionValue("--feed") || process.env.DESKTOP_UPDATE_FEED || "src-tauri/target/release/bundle/latest.json",
|
||||||
|
);
|
||||||
|
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
|
||||||
|
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||||
|
|
||||||
|
const fail = (message) => failures.push(message);
|
||||||
|
const assert = (condition, message) => {
|
||||||
|
if (!condition) fail(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertFileExists = async (path, description) => {
|
||||||
|
try {
|
||||||
|
await access(path);
|
||||||
|
} catch {
|
||||||
|
fail(`${description} does not exist: ${path}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let feed;
|
||||||
|
try {
|
||||||
|
feed = JSON.parse(await readFile(feedPath, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
fail(`Cannot read desktop update feed ${feedPath}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feed) {
|
||||||
|
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
|
||||||
|
const platforms = feed.platforms || {};
|
||||||
|
const darwinArm = platforms["darwin-aarch64"];
|
||||||
|
const darwinIntel = platforms["darwin-x86_64"];
|
||||||
|
|
||||||
|
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
|
||||||
|
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
|
||||||
|
assert(Boolean(darwinArm), "latest.json must include darwin-aarch64.");
|
||||||
|
assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64.");
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
["darwin-aarch64", darwinArm],
|
||||||
|
["darwin-x86_64", darwinIntel],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [platform, entry] of entries) {
|
||||||
|
const rawUrl = entry?.url;
|
||||||
|
const signature = entry?.signature;
|
||||||
|
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
|
||||||
|
assert(typeof rawUrl === "string" && rawUrl.length > 0, `${platform} must include an artifact URL.`);
|
||||||
|
if (!rawUrl) continue;
|
||||||
|
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
fail(`${platform} artifact URL is invalid: ${rawUrl}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(url.protocol === "https:", `${platform} artifact URL must use HTTPS.`);
|
||||||
|
assert(!/[?&]token=/i.test(url.search), `${platform} artifact URL must not contain token query parameters.`);
|
||||||
|
assert(!url.pathname.endsWith("/latest.json"), `${platform} artifact URL must point to an immutable artifact, not latest.json.`);
|
||||||
|
assert(url.pathname.includes(packageInfo.version), `${platform} artifact URL must include version ${packageInfo.version}.`);
|
||||||
|
if (expectedBaseUrl) {
|
||||||
|
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artifactDir) {
|
||||||
|
const artifactPath = resolve(artifactDir, basename(url.pathname));
|
||||||
|
await assertFileExists(artifactPath, `${platform} updater artifact`);
|
||||||
|
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (darwinArm?.url && darwinIntel?.url) {
|
||||||
|
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(`Desktop update feed check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log("Desktop update feed check passed.");
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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 packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
const allowedChannels = new Set(["dev", "main", "release", "local"]);
|
||||||
|
const fullShaPattern = /^[0-9a-f]{40}$/i;
|
||||||
|
const semverTag = `v${packageInfo.version}`;
|
||||||
|
|
||||||
|
const env = process.env;
|
||||||
|
const channel = env.VITE_BUILD_CHANNEL || "local";
|
||||||
|
const commit = env.VITE_BUILD_COMMIT || "local";
|
||||||
|
const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true";
|
||||||
|
const isTagBuild = env.GITHUB_REF_TYPE === "tag";
|
||||||
|
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
|
||||||
|
|
||||||
|
const fail = (message) => failures.push(message);
|
||||||
|
const assert = (condition, message) => {
|
||||||
|
if (!condition) fail(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireEnv = (name) => {
|
||||||
|
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`);
|
||||||
|
|
||||||
|
if (isCi || isReleaseBuild) {
|
||||||
|
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
|
||||||
|
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.GITHUB_SHA) {
|
||||||
|
assert(
|
||||||
|
commit === env.GITHUB_SHA || commit === "local",
|
||||||
|
"VITE_BUILD_COMMIT must match GITHUB_SHA when GitHub Actions provides a source commit.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTagBuild) {
|
||||||
|
assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || "<missing>"}.`);
|
||||||
|
assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
|
||||||
|
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
|
||||||
|
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
|
||||||
|
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
|
||||||
|
requireEnv("APPLE_ID");
|
||||||
|
requireEnv("APPLE_PASSWORD");
|
||||||
|
requireEnv("APPLE_TEAM_ID");
|
||||||
|
assert(
|
||||||
|
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
|
||||||
|
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log("Release build environment check passed.");
|
||||||
|
}
|
||||||
@@ -32,38 +32,71 @@ const missing = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const pageContractChecks = [
|
const pageContractChecks = [
|
||||||
"src/views/ia/ProjectMilestones.vue",
|
{
|
||||||
"src/views/ia/SubjectManagement.vue",
|
file: "src/views/ia/ProjectMilestones.vue",
|
||||||
"src/views/ia/RiskIssueSae.vue",
|
groups: [
|
||||||
"src/views/ia/RiskIssuePd.vue",
|
["ctms-page-shell", "page"],
|
||||||
"src/views/ia/RiskIssueMonitoringVisits.vue"
|
["unified-action-bar", "table-card-toolbar"],
|
||||||
|
["ctms-table-card", "table-card"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "src/views/ia/SubjectManagement.vue",
|
||||||
|
groups: [
|
||||||
|
["ctms-page-shell", "page"],
|
||||||
|
["unified-action-bar", "table-card-toolbar"],
|
||||||
|
["ctms-table-card", "table-card"],
|
||||||
|
["subject-table"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "src/views/ia/RiskIssueSae.vue",
|
||||||
|
groups: [
|
||||||
|
["ctms-page-shell", "page"],
|
||||||
|
["unified-action-bar", "table-card-toolbar"],
|
||||||
|
["ctms-table-card", "table-card"],
|
||||||
|
["risk-table"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "src/views/ia/RiskIssuePd.vue",
|
||||||
|
groups: [
|
||||||
|
["ctms-page-shell", "page"],
|
||||||
|
["unified-action-bar", "table-card-toolbar"],
|
||||||
|
["ctms-table-card", "table-card"],
|
||||||
|
["risk-table"]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "src/views/ia/RiskIssueMonitoringVisits.vue",
|
||||||
|
groups: [
|
||||||
|
["ctms-page-shell", "page"],
|
||||||
|
["unified-action-bar", "monitoring-toolbar", "template-toolbar", "toolbar"],
|
||||||
|
["ctms-table-card", "table-card", "monitoring-table", "issue-table-panel"],
|
||||||
|
["issue-table"]
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const requiredPageClasses = [
|
for (const { file, groups } of pageContractChecks) {
|
||||||
"ctms-page-shell",
|
|
||||||
"unified-action-bar",
|
|
||||||
"ctms-table-card"
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of pageContractChecks) {
|
|
||||||
const content = readFileSync(file, "utf8");
|
const content = readFileSync(file, "utf8");
|
||||||
for (const className of requiredPageClasses) {
|
for (const group of groups) {
|
||||||
if (!content.includes(className)) {
|
if (!group.some((className) => content.includes(className))) {
|
||||||
missing.push(`${file}:${className}`);
|
missing.push(`${file}:${group.join("|")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8");
|
const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8");
|
||||||
const requiredOverviewClasses = [
|
const requiredOverviewGroups = [
|
||||||
"ctms-page-shell",
|
["ctms-page-shell", "page"],
|
||||||
"kpi",
|
["kpi", "overview-card"],
|
||||||
"unified-section"
|
["unified-section", "overview-container"]
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const className of requiredOverviewClasses) {
|
for (const group of requiredOverviewGroups) {
|
||||||
if (!overview.includes(className)) {
|
if (!group.some((className) => overview.includes(className))) {
|
||||||
missing.push(`src/views/ia/ProjectOverview.vue:${className}`);
|
missing.push(`src/views/ia/ProjectOverview.vue:${group.join("|")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,132 @@
|
|||||||
mod credentials;
|
mod credentials;
|
||||||
mod updates;
|
mod updates;
|
||||||
|
|
||||||
use tauri::Manager;
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||||
|
use tauri::{Emitter, Manager, Runtime};
|
||||||
|
|
||||||
|
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
|
||||||
|
|
||||||
|
fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||||
|
Menu::with_items(
|
||||||
|
handle,
|
||||||
|
&[
|
||||||
|
&Submenu::with_items(
|
||||||
|
handle,
|
||||||
|
"文件",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&MenuItem::with_id(
|
||||||
|
handle,
|
||||||
|
"ctms.desktop.commandPalette",
|
||||||
|
"打开命令面板",
|
||||||
|
true,
|
||||||
|
Some("CmdOrCtrl+K"),
|
||||||
|
)?,
|
||||||
|
&MenuItem::with_id(
|
||||||
|
handle,
|
||||||
|
"ctms.desktop.serverSettings",
|
||||||
|
"服务器设置",
|
||||||
|
true,
|
||||||
|
None::<&str>,
|
||||||
|
)?,
|
||||||
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
|
&PredefinedMenuItem::close_window(handle, None)?,
|
||||||
|
&PredefinedMenuItem::quit(handle, None)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
&Submenu::with_items(
|
||||||
|
handle,
|
||||||
|
"编辑",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::undo(handle, None)?,
|
||||||
|
&PredefinedMenuItem::redo(handle, None)?,
|
||||||
|
&PredefinedMenuItem::separator(handle)?,
|
||||||
|
&PredefinedMenuItem::cut(handle, None)?,
|
||||||
|
&PredefinedMenuItem::copy(handle, None)?,
|
||||||
|
&PredefinedMenuItem::paste(handle, None)?,
|
||||||
|
&PredefinedMenuItem::select_all(handle, None)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
&Submenu::with_items(
|
||||||
|
handle,
|
||||||
|
"视图",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&MenuItem::with_id(
|
||||||
|
handle,
|
||||||
|
"ctms.desktop.refresh",
|
||||||
|
"刷新当前视图",
|
||||||
|
true,
|
||||||
|
Some("CmdOrCtrl+R"),
|
||||||
|
)?,
|
||||||
|
&PredefinedMenuItem::fullscreen(handle, None)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
&Submenu::with_items(
|
||||||
|
handle,
|
||||||
|
"导航",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&MenuItem::with_id(
|
||||||
|
handle,
|
||||||
|
"ctms.desktop.back",
|
||||||
|
"返回",
|
||||||
|
true,
|
||||||
|
Some("CmdOrCtrl+["),
|
||||||
|
)?,
|
||||||
|
&MenuItem::with_id(
|
||||||
|
handle,
|
||||||
|
"ctms.desktop.forward",
|
||||||
|
"前进",
|
||||||
|
true,
|
||||||
|
Some("CmdOrCtrl+]"),
|
||||||
|
)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
&Submenu::with_items(
|
||||||
|
handle,
|
||||||
|
"窗口",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::minimize(handle, None)?,
|
||||||
|
&PredefinedMenuItem::maximize(handle, None)?,
|
||||||
|
&PredefinedMenuItem::close_window(handle, None)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
&Submenu::with_items(
|
||||||
|
handle,
|
||||||
|
"帮助",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&MenuItem::with_id(
|
||||||
|
handle,
|
||||||
|
"ctms.desktop.preferences",
|
||||||
|
"桌面偏好",
|
||||||
|
true,
|
||||||
|
Some("CmdOrCtrl+,"),
|
||||||
|
)?,
|
||||||
|
],
|
||||||
|
)?,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_menu_command<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.emit(DESKTOP_MENU_COMMAND_EVENT, command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.menu(desktop_menu)
|
||||||
|
.on_menu_event(|app, event| {
|
||||||
|
let command = event.id().as_ref();
|
||||||
|
if command.starts_with("ctms.desktop.") {
|
||||||
|
emit_menu_command(app, command);
|
||||||
|
}
|
||||||
|
})
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
|
|||||||
@@ -10,8 +10,12 @@
|
|||||||
import { initSessionManager } from "./session/sessionManager";
|
import { initSessionManager } from "./session/sessionManager";
|
||||||
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
|
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
|
||||||
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
|
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
|
||||||
|
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
|
||||||
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
|
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
|
||||||
|
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
applyDesktopThemePreference();
|
||||||
|
}
|
||||||
initSessionManager();
|
initSessionManager();
|
||||||
initDesktopNotificationManager();
|
initDesktopNotificationManager();
|
||||||
initDesktopUpdateManager();
|
initDesktopUpdateManager();
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visibleProxy"
|
||||||
|
class="desktop-command-dialog"
|
||||||
|
width="640px"
|
||||||
|
align-center
|
||||||
|
:show-close="false"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
destroy-on-close
|
||||||
|
@opened="focusSearch"
|
||||||
|
>
|
||||||
|
<div class="desktop-command-palette">
|
||||||
|
<div class="command-search-row">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
<input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model="query"
|
||||||
|
class="command-search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="搜索模块、项目或桌面操作"
|
||||||
|
@keydown.enter.prevent="runFirstCommand"
|
||||||
|
@keydown.esc.prevent="visibleProxy = false"
|
||||||
|
/>
|
||||||
|
<kbd>Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="command-list" role="listbox">
|
||||||
|
<template v-if="groupedCommands.length">
|
||||||
|
<section v-for="group in groupedCommands" :key="group.name" class="command-group">
|
||||||
|
<div class="command-group-title">{{ group.name }}</div>
|
||||||
|
<button
|
||||||
|
v-for="command in group.items"
|
||||||
|
:key="command.id"
|
||||||
|
type="button"
|
||||||
|
class="command-item"
|
||||||
|
@click="runCommand(command)"
|
||||||
|
>
|
||||||
|
<span class="command-item-title">{{ command.title }}</span>
|
||||||
|
<kbd v-if="command.shortcut">{{ command.shortcut }}</kbd>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<div v-else class="command-empty">没有匹配的命令</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from "vue";
|
||||||
|
import { Search } from "@element-plus/icons-vue";
|
||||||
|
import { getVisibleDesktopCommands, type DesktopCommand } from "../types/desktopCommands";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
commands: DesktopCommand[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const query = ref("");
|
||||||
|
const searchInputRef = ref<HTMLInputElement>();
|
||||||
|
|
||||||
|
const visibleProxy = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit("update:modelValue", value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCommands = computed(() => getVisibleDesktopCommands(props.commands, query.value));
|
||||||
|
|
||||||
|
const groupedCommands = computed(() => {
|
||||||
|
const map = new Map<string, DesktopCommand[]>();
|
||||||
|
filteredCommands.value.forEach((command) => {
|
||||||
|
const items = map.get(command.group) || [];
|
||||||
|
items.push(command);
|
||||||
|
map.set(command.group, items);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const focusSearch = () => {
|
||||||
|
nextTick(() => searchInputRef.value?.focus());
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCommand = async (command: DesktopCommand) => {
|
||||||
|
visibleProxy.value = false;
|
||||||
|
await command.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const runFirstCommand = () => {
|
||||||
|
const [first] = filteredCommands.value;
|
||||||
|
if (first) void runCommand(first);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(visible) => {
|
||||||
|
if (visible) {
|
||||||
|
query.value = "";
|
||||||
|
focusSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.desktop-command-palette {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-search-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #d9e2ef;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-search-input {
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-list {
|
||||||
|
max-height: min(58vh, 520px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-group + .command-group {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-group-title {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f172a;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item:hover,
|
||||||
|
.command-item:focus-visible {
|
||||||
|
background: #eef4ff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-item-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
min-width: 28px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-empty {
|
||||||
|
padding: 36px 12px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.desktop-command-dialog .el-dialog__header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-command-dialog .el-dialog__body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||||
|
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
|
||||||
|
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
|
||||||
|
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
|
||||||
|
|
||||||
|
describe("desktop layout shell", () => {
|
||||||
|
it("routes desktop and web shells through the runtime flag", () => {
|
||||||
|
const source = readLayoutSource();
|
||||||
|
|
||||||
|
expect(source).toContain("const isDesktop = isTauriRuntime()");
|
||||||
|
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
|
||||||
|
expect(source).toContain("<WebLayout v-else />");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses route-only desktop preference storage", () => {
|
||||||
|
const source = readDesktopLayoutSource();
|
||||||
|
|
||||||
|
expect(source).toContain("readDesktopRecentRoutes");
|
||||||
|
expect(source).toContain("readDesktopFavoriteRoutes");
|
||||||
|
expect(source).toContain("recordDesktopRecentRoute");
|
||||||
|
expect(source).toContain("currentDesktopRoutePreference");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares active route mapping between web and desktop shells", () => {
|
||||||
|
const webLayout = readWebLayoutSource();
|
||||||
|
const desktopLayout = readDesktopLayoutSource();
|
||||||
|
const navigation = readNavigationSource();
|
||||||
|
|
||||||
|
expect(webLayout).toContain("getActiveLayoutPath(route.path)");
|
||||||
|
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
|
||||||
|
expect(navigation).toContain("export const getActiveLayoutPath");
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
|||||||
|
import { TEXT } from "../../locales";
|
||||||
|
|
||||||
|
export type LayoutNavigationIcon =
|
||||||
|
| "audit"
|
||||||
|
| "calendar"
|
||||||
|
| "check"
|
||||||
|
| "coin"
|
||||||
|
| "dashboard"
|
||||||
|
| "document"
|
||||||
|
| "flag"
|
||||||
|
| "folder"
|
||||||
|
| "key"
|
||||||
|
| "monitor"
|
||||||
|
| "notebook"
|
||||||
|
| "project"
|
||||||
|
| "settings"
|
||||||
|
| "subject"
|
||||||
|
| "users";
|
||||||
|
|
||||||
|
export type LayoutNavigationItem = {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
group: string;
|
||||||
|
icon: LayoutNavigationIcon;
|
||||||
|
keywords?: string[];
|
||||||
|
children?: LayoutNavigationItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveLayoutPath = (path: string) => {
|
||||||
|
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||||
|
if (path.startsWith("/project/")) return "/project/overview";
|
||||||
|
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
|
||||||
|
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||||
|
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
||||||
|
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
||||||
|
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||||
|
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||||
|
return "/startup/meeting-auth";
|
||||||
|
}
|
||||||
|
if (path.startsWith("/subjects")) return "/subjects";
|
||||||
|
if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae";
|
||||||
|
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
|
||||||
|
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
|
||||||
|
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
|
||||||
|
if (path.startsWith("/etmf")) return "/etmf";
|
||||||
|
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
|
||||||
|
if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions";
|
||||||
|
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||||
|
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
||||||
|
if (path.startsWith("/projects/")) return "/admin/projects";
|
||||||
|
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
||||||
|
if (path.startsWith("/admin/system-monitoring") || path.startsWith("/admin/permission-monitoring")) return "/admin/system-monitoring";
|
||||||
|
if (path.startsWith("/admin/permissions/")) return path;
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildAdminNavigationItems = (options: {
|
||||||
|
hasUser: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
|
canAccessAdminPermissions: boolean;
|
||||||
|
}): LayoutNavigationItem[] => {
|
||||||
|
if (!options.hasUser) return [];
|
||||||
|
|
||||||
|
const items: LayoutNavigationItem[] = [];
|
||||||
|
if (options.isAdmin) {
|
||||||
|
items.push({
|
||||||
|
label: TEXT.menu.accountManagement,
|
||||||
|
path: "/admin/users",
|
||||||
|
group: TEXT.menu.admin,
|
||||||
|
icon: "users",
|
||||||
|
keywords: ["user", "account"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
label: TEXT.menu.projectManagement,
|
||||||
|
path: "/admin/projects",
|
||||||
|
group: TEXT.menu.admin,
|
||||||
|
icon: "project",
|
||||||
|
keywords: ["project"],
|
||||||
|
});
|
||||||
|
if (options.isAdmin) {
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
label: TEXT.menu.auditLogs,
|
||||||
|
path: "/admin/audit-logs",
|
||||||
|
group: TEXT.menu.admin,
|
||||||
|
icon: "audit",
|
||||||
|
keywords: ["audit"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.systemMonitoring,
|
||||||
|
path: "/admin/system-monitoring",
|
||||||
|
group: TEXT.menu.admin,
|
||||||
|
icon: "monitor",
|
||||||
|
keywords: ["monitoring"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "邮件服务",
|
||||||
|
path: "/admin/email-settings",
|
||||||
|
group: TEXT.menu.admin,
|
||||||
|
icon: "settings",
|
||||||
|
keywords: ["email"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.canAccessAdminPermissions) {
|
||||||
|
items.push({
|
||||||
|
label: TEXT.menu.permissionManagement,
|
||||||
|
path: "/admin/permissions/system",
|
||||||
|
group: TEXT.menu.admin,
|
||||||
|
icon: "key",
|
||||||
|
keywords: ["permission"],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "系统级权限",
|
||||||
|
path: "/admin/permissions/system",
|
||||||
|
group: TEXT.menu.permissionManagement,
|
||||||
|
icon: "key",
|
||||||
|
keywords: ["permission", "system"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "项目权限配置",
|
||||||
|
path: "/admin/permissions/project",
|
||||||
|
group: TEXT.menu.permissionManagement,
|
||||||
|
icon: "key",
|
||||||
|
keywords: ["permission", "project"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildProjectNavigationItems = (options: {
|
||||||
|
hasCurrentStudy: boolean;
|
||||||
|
hasAnyProjectModuleAccess: boolean;
|
||||||
|
canAccessProjectPath: (path: string) => boolean;
|
||||||
|
}): LayoutNavigationItem[] => {
|
||||||
|
if (!options.hasCurrentStudy || !options.hasAnyProjectModuleAccess) return [];
|
||||||
|
|
||||||
|
const items: LayoutNavigationItem[] = [
|
||||||
|
{
|
||||||
|
label: TEXT.menu.projectOverview,
|
||||||
|
path: "/project/overview",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "dashboard",
|
||||||
|
keywords: ["overview"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.projectMilestones,
|
||||||
|
path: "/project/milestones",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "calendar",
|
||||||
|
keywords: ["milestone"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.feeContracts,
|
||||||
|
path: "/fees/contracts",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "coin",
|
||||||
|
keywords: ["fee", "contract"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.materialManagement,
|
||||||
|
path: "/drug/shipments",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "folder",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: TEXT.menu.drugShipments,
|
||||||
|
path: "/drug/shipments",
|
||||||
|
group: TEXT.menu.materialManagement,
|
||||||
|
icon: "folder",
|
||||||
|
keywords: ["drug", "shipment"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.materialEquipment,
|
||||||
|
path: "/materials/equipment",
|
||||||
|
group: TEXT.menu.materialManagement,
|
||||||
|
icon: "folder",
|
||||||
|
keywords: ["material", "equipment"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.fileVersionManagement,
|
||||||
|
path: "/file-versions",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "document",
|
||||||
|
keywords: ["document", "file", "version"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.startupFeasibilityEthics,
|
||||||
|
path: "/startup/feasibility-ethics",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "calendar",
|
||||||
|
keywords: ["startup", "ethics"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.startupMeetingAuth,
|
||||||
|
path: "/startup/meeting-auth",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "check",
|
||||||
|
keywords: ["meeting", "training"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.subjects,
|
||||||
|
path: "/subjects",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "subject",
|
||||||
|
keywords: ["subject"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.riskIssues,
|
||||||
|
path: "/risk-issues/sae",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "flag",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: TEXT.menu.riskIssueSae,
|
||||||
|
path: "/risk-issues/sae",
|
||||||
|
group: TEXT.menu.riskIssues,
|
||||||
|
icon: "flag",
|
||||||
|
keywords: ["sae", "risk"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.riskIssuePd,
|
||||||
|
path: "/risk-issues/pd",
|
||||||
|
group: TEXT.menu.riskIssues,
|
||||||
|
icon: "flag",
|
||||||
|
keywords: ["pd", "risk"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.riskIssueMonitoringVisits,
|
||||||
|
path: "/risk-issues/monitoring-visits",
|
||||||
|
group: TEXT.menu.riskIssues,
|
||||||
|
icon: "flag",
|
||||||
|
keywords: ["monitoring", "visit"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.etmf,
|
||||||
|
path: "/etmf",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "folder",
|
||||||
|
keywords: ["etmf"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.sharedLibrary,
|
||||||
|
path: "/knowledge/medical-consult",
|
||||||
|
group: TEXT.menu.currentProject,
|
||||||
|
icon: "notebook",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: TEXT.menu.knowledgeMedicalConsult,
|
||||||
|
path: "/knowledge/medical-consult",
|
||||||
|
group: TEXT.menu.sharedLibrary,
|
||||||
|
icon: "notebook",
|
||||||
|
keywords: ["knowledge", "medical"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.knowledgeNotes,
|
||||||
|
path: "/knowledge/precautions",
|
||||||
|
group: TEXT.menu.sharedLibrary,
|
||||||
|
icon: "notebook",
|
||||||
|
keywords: ["knowledge", "note"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.knowledgeSupportFiles,
|
||||||
|
path: "/knowledge/support-files",
|
||||||
|
group: TEXT.menu.sharedLibrary,
|
||||||
|
icon: "notebook",
|
||||||
|
keywords: ["knowledge", "support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: TEXT.menu.knowledgeInstructionFiles,
|
||||||
|
path: "/knowledge/instruction-files",
|
||||||
|
group: TEXT.menu.sharedLibrary,
|
||||||
|
icon: "notebook",
|
||||||
|
keywords: ["knowledge", "instruction"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if (!item.children) return options.canAccessProjectPath(item.path) ? item : null;
|
||||||
|
const children = item.children.filter((child) => options.canAccessProjectPath(child.path));
|
||||||
|
if (!children.length) return null;
|
||||||
|
return { ...item, path: children[0].path, children };
|
||||||
|
})
|
||||||
|
.filter(Boolean) as LayoutNavigationItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
|
||||||
|
items.flatMap((item) => [item, ...(item.children || [])]);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export const DESKTOP_REFRESH_CURRENT_VIEW_EVENT = "ctms:desktop-refresh-current-view";
|
||||||
|
|
||||||
|
export interface DesktopRefreshEventDetail {
|
||||||
|
handled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dispatchDesktopRefreshCurrentView = (): boolean => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const detail: DesktopRefreshEventDetail = { handled: false };
|
||||||
|
window.dispatchEvent(new CustomEvent<DesktopRefreshEventDetail>(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, { detail }));
|
||||||
|
return detail.handled;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const onDesktopRefreshCurrentView = (handler: () => void | Promise<void>): (() => void) => {
|
||||||
|
if (typeof window === "undefined") return () => {};
|
||||||
|
const listener = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<DesktopRefreshEventDetail>).detail;
|
||||||
|
if (detail) detail.handled = true;
|
||||||
|
void handler();
|
||||||
|
};
|
||||||
|
window.addEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener);
|
||||||
|
return () => window.removeEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isEditableShortcutTarget } from "./useDesktopShortcuts";
|
||||||
|
|
||||||
|
describe("desktop shortcut target detection", () => {
|
||||||
|
it("does not hijack form controls", () => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
const editable = document.createElement("div");
|
||||||
|
editable.setAttribute("contenteditable", "true");
|
||||||
|
|
||||||
|
expect(isEditableShortcutTarget(input)).toBe(true);
|
||||||
|
expect(isEditableShortcutTarget(textarea)).toBe(true);
|
||||||
|
expect(isEditableShortcutTarget(editable)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows shortcuts outside editable areas", () => {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
|
||||||
|
expect(isEditableShortcutTarget(button)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { onBeforeUnmount, onMounted } from "vue";
|
||||||
|
|
||||||
|
export interface DesktopShortcutHandlers {
|
||||||
|
openCommandPalette: () => void;
|
||||||
|
closeActiveLayer?: () => boolean;
|
||||||
|
navigateBack?: () => void;
|
||||||
|
navigateForward?: () => void;
|
||||||
|
refreshCurrentView?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isEditableShortcutTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
const tag = target.tagName.toLowerCase();
|
||||||
|
if (["input", "textarea", "select"].includes(tag)) return true;
|
||||||
|
if ((target as HTMLElement).isContentEditable) return true;
|
||||||
|
if (target.getAttribute("contenteditable") === "true") return true;
|
||||||
|
return Boolean(target.closest('[contenteditable="true"], .el-input, .el-textarea, .el-select'));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDesktopShortcuts = (enabled: () => boolean, handlers: DesktopShortcutHandlers) => {
|
||||||
|
const onKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (!enabled() || isEditableShortcutTarget(event.target)) return;
|
||||||
|
const modifier = event.metaKey || event.ctrlKey;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
|
||||||
|
if (!modifier && key === "escape") {
|
||||||
|
if (handlers.closeActiveLayer?.()) event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modifier) return;
|
||||||
|
|
||||||
|
if (key === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
handlers.openCommandPalette();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "[") {
|
||||||
|
event.preventDefault();
|
||||||
|
handlers.navigateBack?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "]") {
|
||||||
|
event.preventDefault();
|
||||||
|
handlers.navigateForward?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "r") {
|
||||||
|
event.preventDefault();
|
||||||
|
handlers.refreshCurrentView?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||||
|
|
||||||
|
return { onKeydown };
|
||||||
|
};
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import packageInfo from "../../package.json";
|
import packageInfo from "../../package.json";
|
||||||
import { clientRuntime } from "./clientRuntime";
|
import { clientRuntime } from "./clientRuntime";
|
||||||
import { getAppMetadata } from "./appMetadata";
|
import { getAppMetadata } from "./appMetadata";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||||
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("client runtime", () => {
|
describe("client runtime", () => {
|
||||||
@@ -32,4 +33,24 @@ describe("client runtime", () => {
|
|||||||
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
|
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
|
||||||
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
|
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts release build metadata only from the expected CI values", () => {
|
||||||
|
vi.stubEnv("VITE_BUILD_CHANNEL", "release");
|
||||||
|
vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567");
|
||||||
|
|
||||||
|
expect(getAppMetadata()).toMatchObject({
|
||||||
|
channel: "release",
|
||||||
|
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose arbitrary build metadata strings", () => {
|
||||||
|
vi.stubEnv("VITE_BUILD_CHANNEL", "feature/demo");
|
||||||
|
vi.stubEnv("VITE_BUILD_COMMIT", "token=secret");
|
||||||
|
|
||||||
|
expect(getAppMetadata()).toMatchObject({
|
||||||
|
channel: "local",
|
||||||
|
commit: "local",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,13 +13,17 @@ export interface AppMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BUILD_CHANNELS = new Set<BuildChannel>(["dev", "main", "release", "local"]);
|
const BUILD_CHANNELS = new Set<BuildChannel>(["dev", "main", "release", "local"]);
|
||||||
|
const BUILD_COMMIT_PATTERN = /^[0-9a-f]{7,40}$/i;
|
||||||
|
|
||||||
const resolveBuildChannel = (value: string | undefined): BuildChannel =>
|
const resolveBuildChannel = (value: string | undefined): BuildChannel =>
|
||||||
value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local";
|
value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local";
|
||||||
|
|
||||||
|
const resolveBuildCommit = (value: string | undefined): string =>
|
||||||
|
value && BUILD_COMMIT_PATTERN.test(value) ? value : "local";
|
||||||
|
|
||||||
export const getAppMetadata = (): AppMetadata => ({
|
export const getAppMetadata = (): AppMetadata => ({
|
||||||
version: packageInfo.version,
|
version: packageInfo.version,
|
||||||
commit: import.meta.env.VITE_BUILD_COMMIT || "local",
|
commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT),
|
||||||
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
|
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
|
||||||
clientType: isTauriRuntime() ? "desktop" : "web",
|
clientType: isTauriRuntime() ? "desktop" : "web",
|
||||||
platform: getRuntimePlatform(),
|
platform: getRuntimePlatform(),
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { isTauriRuntime } from "./platform";
|
||||||
|
|
||||||
|
export const DESKTOP_MENU_COMMAND_EVENT = "ctms:desktop-menu-command";
|
||||||
|
|
||||||
|
export type DesktopMenuCommand =
|
||||||
|
| "ctms.desktop.commandPalette"
|
||||||
|
| "ctms.desktop.preferences"
|
||||||
|
| "ctms.desktop.serverSettings"
|
||||||
|
| "ctms.desktop.refresh"
|
||||||
|
| "ctms.desktop.back"
|
||||||
|
| "ctms.desktop.forward";
|
||||||
|
|
||||||
|
type Unlisten = () => void;
|
||||||
|
|
||||||
|
export const listenDesktopMenuCommand = async (
|
||||||
|
handler: (command: DesktopMenuCommand | string) => void,
|
||||||
|
): Promise<Unlisten> => {
|
||||||
|
if (!isTauriRuntime()) return () => {};
|
||||||
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
|
return listen<string>(DESKTOP_MENU_COMMAND_EVENT, (event) => {
|
||||||
|
if (typeof event.payload === "string") handler(event.payload);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -9,6 +9,24 @@ export type DesktopServerUrlValidationResult =
|
|||||||
|
|
||||||
const LOCAL_HTTP_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
const LOCAL_HTTP_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||||
|
|
||||||
|
const getStorage = (): Storage | null => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
return window.localStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitServerUrlChanged = (previous: string | null, current: string | null): void => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
|
||||||
|
detail: { previous, current },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||||
const raw = value.trim();
|
const raw = value.trim();
|
||||||
if (!raw) return { ok: false, reason: "请输入服务器地址" };
|
if (!raw) return { ok: false, reason: "请输入服务器地址" };
|
||||||
@@ -34,7 +52,7 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getDesktopServerUrl = (): string | null => {
|
export const getDesktopServerUrl = (): string | null => {
|
||||||
const stored = window.localStorage.getItem(DESKTOP_SERVER_URL_KEY);
|
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
|
||||||
if (!stored) return null;
|
if (!stored) return null;
|
||||||
const result = normalizeDesktopServerUrl(stored);
|
const result = normalizeDesktopServerUrl(stored);
|
||||||
return result.ok ? result.url : null;
|
return result.ok ? result.url : null;
|
||||||
@@ -45,24 +63,18 @@ export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl())
|
|||||||
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||||
const result = normalizeDesktopServerUrl(value);
|
const result = normalizeDesktopServerUrl(value);
|
||||||
if (!result.ok) return result;
|
if (!result.ok) return result;
|
||||||
|
const storage = getStorage();
|
||||||
|
if (!storage) return { ok: false, reason: "当前环境无法保存服务器地址" };
|
||||||
const previous = getDesktopServerUrl();
|
const previous = getDesktopServerUrl();
|
||||||
window.localStorage.setItem(DESKTOP_SERVER_URL_KEY, result.url);
|
storage.setItem(DESKTOP_SERVER_URL_KEY, result.url);
|
||||||
window.dispatchEvent(
|
emitServerUrlChanged(previous, result.url);
|
||||||
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
|
|
||||||
detail: { previous, current: result.url },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearDesktopServerUrl = (): void => {
|
export const clearDesktopServerUrl = (): void => {
|
||||||
const previous = getDesktopServerUrl();
|
const previous = getDesktopServerUrl();
|
||||||
window.localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
|
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
|
||||||
window.dispatchEvent(
|
emitServerUrlChanged(previous, null);
|
||||||
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
|
|
||||||
detail: { previous, current: null },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||||
|
DESKTOP_RECENT_ROUTES_KEY,
|
||||||
|
DESKTOP_THEME_KEY,
|
||||||
|
applyDesktopThemePreference,
|
||||||
|
readDesktopFavoriteRoutes,
|
||||||
|
readDesktopRecentRoutes,
|
||||||
|
readDesktopThemePreference,
|
||||||
|
recordDesktopRecentRoute,
|
||||||
|
setDesktopThemePreference,
|
||||||
|
toggleDesktopFavoriteRoute,
|
||||||
|
} from "./desktopUiPreferences";
|
||||||
|
|
||||||
|
const installLocalStorageMock = () => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
Object.defineProperty(window, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
getItem: (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => store.set(key, value),
|
||||||
|
removeItem: (key: string) => store.delete(key),
|
||||||
|
clear: () => store.clear(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("desktop UI preferences", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
installLocalStorageMock();
|
||||||
|
document.documentElement.removeAttribute("data-ctms-theme");
|
||||||
|
document.documentElement.style.colorScheme = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores only route metadata for recent desktop routes", () => {
|
||||||
|
recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
|
||||||
|
|
||||||
|
expect(readDesktopRecentRoutes()).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
path: "/subjects",
|
||||||
|
title: "受试者",
|
||||||
|
group: "当前项目",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(window.localStorage.getItem(DESKTOP_RECENT_ROUTES_KEY)).not.toContain("token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates favorites by path", () => {
|
||||||
|
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" });
|
||||||
|
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" });
|
||||||
|
toggleDesktopFavoriteRoute({ path: "/file-versions", title: "文件版本" });
|
||||||
|
|
||||||
|
expect(readDesktopFavoriteRoutes()).toHaveLength(1);
|
||||||
|
expect(readDesktopFavoriteRoutes()[0].path).toBe("/file-versions");
|
||||||
|
expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores and applies only the desktop theme enum", () => {
|
||||||
|
expect(readDesktopThemePreference()).toBe("light");
|
||||||
|
|
||||||
|
setDesktopThemePreference("dark");
|
||||||
|
|
||||||
|
expect(readDesktopThemePreference()).toBe("dark");
|
||||||
|
expect(window.localStorage.getItem(DESKTOP_THEME_KEY)).toBe("dark");
|
||||||
|
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("dark");
|
||||||
|
expect(document.documentElement.style.colorScheme).toBe("dark");
|
||||||
|
|
||||||
|
applyDesktopThemePreference("light");
|
||||||
|
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes";
|
||||||
|
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
|
||||||
|
export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
|
||||||
|
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
|
||||||
|
|
||||||
|
export type DesktopThemePreference = "light" | "dark";
|
||||||
|
|
||||||
|
export interface DesktopRoutePreference {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
group?: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RECENT_ROUTES = 8;
|
||||||
|
const MAX_FAVORITE_ROUTES = 12;
|
||||||
|
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
|
||||||
|
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme";
|
||||||
|
|
||||||
|
const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||||
|
|
||||||
|
const sanitizeRoutePreference = (value: Partial<DesktopRoutePreference> | null | undefined): DesktopRoutePreference | null => {
|
||||||
|
const path = typeof value?.path === "string" ? value.path.trim() : "";
|
||||||
|
const title = typeof value?.title === "string" ? value.title.trim() : "";
|
||||||
|
if (!path.startsWith("/") || !title) return null;
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
title: title.slice(0, 80),
|
||||||
|
group: typeof value?.group === "string" ? value.group.slice(0, 40) : undefined,
|
||||||
|
updatedAt: typeof value?.updatedAt === "string" ? value.updatedAt : new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const readRoutePreferences = (key: string): DesktopRoutePreference[] => {
|
||||||
|
if (!isStorageAvailable()) return [];
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.map((item) => sanitizeRoutePreference(item))
|
||||||
|
.filter(Boolean) as DesktopRoutePreference[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) => {
|
||||||
|
if (!isStorageAvailable()) return;
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(routes));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeDesktopThemePreference = (value: unknown): DesktopThemePreference =>
|
||||||
|
value === "dark" ? "dark" : DEFAULT_DESKTOP_THEME;
|
||||||
|
|
||||||
|
export const readDesktopThemePreference = (): DesktopThemePreference => {
|
||||||
|
if (!isStorageAvailable()) return DEFAULT_DESKTOP_THEME;
|
||||||
|
return sanitizeDesktopThemePreference(window.localStorage.getItem(DESKTOP_THEME_KEY));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyDesktopThemePreference = (theme: DesktopThemePreference = readDesktopThemePreference()): DesktopThemePreference => {
|
||||||
|
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, nextTheme);
|
||||||
|
document.documentElement.style.colorScheme = nextTheme;
|
||||||
|
}
|
||||||
|
return nextTheme;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setDesktopThemePreference = (theme: DesktopThemePreference): DesktopThemePreference => {
|
||||||
|
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||||
|
if (isStorageAvailable()) {
|
||||||
|
window.localStorage.setItem(DESKTOP_THEME_KEY, nextTheme);
|
||||||
|
}
|
||||||
|
applyDesktopThemePreference(nextTheme);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent(DESKTOP_THEME_CHANGED_EVENT, { detail: nextTheme }));
|
||||||
|
}
|
||||||
|
return nextTheme;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
|
||||||
|
|
||||||
|
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
|
||||||
|
|
||||||
|
export const recordDesktopRecentRoute = (route: Pick<DesktopRoutePreference, "path" | "title" | "group">): DesktopRoutePreference[] => {
|
||||||
|
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
|
||||||
|
if (!item) return readDesktopRecentRoutes();
|
||||||
|
const next = [
|
||||||
|
item,
|
||||||
|
...readDesktopRecentRoutes().filter((existing) => existing.path !== item.path),
|
||||||
|
].slice(0, MAX_RECENT_ROUTES);
|
||||||
|
writeRoutePreferences(DESKTOP_RECENT_ROUTES_KEY, next);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isDesktopFavoriteRoute = (path: string): boolean =>
|
||||||
|
readDesktopFavoriteRoutes().some((item) => item.path === path);
|
||||||
|
|
||||||
|
export const toggleDesktopFavoriteRoute = (
|
||||||
|
route: Pick<DesktopRoutePreference, "path" | "title" | "group">,
|
||||||
|
): DesktopRoutePreference[] => {
|
||||||
|
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
|
||||||
|
if (!item) return readDesktopFavoriteRoutes();
|
||||||
|
const current = readDesktopFavoriteRoutes();
|
||||||
|
const exists = current.some((existing) => existing.path === item.path);
|
||||||
|
const next = exists
|
||||||
|
? current.filter((existing) => existing.path !== item.path)
|
||||||
|
: [item, ...current].slice(0, MAX_FAVORITE_ROUTES);
|
||||||
|
writeRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY, next);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
@@ -8,6 +8,27 @@ export {
|
|||||||
setDesktopServerUrl,
|
setDesktopServerUrl,
|
||||||
shouldRequireDesktopServerUrl,
|
shouldRequireDesktopServerUrl,
|
||||||
} from "./desktopServerConfig";
|
} from "./desktopServerConfig";
|
||||||
|
export {
|
||||||
|
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||||
|
DESKTOP_RECENT_ROUTES_KEY,
|
||||||
|
DESKTOP_THEME_CHANGED_EVENT,
|
||||||
|
DESKTOP_THEME_KEY,
|
||||||
|
applyDesktopThemePreference,
|
||||||
|
isDesktopFavoriteRoute,
|
||||||
|
readDesktopFavoriteRoutes,
|
||||||
|
readDesktopRecentRoutes,
|
||||||
|
readDesktopThemePreference,
|
||||||
|
recordDesktopRecentRoute,
|
||||||
|
setDesktopThemePreference,
|
||||||
|
toggleDesktopFavoriteRoute,
|
||||||
|
type DesktopRoutePreference,
|
||||||
|
type DesktopThemePreference,
|
||||||
|
} from "./desktopUiPreferences";
|
||||||
|
export {
|
||||||
|
DESKTOP_MENU_COMMAND_EVENT,
|
||||||
|
listenDesktopMenuCommand,
|
||||||
|
type DesktopMenuCommand,
|
||||||
|
} from "./desktopMenu";
|
||||||
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
||||||
export {
|
export {
|
||||||
cleanupTemporaryFiles,
|
cleanupTemporaryFiles,
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ let checkTimer: number | null = null;
|
|||||||
let intervalTimer: number | null = null;
|
let intervalTimer: number | null = null;
|
||||||
let promptVisible = false;
|
let promptVisible = false;
|
||||||
|
|
||||||
|
export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed";
|
||||||
|
|
||||||
|
export interface DesktopUpdateCheckOptions {
|
||||||
|
notifyWhenCurrent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
|
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
|
||||||
|
|
||||||
const getPostponeUntil = (version: string): number => {
|
const getPostponeUntil = (version: string): number => {
|
||||||
@@ -46,8 +52,8 @@ const releaseNotes = (update: DesktopUpdateInfo): string => {
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptForUpdate = async (update: DesktopUpdateInfo) => {
|
const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdateCheckStatus> => {
|
||||||
if (promptVisible || isSuppressed(update.version)) return;
|
if (promptVisible || isSuppressed(update.version)) return "suppressed";
|
||||||
promptVisible = true;
|
promptVisible = true;
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", {
|
await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", {
|
||||||
@@ -57,26 +63,37 @@ const promptForUpdate = async (update: DesktopUpdateInfo) => {
|
|||||||
type: "info",
|
type: "info",
|
||||||
});
|
});
|
||||||
await installPendingDesktopUpdate();
|
await installPendingDesktopUpdate();
|
||||||
|
return "available";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error === "cancel" || error === "close") {
|
if (error === "cancel" || error === "close") {
|
||||||
postponeVersion(update.version);
|
postponeVersion(update.version);
|
||||||
return;
|
return "postponed";
|
||||||
}
|
}
|
||||||
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
|
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
|
||||||
|
return "failed";
|
||||||
} finally {
|
} finally {
|
||||||
promptVisible = false;
|
promptVisible = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkDesktopUpdateAndPrompt = async () => {
|
export const checkDesktopUpdateAndPrompt = async (
|
||||||
if (!isDesktopUpdaterAvailable()) return;
|
options: DesktopUpdateCheckOptions = {},
|
||||||
|
): Promise<DesktopUpdateCheckStatus> => {
|
||||||
|
if (!isDesktopUpdaterAvailable()) {
|
||||||
|
if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新");
|
||||||
|
return "disabled";
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const update = await checkForDesktopUpdate();
|
const update = await checkForDesktopUpdate();
|
||||||
if (update) {
|
if (update) {
|
||||||
await promptForUpdate(update);
|
return promptForUpdate(update);
|
||||||
}
|
}
|
||||||
|
if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本");
|
||||||
|
return "up-to-date";
|
||||||
} catch {
|
} catch {
|
||||||
// 启动和定时检查不打断录入;下一轮继续检查。
|
// 启动和定时检查不打断录入;下一轮继续检查。
|
||||||
|
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
|
||||||
|
return "failed";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,54 @@
|
|||||||
--el-text-color-regular: var(--ctms-text-regular);
|
--el-text-color-regular: var(--ctms-text-regular);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] {
|
||||||
|
--ctms-primary: #8fb7d4;
|
||||||
|
--ctms-primary-hover: #a7c9df;
|
||||||
|
--ctms-primary-active: #c3dced;
|
||||||
|
--ctms-primary-light: #203145;
|
||||||
|
--ctms-brand-900: #d8e7f4;
|
||||||
|
--ctms-brand-700: #b4d0e5;
|
||||||
|
|
||||||
|
--ctms-success: #7dd3a8;
|
||||||
|
--ctms-warning: #f1c271;
|
||||||
|
--ctms-danger: #f28b8b;
|
||||||
|
--ctms-info: #a6b4c5;
|
||||||
|
|
||||||
|
--ctms-text-main: #f8fafc;
|
||||||
|
--ctms-text-regular: #dbe5f1;
|
||||||
|
--ctms-text-secondary: #9aaabd;
|
||||||
|
--ctms-text-disabled: #66788d;
|
||||||
|
|
||||||
|
--ctms-bg-base: #0f172a;
|
||||||
|
--ctms-bg-card: #172033;
|
||||||
|
--ctms-bg-muted: #111827;
|
||||||
|
--ctms-neutral-100: #182337;
|
||||||
|
--ctms-neutral-300: #334155;
|
||||||
|
--ctms-border-color: #26364a;
|
||||||
|
--ctms-border-color-hover: #3d5068;
|
||||||
|
|
||||||
|
--ctms-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||||
|
--ctms-shadow: 0 8px 20px rgba(0, 0, 0, 0.32);
|
||||||
|
--ctms-shadow-md: 0 12px 26px rgba(0, 0, 0, 0.36);
|
||||||
|
--ctms-shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.42);
|
||||||
|
|
||||||
|
--el-color-primary: var(--ctms-primary);
|
||||||
|
--el-color-primary-light-3: #567894;
|
||||||
|
--el-color-primary-light-5: #3c5871;
|
||||||
|
--el-color-primary-light-7: #283c50;
|
||||||
|
--el-color-primary-light-8: #203145;
|
||||||
|
--el-color-primary-dark-2: #c3dced;
|
||||||
|
--el-border-color: var(--ctms-border-color);
|
||||||
|
--el-text-color-primary: var(--ctms-text-main);
|
||||||
|
--el-text-color-regular: var(--ctms-text-regular);
|
||||||
|
--el-bg-color: var(--ctms-bg-card);
|
||||||
|
--el-bg-color-overlay: #172033;
|
||||||
|
--el-fill-color-blank: #172033;
|
||||||
|
--el-fill-color-light: #111827;
|
||||||
|
--el-fill-color-lighter: #0f172a;
|
||||||
|
--el-mask-color: rgba(0, 0, 0, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
/* 全局重置 */
|
/* 全局重置 */
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
@@ -85,6 +133,90 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] body {
|
||||||
|
background: var(--ctms-bg-base);
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-button--default {
|
||||||
|
background-color: #172033;
|
||||||
|
border-color: #334155;
|
||||||
|
color: var(--ctms-text-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-button--default:hover {
|
||||||
|
border-color: #4b6580;
|
||||||
|
background-color: #1f2d3d;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-card,
|
||||||
|
:root[data-ctms-theme="dark"] .el-alert,
|
||||||
|
:root[data-ctms-theme="dark"] .el-popper,
|
||||||
|
:root[data-ctms-theme="dark"] .el-dropdown-menu,
|
||||||
|
:root[data-ctms-theme="dark"] .el-popover.el-popper,
|
||||||
|
:root[data-ctms-theme="dark"] .el-dialog {
|
||||||
|
border-color: var(--ctms-border-color);
|
||||||
|
background-color: var(--ctms-bg-card);
|
||||||
|
color: var(--ctms-text-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-card__header {
|
||||||
|
border-bottom-color: var(--ctms-border-color);
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-input__wrapper,
|
||||||
|
:root[data-ctms-theme="dark"] .el-select .el-input__wrapper {
|
||||||
|
background-color: #172033;
|
||||||
|
box-shadow: 0 0 0 1px #334155 inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-input__inner {
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-input__inner::placeholder {
|
||||||
|
color: var(--ctms-text-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-table {
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--ctms-text-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-table th.el-table__cell {
|
||||||
|
background-color: #111827;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-table tr,
|
||||||
|
:root[data-ctms-theme="dark"] .el-table td.el-table__cell {
|
||||||
|
background-color: #172033;
|
||||||
|
border-bottom-color: var(--ctms-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-table--enable-row-hover .el-table__row:hover>td.el-table__cell {
|
||||||
|
background-color: #1f2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-dropdown-menu__item {
|
||||||
|
color: var(--ctms-text-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):focus,
|
||||||
|
:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):hover {
|
||||||
|
background-color: #1f2d3d;
|
||||||
|
color: var(--ctms-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-ctms-theme="dark"] .ctms-state,
|
||||||
|
:root[data-ctms-theme="dark"] .ctms-table-card,
|
||||||
|
:root[data-ctms-theme="dark"] .ctms-section-card {
|
||||||
|
border-color: var(--ctms-border-color);
|
||||||
|
background: var(--ctms-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
/* Element Plus 全局覆盖 */
|
/* Element Plus 全局覆盖 */
|
||||||
|
|
||||||
/* 按钮美化 */
|
/* 按钮美化 */
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getVisibleDesktopCommands, type DesktopCommand } from "./desktopCommands";
|
||||||
|
|
||||||
|
const commands: DesktopCommand[] = [
|
||||||
|
{
|
||||||
|
id: "visible",
|
||||||
|
title: "受试者",
|
||||||
|
group: "当前项目",
|
||||||
|
keywords: ["subject"],
|
||||||
|
visible: true,
|
||||||
|
run: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hidden",
|
||||||
|
title: "系统监控",
|
||||||
|
group: "管理后台",
|
||||||
|
visible: false,
|
||||||
|
run: () => {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("desktop command filtering", () => {
|
||||||
|
it("only returns visible commands", () => {
|
||||||
|
expect(getVisibleDesktopCommands(commands, "").map((item) => item.id)).toEqual(["visible"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches title, group, and keywords", () => {
|
||||||
|
expect(getVisibleDesktopCommands(commands, "subject").map((item) => item.id)).toEqual(["visible"]);
|
||||||
|
expect(getVisibleDesktopCommands(commands, "当前").map((item) => item.id)).toEqual(["visible"]);
|
||||||
|
expect(getVisibleDesktopCommands(commands, "missing")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface DesktopCommand {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
group: string;
|
||||||
|
keywords?: string[];
|
||||||
|
shortcut?: string;
|
||||||
|
visible: boolean;
|
||||||
|
run: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVisibleDesktopCommands = (commands: DesktopCommand[], query: string): DesktopCommand[] => {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
return commands.filter((command) => {
|
||||||
|
if (!command.visible) return false;
|
||||||
|
if (!normalized) return true;
|
||||||
|
const haystack = [command.title, command.group, command.shortcut, ...(command.keywords || [])]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
return haystack.includes(normalized);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
<template>
|
||||||
|
<div class="desktop-preferences">
|
||||||
|
<header class="preferences-header">
|
||||||
|
<div>
|
||||||
|
<p class="preferences-kicker">CTMS Desktop</p>
|
||||||
|
<h3>系统偏好</h3>
|
||||||
|
</div>
|
||||||
|
<el-button text :icon="Close" aria-label="关闭系统偏好" @click="emit('close-request')" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="preference-section">
|
||||||
|
<div class="section-title">连接</div>
|
||||||
|
<div class="server-card">
|
||||||
|
<span class="server-label">当前服务器</span>
|
||||||
|
<code>{{ desktopServerUrl || "未配置" }}</code>
|
||||||
|
<el-button size="small" @click="openServerSettings">服务器设置</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="preference-section">
|
||||||
|
<div class="section-title">外观</div>
|
||||||
|
<div class="preference-row">
|
||||||
|
<div>
|
||||||
|
<div class="row-title">界面主题</div>
|
||||||
|
<div class="row-desc">切换桌面工作台与系统弹窗的明暗配色</div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-segmented" role="group" aria-label="界面主题">
|
||||||
|
<button
|
||||||
|
v-for="option in themeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="theme-option"
|
||||||
|
:class="{ active: desktopTheme === option.value }"
|
||||||
|
@click="setTheme(option.value)"
|
||||||
|
>
|
||||||
|
<el-icon><component :is="option.icon" /></el-icon>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="preference-section">
|
||||||
|
<div class="section-title">通知与更新</div>
|
||||||
|
<div class="preference-row">
|
||||||
|
<div>
|
||||||
|
<div class="row-title">系统通知</div>
|
||||||
|
<div class="row-desc">只显示不含项目详情的文件更新提示</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-control">
|
||||||
|
<el-switch
|
||||||
|
v-model="desktopNotificationsEnabled"
|
||||||
|
:loading="desktopNotificationLoading"
|
||||||
|
@change="onDesktopNotificationChange"
|
||||||
|
/>
|
||||||
|
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preference-row">
|
||||||
|
<div>
|
||||||
|
<div class="row-title">桌面更新</div>
|
||||||
|
<div class="row-desc">正式版本按当前服务器发布源检查签名更新</div>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" :disabled="!desktopUpdaterAvailable" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
|
||||||
|
检查更新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="preference-section">
|
||||||
|
<div class="section-title">诊断信息</div>
|
||||||
|
<div class="metadata-panel">
|
||||||
|
<dl>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Close, Moon, Sunny } from "@element-plus/icons-vue";
|
||||||
|
import {
|
||||||
|
getDesktopNotificationSubscription,
|
||||||
|
setDesktopNotificationSubscription,
|
||||||
|
} from "../api/desktopNotifications";
|
||||||
|
import {
|
||||||
|
clientRuntime,
|
||||||
|
getAppMetadata,
|
||||||
|
getDesktopServerUrl,
|
||||||
|
getNotificationPermission,
|
||||||
|
isDesktopUpdaterAvailable,
|
||||||
|
readDesktopThemePreference,
|
||||||
|
requestNotificationPermission,
|
||||||
|
setDesktopThemePreference,
|
||||||
|
type DesktopThemePreference,
|
||||||
|
type NotificationPermissionState,
|
||||||
|
} from "../runtime";
|
||||||
|
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||||
|
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"close-request": [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||||
|
const clientMetadata = getAppMetadata();
|
||||||
|
const desktopCapabilities = clientRuntime.capabilities();
|
||||||
|
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
||||||
|
const desktopNotificationsEnabled = ref(false);
|
||||||
|
const desktopNotificationLoading = ref(false);
|
||||||
|
const desktopUpdateChecking = ref(false);
|
||||||
|
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
|
||||||
|
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
||||||
|
const themeOptions = [
|
||||||
|
{ value: "light" as const, label: "明亮", icon: Sunny },
|
||||||
|
{ value: "dark" as const, label: "暗黑", icon: Moon },
|
||||||
|
];
|
||||||
|
|
||||||
|
const clientMetadataRows = computed(() => [
|
||||||
|
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
|
||||||
|
{ label: "平台", value: clientMetadata.platform },
|
||||||
|
{ label: "构建通道", value: clientMetadata.channel },
|
||||||
|
{ label: "提交", value: clientMetadata.commit },
|
||||||
|
{ label: "主题", value: desktopTheme.value === "dark" ? "暗黑" : "明亮" },
|
||||||
|
{ label: "服务器", value: desktopServerUrl.value || "未配置" },
|
||||||
|
{
|
||||||
|
label: "能力",
|
||||||
|
value: [
|
||||||
|
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
|
||||||
|
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
|
||||||
|
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
|
||||||
|
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
|
||||||
|
].join(" / "),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const notificationPermissionText = computed(() => {
|
||||||
|
if (notificationPermission.value === "granted") return "已授权";
|
||||||
|
if (notificationPermission.value === "denied") return "已拒绝";
|
||||||
|
if (notificationPermission.value === "prompt") return "待授权";
|
||||||
|
return "不可用";
|
||||||
|
});
|
||||||
|
|
||||||
|
const notificationPermissionTagType = computed(() => {
|
||||||
|
if (notificationPermission.value === "granted") return "success";
|
||||||
|
if (notificationPermission.value === "denied") return "danger";
|
||||||
|
if (notificationPermission.value === "prompt") return "warning";
|
||||||
|
return "info";
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadNotificationState = async () => {
|
||||||
|
notificationPermission.value = await getNotificationPermission();
|
||||||
|
try {
|
||||||
|
const { data } = await getDesktopNotificationSubscription();
|
||||||
|
desktopNotificationsEnabled.value = data.enabled;
|
||||||
|
} catch {
|
||||||
|
desktopNotificationsEnabled.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
||||||
|
if (desktopNotificationLoading.value) return;
|
||||||
|
desktopNotificationLoading.value = true;
|
||||||
|
try {
|
||||||
|
if (value) {
|
||||||
|
notificationPermission.value = await requestNotificationPermission();
|
||||||
|
if (notificationPermission.value !== "granted") {
|
||||||
|
desktopNotificationsEnabled.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { data } = await setDesktopNotificationSubscription(Boolean(value));
|
||||||
|
desktopNotificationsEnabled.value = data.enabled;
|
||||||
|
triggerDesktopNotificationPoll();
|
||||||
|
ElMessage.success(data.enabled ? "系统通知已开启" : "系统通知已关闭");
|
||||||
|
} catch (error: any) {
|
||||||
|
desktopNotificationsEnabled.value = !Boolean(value);
|
||||||
|
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
|
||||||
|
} finally {
|
||||||
|
desktopNotificationLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDesktopUpdateNow = async () => {
|
||||||
|
if (desktopUpdateChecking.value) return;
|
||||||
|
desktopUpdateChecking.value = true;
|
||||||
|
try {
|
||||||
|
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||||
|
} finally {
|
||||||
|
desktopUpdateChecking.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (theme: DesktopThemePreference) => {
|
||||||
|
desktopTheme.value = setDesktopThemePreference(theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyClientMetadata = async () => {
|
||||||
|
const text = clientMetadataRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
|
||||||
|
await navigator.clipboard?.writeText(text);
|
||||||
|
ElMessage.success("诊断信息已复制");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openServerSettings = () => {
|
||||||
|
emit("close-request");
|
||||||
|
router.push("/desktop/server-settings");
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadNotificationState();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.desktop-preferences {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preferences-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preferences-kicker {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preference-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card,
|
||||||
|
.preference-row,
|
||||||
|
.metadata-panel {
|
||||||
|
border: 1px solid #dbe6f2;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-label,
|
||||||
|
.row-desc {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #0f172a;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preference-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-segmented {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(76px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #dbe6f2;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option {
|
||||||
|
appearance: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 76px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option.active {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #24496f;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dl {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) {
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .desktop-preferences {
|
||||||
|
color: #e5edf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .preferences-kicker {
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) h3,
|
||||||
|
:global([data-ctms-theme="dark"]) .row-title,
|
||||||
|
:global([data-ctms-theme="dark"]) dd,
|
||||||
|
:global([data-ctms-theme="dark"]) code {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .section-title,
|
||||||
|
:global([data-ctms-theme="dark"]) .server-label,
|
||||||
|
:global([data-ctms-theme="dark"]) .row-desc,
|
||||||
|
:global([data-ctms-theme="dark"]) dt {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .server-card,
|
||||||
|
:global([data-ctms-theme="dark"]) .preference-row,
|
||||||
|
:global([data-ctms-theme="dark"]) .metadata-panel {
|
||||||
|
border-color: #26364a;
|
||||||
|
background: #172033;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .theme-segmented {
|
||||||
|
border-color: #26364a;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .theme-option {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .theme-option:hover {
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-ctms-theme="dark"]) .theme-option.active {
|
||||||
|
background: #243247;
|
||||||
|
color: #bfdbfe;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,6 +7,21 @@
|
|||||||
<p class="description">配置桌面客户端要连接的 CTMS 服务端入口。业务数据仍由服务端统一保存和裁决。</p>
|
<p class="description">配置桌面客户端要连接的 CTMS 服务端入口。业务数据仍由服务端统一保存和裁决。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentServerUrl" class="current-server">
|
||||||
|
<span>当前服务器</span>
|
||||||
|
<code>{{ currentServerUrl }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="connectionStatus"
|
||||||
|
class="connection-alert"
|
||||||
|
:type="connectionStatus.type"
|
||||||
|
:title="connectionStatus.title"
|
||||||
|
:description="connectionStatus.message"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
<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
|
||||||
@@ -24,7 +39,9 @@
|
|||||||
|
|
||||||
<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" @click="save">保存并检查连接</el-button>
|
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
|
||||||
|
保存并检查连接
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</section>
|
</section>
|
||||||
@@ -47,16 +64,33 @@ const currentServerUrl = getDesktopServerUrl();
|
|||||||
const serverUrl = ref(currentServerUrl || "");
|
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 canCancel = computed(() => Boolean(currentServerUrl));
|
const canCancel = computed(() => Boolean(currentServerUrl));
|
||||||
|
const HEALTH_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
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 response = await fetch(healthUrl, {
|
const controller = new AbortController();
|
||||||
method: "GET",
|
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
||||||
headers: { Accept: "application/json" },
|
try {
|
||||||
});
|
const response = await fetch(healthUrl, {
|
||||||
if (!response.ok) {
|
method: "GET",
|
||||||
throw new Error(`HTTP ${response.status}`);
|
headers: { Accept: "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") {
|
||||||
|
throw new Error("连接超时,请确认服务端地址和网络状态");
|
||||||
|
}
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -67,6 +101,7 @@ const clearSessionForServerChange = async () => {
|
|||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
urlError.value = "";
|
urlError.value = "";
|
||||||
|
connectionStatus.value = null;
|
||||||
const normalized = normalizeDesktopServerUrl(serverUrl.value);
|
const normalized = normalizeDesktopServerUrl(serverUrl.value);
|
||||||
if (!normalized.ok) {
|
if (!normalized.ok) {
|
||||||
urlError.value = normalized.reason;
|
urlError.value = normalized.reason;
|
||||||
@@ -85,10 +120,21 @@ const save = async () => {
|
|||||||
if (previous !== result.url) {
|
if (previous !== result.url) {
|
||||||
await clearSessionForServerChange();
|
await clearSessionForServerChange();
|
||||||
}
|
}
|
||||||
|
connectionStatus.value = {
|
||||||
|
type: "success",
|
||||||
|
title: "连接已确认",
|
||||||
|
message: result.url,
|
||||||
|
};
|
||||||
ElMessage.success("服务器连接已确认");
|
ElMessage.success("服务器连接已确认");
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
} catch {
|
} catch (error) {
|
||||||
urlError.value = "无法连接服务器的 /health,请确认地址和网络后重试";
|
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
|
||||||
|
connectionStatus.value = {
|
||||||
|
type: "error",
|
||||||
|
title: "连接检查失败",
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
urlError.value = message;
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
@@ -145,6 +191,30 @@ h1 {
|
|||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current-server {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #dbe6f2;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-server code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #1e293b;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-alert {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin-top: -8px;
|
margin-top: -8px;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
|
|||||||
@@ -95,4 +95,16 @@ describe("Login protocol agreement", () => {
|
|||||||
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
||||||
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", () => {
|
||||||
|
const source = readLoginView();
|
||||||
|
|
||||||
|
expect(source).toContain('fetchEmailDomains()');
|
||||||
|
expect(source).toContain(':disabled="availableEmailDomains.length === 0"');
|
||||||
|
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
|
||||||
|
expect(source).toContain("configuredEmailDomains.value.includes(domain)");
|
||||||
|
expect(source).not.toContain("preservedEmailDomain");
|
||||||
|
expect(source).not.toContain("showDomainSelect");
|
||||||
|
expect(source).not.toContain("email-domain-input");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,6 +100,12 @@
|
|||||||
<span class="tab-item active">账号登录</span>
|
<span class="tab-item active">账号登录</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showDesktopServerSettings" class="desktop-server-status">
|
||||||
|
<span class="desktop-server-label">服务器</span>
|
||||||
|
<code>{{ desktopServerUrl || "未配置" }}</code>
|
||||||
|
<RouterLink to="/desktop/server-settings" class="desktop-server-action">设置</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 退出通知 -->
|
<!-- 退出通知 -->
|
||||||
<div v-if="logoutNotice" class="logout-notice" :class="'logout-notice--' + logoutNotice.type">
|
<div v-if="logoutNotice" class="logout-notice" :class="'logout-notice--' + logoutNotice.type">
|
||||||
<div class="ln-icon">
|
<div class="ln-icon">
|
||||||
@@ -153,22 +159,14 @@
|
|||||||
<span class="email-at-sign">@</span>
|
<span class="email-at-sign">@</span>
|
||||||
<div class="email-domain-wrapper">
|
<div class="email-domain-wrapper">
|
||||||
<select
|
<select
|
||||||
v-if="showDomainSelect"
|
|
||||||
v-model="form.emailDomain"
|
v-model="form.emailDomain"
|
||||||
class="email-domain-field"
|
class="email-domain-field"
|
||||||
aria-label="邮箱域名"
|
aria-label="邮箱域名"
|
||||||
|
:disabled="availableEmailDomains.length === 0"
|
||||||
>
|
>
|
||||||
<option v-for="domain in availableEmailDomains" :key="domain" :value="domain">{{ domain }}</option>
|
<option v-for="domain in availableEmailDomains" :key="domain" :value="domain">{{ domain }}</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<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>
|
||||||
v-else
|
|
||||||
v-model.trim="form.emailDomain"
|
|
||||||
type="text"
|
|
||||||
class="email-domain-field email-domain-input"
|
|
||||||
placeholder="邮箱域名"
|
|
||||||
aria-label="邮箱域名"
|
|
||||||
/>
|
|
||||||
<svg v-if="showDomainSelect" 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>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -195,12 +193,9 @@
|
|||||||
<span>登 录</span>
|
<span>登 录</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
<!-- 注册与忘记密码(左右分立) -->
|
<!-- 忘记密码与注册 -->
|
||||||
<div class="forgot-register-row">
|
<div class="forgot-register-row">
|
||||||
<RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink>
|
<RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink>
|
||||||
<RouterLink v-if="showDesktopServerSettings" to="/desktop/server-settings" class="server-settings-link">
|
|
||||||
服务器设置
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/register" class="register-link">新用户注册</RouterLink>
|
<RouterLink to="/register" class="register-link">新用户注册</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
@@ -249,7 +244,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref, onMounted, watch } from "vue";
|
import { computed, reactive, ref, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||||
// 从 package.json 读取版本号,构建时由 Vite 注入
|
// 从 package.json 读取版本号,构建时由 Vite 注入
|
||||||
@@ -260,7 +255,7 @@ 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 { isTauriRuntime } from "../runtime";
|
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime";
|
||||||
import {
|
import {
|
||||||
consumeLogoutReason,
|
consumeLogoutReason,
|
||||||
LOGOUT_REASON_AUTH_EXPIRED,
|
LOGOUT_REASON_AUTH_EXPIRED,
|
||||||
@@ -276,7 +271,6 @@ 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: "", agreeProtocol: false });
|
||||||
const configuredEmailDomains = ref<string[]>([]);
|
const configuredEmailDomains = ref<string[]>([]);
|
||||||
const preservedEmailDomain = ref("");
|
|
||||||
|
|
||||||
const rules: FormRules<typeof form> = {
|
const rules: FormRules<typeof form> = {
|
||||||
email: [
|
email: [
|
||||||
@@ -292,15 +286,14 @@ const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: str
|
|||||||
const loginError = ref<{ title: string; message?: string } | null>(null);
|
const loginError = ref<{ title: string; message?: string } | null>(null);
|
||||||
const protocolSections = authProtocolSections;
|
const protocolSections = authProtocolSections;
|
||||||
const showDesktopServerSettings = isTauriRuntime();
|
const showDesktopServerSettings = isTauriRuntime();
|
||||||
|
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||||
|
|
||||||
|
const refreshDesktopServerUrl = () => {
|
||||||
|
desktopServerUrl.value = getDesktopServerUrl();
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
||||||
const availableEmailDomains = computed(() => Array.from(new Set([
|
const availableEmailDomains = computed(() => configuredEmailDomains.value);
|
||||||
...configuredEmailDomains.value,
|
|
||||||
preservedEmailDomain.value,
|
|
||||||
].filter(Boolean))));
|
|
||||||
const showDomainSelect = computed(
|
|
||||||
() => configuredEmailDomains.value.length > 0 || Boolean(preservedEmailDomain.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const syncEmailFromParts = () => {
|
const syncEmailFromParts = () => {
|
||||||
const local = form.emailLocal.trim().toLowerCase();
|
const local = form.emailLocal.trim().toLowerCase();
|
||||||
@@ -314,14 +307,13 @@ const applyEmailValue = (email: string) => {
|
|||||||
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);
|
||||||
form.emailDomain = normalizeDomain(normalized.slice(separator + 1));
|
const domain = normalizeDomain(normalized.slice(separator + 1));
|
||||||
preservedEmailDomain.value = configuredEmailDomains.value.includes(form.emailDomain)
|
form.emailDomain = configuredEmailDomains.value.includes(domain)
|
||||||
? ""
|
? domain
|
||||||
: form.emailDomain;
|
: configuredEmailDomains.value[0] || "";
|
||||||
} else {
|
} else {
|
||||||
form.emailLocal = normalized;
|
form.emailLocal = normalized;
|
||||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||||
preservedEmailDomain.value = "";
|
|
||||||
}
|
}
|
||||||
syncEmailFromParts();
|
syncEmailFromParts();
|
||||||
};
|
};
|
||||||
@@ -332,8 +324,12 @@ const loadEmailDomains = async () => {
|
|||||||
configuredEmailDomains.value = Array.from(new Set(
|
configuredEmailDomains.value = Array.from(new Set(
|
||||||
data.items.map(normalizeDomain).filter(Boolean)
|
data.items.map(normalizeDomain).filter(Boolean)
|
||||||
));
|
));
|
||||||
|
if (!configuredEmailDomains.value.includes(form.emailDomain)) {
|
||||||
|
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
configuredEmailDomains.value = [];
|
configuredEmailDomains.value = [];
|
||||||
|
form.emailDomain = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -345,6 +341,7 @@ const handleAccountPaste = (event: ClipboardEvent) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||||
await loadEmailDomains();
|
await loadEmailDomains();
|
||||||
const reason = consumeLogoutReason();
|
const reason = consumeLogoutReason();
|
||||||
if (reason === LOGOUT_REASON_TIMEOUT) {
|
if (reason === LOGOUT_REASON_TIMEOUT) {
|
||||||
@@ -358,6 +355,10 @@ onMounted(async () => {
|
|||||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||||
|
});
|
||||||
|
|
||||||
watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
|
watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
|
||||||
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
|
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
|
||||||
|
|
||||||
@@ -786,6 +787,42 @@ const onSubmit = async () => {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-server-status {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin: -18px 0 24px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #dbe7f5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-server-label {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-server-status code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: #1e293b;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-server-action {
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-server-action:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════
|
/* ═══════════════════════
|
||||||
通知与错误提示
|
通知与错误提示
|
||||||
═══════════════════════ */
|
═══════════════════════ */
|
||||||
@@ -928,8 +965,7 @@ const onSubmit = async () => {
|
|||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-local-field::placeholder,
|
.email-local-field::placeholder {
|
||||||
.email-domain-input::placeholder {
|
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -961,10 +997,9 @@ const onSubmit = async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-domain-input {
|
.email-domain-field:disabled {
|
||||||
width: 150px;
|
color: #94a3b8;
|
||||||
padding-right: 4px;
|
cursor: not-allowed;
|
||||||
cursor: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-domain-chevron {
|
.email-domain-chevron {
|
||||||
@@ -1049,7 +1084,7 @@ const onSubmit = async () => {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgot-link, .register-link, .server-settings-link {
|
.forgot-link, .register-link {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -1057,7 +1092,7 @@ const onSubmit = async () => {
|
|||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgot-link:hover, .register-link:hover, .server-settings-link:hover {
|
.forgot-link:hover, .register-link:hover {
|
||||||
color: #1d4ed8;
|
color: #1d4ed8;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,16 +70,34 @@
|
|||||||
<h4>客户端与通知</h4>
|
<h4>客户端与通知</h4>
|
||||||
</div>
|
</div>
|
||||||
<el-form-item v-if="isDesktop" label="系统通知">
|
<el-form-item v-if="isDesktop" label="系统通知">
|
||||||
<el-switch
|
<div class="desktop-setting-stack">
|
||||||
v-model="desktopNotificationsEnabled"
|
<div class="desktop-setting-row">
|
||||||
:loading="desktopNotificationLoading"
|
<el-switch
|
||||||
@change="onDesktopNotificationChange"
|
v-model="desktopNotificationsEnabled"
|
||||||
/>
|
:loading="desktopNotificationLoading"
|
||||||
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
|
@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>
|
||||||
<el-form-item label="客户端信息">
|
<el-form-item label="客户端信息">
|
||||||
<div class="client-metadata">
|
<div class="client-metadata-panel">
|
||||||
<code>{{ clientMetadataText }}</code>
|
<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>
|
<el-button size="small" @click="copyClientMetadata">复制</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -106,12 +124,18 @@ import {
|
|||||||
} from "../api/desktopNotifications";
|
} from "../api/desktopNotifications";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import {
|
import {
|
||||||
|
clientRuntime,
|
||||||
getAppMetadata,
|
getAppMetadata,
|
||||||
|
getDesktopServerUrl,
|
||||||
|
getNotificationPermission,
|
||||||
|
isDesktopUpdaterAvailable,
|
||||||
isTauriRuntime,
|
isTauriRuntime,
|
||||||
pickFiles,
|
pickFiles,
|
||||||
requestNotificationPermission,
|
requestNotificationPermission,
|
||||||
|
type NotificationPermissionState,
|
||||||
} from "../runtime";
|
} from "../runtime";
|
||||||
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
||||||
|
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||||
import { TEXT, requiredMessage } from "../locales";
|
import { TEXT, requiredMessage } from "../locales";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -122,14 +146,29 @@ const emit = defineEmits<{
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const isDesktop = isTauriRuntime();
|
const isDesktop = isTauriRuntime();
|
||||||
const clientMetadata = getAppMetadata();
|
const clientMetadata = getAppMetadata();
|
||||||
const clientMetadataText = [
|
const desktopCapabilities = clientRuntime.capabilities();
|
||||||
`${clientMetadata.clientType} ${clientMetadata.version}`,
|
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
||||||
clientMetadata.platform,
|
const clientMetadataRows = [
|
||||||
clientMetadata.channel,
|
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
|
||||||
clientMetadata.commit,
|
{ label: "平台", value: clientMetadata.platform },
|
||||||
].join(" · ");
|
{ 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 desktopNotificationsEnabled = ref(false);
|
||||||
const desktopNotificationLoading = 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({
|
||||||
@@ -210,10 +249,26 @@ const loadProfile = async () => {
|
|||||||
|
|
||||||
const loadDesktopNotificationSubscription = async () => {
|
const loadDesktopNotificationSubscription = async () => {
|
||||||
if (!isDesktop) return;
|
if (!isDesktop) return;
|
||||||
|
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
|
||||||
const { data } = await getDesktopNotificationSubscription();
|
const { data } = await getDesktopNotificationSubscription();
|
||||||
desktopNotificationsEnabled.value = data.enabled;
|
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) => {
|
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
||||||
if (!isDesktop || desktopNotificationLoading.value) return;
|
if (!isDesktop || desktopNotificationLoading.value) return;
|
||||||
desktopNotificationLoading.value = true;
|
desktopNotificationLoading.value = true;
|
||||||
@@ -221,6 +276,7 @@ const onDesktopNotificationChange = async (value: string | number | boolean) =>
|
|||||||
const enable = Boolean(value);
|
const enable = Boolean(value);
|
||||||
if (enable) {
|
if (enable) {
|
||||||
const permission = await requestNotificationPermission();
|
const permission = await requestNotificationPermission();
|
||||||
|
notificationPermission.value = permission;
|
||||||
if (permission !== "granted") {
|
if (permission !== "granted") {
|
||||||
desktopNotificationsEnabled.value = false;
|
desktopNotificationsEnabled.value = false;
|
||||||
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
||||||
@@ -239,10 +295,24 @@ const onDesktopNotificationChange = async (value: string | number | boolean) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyClientMetadata = async () => {
|
const copyClientMetadata = async () => {
|
||||||
|
if (!navigator.clipboard?.writeText) {
|
||||||
|
ElMessage.warning("当前环境无法访问剪贴板");
|
||||||
|
return;
|
||||||
|
}
|
||||||
await navigator.clipboard.writeText(clientMetadataText);
|
await navigator.clipboard.writeText(clientMetadataText);
|
||||||
ElMessage.success("客户端信息已复制");
|
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) => {
|
||||||
@@ -419,25 +489,53 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
|||||||
border-top: 1px solid #e5ebf2;
|
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 {
|
.desktop-setting-hint {
|
||||||
margin-left: 12px;
|
|
||||||
color: #7f92ad;
|
color: #7f92ad;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-metadata {
|
.client-metadata-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-metadata code {
|
.client-metadata-list {
|
||||||
overflow-wrap: anywhere;
|
display: grid;
|
||||||
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
|
gap: 6px 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
color: #40566f;
|
color: #40566f;
|
||||||
font-size: 12px;
|
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;
|
||||||
|
|||||||
@@ -449,12 +449,14 @@ const loadEmailDomains = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await fetchEmailDomains();
|
const { data } = await fetchEmailDomains();
|
||||||
emailDomains.value = normalizeDomains(data.items);
|
emailDomains.value = normalizeDomains(data.items);
|
||||||
if (!form.emailDomain && emailDomains.value.length > 0) {
|
if (!emailDomains.value.includes(form.emailDomain)) {
|
||||||
form.emailDomain = emailDomains.value[0];
|
form.emailDomain = emailDomains.value[0] || "";
|
||||||
syncEmailFromParts();
|
|
||||||
}
|
}
|
||||||
|
syncEmailFromParts();
|
||||||
} catch {
|
} catch {
|
||||||
emailDomains.value = [];
|
emailDomains.value = [];
|
||||||
|
form.emailDomain = "";
|
||||||
|
syncEmailFromParts();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { resolve } from "node:path";
|
|||||||
|
|
||||||
const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "utf8");
|
const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "utf8");
|
||||||
const readRouter = () => readFileSync(resolve(__dirname, "../../router/index.ts"), "utf8");
|
const readRouter = () => readFileSync(resolve(__dirname, "../../router/index.ts"), "utf8");
|
||||||
const readLayout = () => readFileSync(resolve(__dirname, "../../components/Layout.vue"), "utf8");
|
const readLayout = () => [
|
||||||
|
"../../components/WebLayout.vue",
|
||||||
|
"../../components/DesktopLayout.vue",
|
||||||
|
"../../components/layout/navigation.ts",
|
||||||
|
].map(path => readFileSync(resolve(__dirname, path), "utf8")).join("\n");
|
||||||
|
|
||||||
describe("project management access", () => {
|
describe("project management access", () => {
|
||||||
it("shows project management to all signed-in users while keeping system operations admin-only", () => {
|
it("shows project management to all signed-in users while keeping system operations admin-only", () => {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
{{ TEXT.common.labels.preview }}
|
{{ TEXT.common.labels.preview }}
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
|
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
|
||||||
{{ TEXT.modules.fileVersionManagement.actions.download }}
|
另存为
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="primary" size="small" @click="openVersion(row)" v-if="canReadDocument">
|
<el-button link type="primary" size="small" @click="openVersion(row)" v-if="canReadDocument">
|
||||||
打开
|
打开
|
||||||
@@ -264,7 +264,13 @@
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
上传文件
|
上传文件
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-zone" :class="{ 'has-file': uploadFile }" @click="triggerFileInput">
|
<div
|
||||||
|
class="upload-zone"
|
||||||
|
:class="{ 'has-file': uploadFile }"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="handleUploadDrop"
|
||||||
|
>
|
||||||
<template v-if="!uploadFile">
|
<template v-if="!uploadFile">
|
||||||
<div class="upload-zone-icon">
|
<div class="upload-zone-icon">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
@@ -344,7 +350,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||||
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
||||||
@@ -373,6 +379,7 @@ 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 { openFile, pickFiles, saveFile } from "../../runtime";
|
||||||
|
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -380,6 +387,7 @@ const study = useStudyStore();
|
|||||||
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
const documentId = computed(() => route.params.id as string);
|
const documentId = computed(() => route.params.id as string);
|
||||||
|
let desktopRefreshCleanup: (() => void) | undefined;
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
@@ -523,6 +531,10 @@ const triggerFileInput = async () => {
|
|||||||
});
|
});
|
||||||
if (file) uploadFile.value = file;
|
if (file) uploadFile.value = file;
|
||||||
};
|
};
|
||||||
|
const handleUploadDrop = (event: DragEvent) => {
|
||||||
|
const [file] = Array.from(event.dataTransfer?.files || []);
|
||||||
|
if (file) uploadFile.value = file;
|
||||||
|
};
|
||||||
const removeFile = () => { uploadFile.value = null; };
|
const removeFile = () => { uploadFile.value = null; };
|
||||||
|
|
||||||
const distributeVisible = ref(false);
|
const distributeVisible = ref(false);
|
||||||
@@ -887,9 +899,14 @@ watch(previewVisible, (visible) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
desktopRefreshCleanup = onDesktopRefreshCurrentView(loadDetail);
|
||||||
await loadRoleTemplates();
|
await loadRoleTemplates();
|
||||||
await loadDetail();
|
await loadDetail();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
desktopRefreshCleanup?.();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref, computed, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||||
import { Plus } from "@element-plus/icons-vue";
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
@@ -178,6 +178,7 @@ import type { DocumentSummary } from "../../types/documents";
|
|||||||
import type { Site } from "../../types/api";
|
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";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -191,6 +192,7 @@ 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>();
|
||||||
|
let desktopRefreshCleanup: (() => void) | undefined;
|
||||||
|
|
||||||
const trialId = computed(() => (route.params.trialId as string) || "");
|
const trialId = computed(() => (route.params.trialId as string) || "");
|
||||||
|
|
||||||
@@ -436,6 +438,14 @@ onMounted(() => {
|
|||||||
ensureTrialRoute();
|
ensureTrialRoute();
|
||||||
load();
|
load();
|
||||||
loadSites();
|
loadSites();
|
||||||
|
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
|
||||||
|
load();
|
||||||
|
loadSites();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
desktopRefreshCleanup?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -27,3 +27,15 @@ describe("SubjectManagement drawer editor", () => {
|
|||||||
expect(source).not.toContain('router.push("/subjects/new")');
|
expect(source).not.toContain('router.push("/subjects/new")');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("SubjectManagement desktop list workflow", () => {
|
||||||
|
it("selects rows for preview and opens details on explicit desktop actions", () => {
|
||||||
|
const source = readSubjectManagementSource();
|
||||||
|
|
||||||
|
expect(source).toContain("@row-click=\"selectSubject\"");
|
||||||
|
expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
|
||||||
|
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
|
||||||
|
expect(source).toContain("subject-preview-pane");
|
||||||
|
expect(source).toContain("subject-context-menu");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
</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 }}
|
||||||
@@ -31,77 +32,140 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table
|
<div class="subject-workbench">
|
||||||
:data="pagedItems"
|
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
|
||||||
v-loading="loading"
|
<el-table
|
||||||
style="width: 100%"
|
:data="pagedItems"
|
||||||
class="subject-table"
|
v-loading="loading"
|
||||||
:row-class-name="subjectRowClass"
|
style="width: 100%"
|
||||||
@row-click="onRowClick"
|
class="subject-table"
|
||||||
table-layout="fixed"
|
:row-class-name="subjectRowClass"
|
||||||
>
|
highlight-current-row
|
||||||
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
|
@selection-change="onSelectionChange"
|
||||||
<template #default="scope">
|
@row-click="selectSubject"
|
||||||
<div class="subject-info-cell">
|
@row-dblclick="openSubjectDetail"
|
||||||
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
|
@row-contextmenu="openSubjectContextMenu"
|
||||||
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
|
table-layout="fixed"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="42" />
|
||||||
|
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="subject-info-cell">
|
||||||
|
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
|
||||||
|
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
|
||||||
|
{{ badge.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||||
|
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
|
||||||
|
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||||
|
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
|
||||||
|
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
|
||||||
|
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<div class="cell-actions">
|
||||||
|
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
|
||||||
|
{{ TEXT.common.actions.delete }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!loading" class="table-empty">
|
||||||
|
<div class="empty-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
|
</div>
|
||||||
|
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div v-if="filteredItems.length > 0" class="pagination-wrap">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="pagination.currentPage"
|
||||||
|
v-model:page-size="pagination.pageSize"
|
||||||
|
:page-sizes="[5, 10, 20]"
|
||||||
|
:total="filteredItems.length"
|
||||||
|
layout="prev, pager, next, sizes, total"
|
||||||
|
small
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="subject-preview-pane">
|
||||||
|
<template v-if="selectedSubject">
|
||||||
|
<div class="preview-head">
|
||||||
|
<div>
|
||||||
|
<div class="preview-kicker">当前参与者</div>
|
||||||
|
<div class="preview-title">{{ selectedSubject.subject_no || TEXT.common.fallback }}</div>
|
||||||
|
</div>
|
||||||
|
<el-button size="small" type="primary" @click="openSubjectDetail(selectedSubject)">打开详情</el-button>
|
||||||
|
</div>
|
||||||
|
<dl class="preview-list">
|
||||||
|
<dt>{{ TEXT.common.fields.site }}</dt>
|
||||||
|
<dd>{{ siteMap[selectedSubject.site_id] || TEXT.common.fallback }}</dd>
|
||||||
|
<dt>{{ TEXT.common.fields.status }}</dt>
|
||||||
|
<dd>{{ displaySubjectStatus(selectedSubject) }}</dd>
|
||||||
|
<dt>{{ TEXT.common.fields.consentDate }}</dt>
|
||||||
|
<dd>{{ displayDate(selectedSubject.consent_date) }}</dd>
|
||||||
|
<dt>{{ TEXT.common.fields.enrollmentDate }}</dt>
|
||||||
|
<dd>{{ displayDate(selectedSubject.enrollment_date) }}</dd>
|
||||||
|
<dt>{{ TEXT.common.fields.completionDate }}</dt>
|
||||||
|
<dd>{{ displayDate(selectedSubject.completion_date) }}</dd>
|
||||||
|
</dl>
|
||||||
|
<div v-if="getAeBadges(selectedSubject).length" class="preview-badges">
|
||||||
|
<span v-for="badge in getAeBadges(selectedSubject)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
|
||||||
{{ badge.label }}
|
{{ badge.label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
<div v-else class="preview-empty">
|
||||||
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
|
|
||||||
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
|
|
||||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
|
||||||
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
|
|
||||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
|
|
||||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
|
|
||||||
<template #default="scope">
|
|
||||||
<div class="cell-actions">
|
|
||||||
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
|
|
||||||
{{ TEXT.common.actions.delete }}
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<template #empty>
|
|
||||||
<div v-if="!loading" class="table-empty">
|
|
||||||
<div class="empty-icon">
|
<div class="empty-icon">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
|
<span>选择一行查看详情摘要</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</aside>
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<div v-if="filteredItems.length > 0" class="pagination-wrap">
|
|
||||||
<el-pagination
|
|
||||||
v-model:current-page="pagination.currentPage"
|
|
||||||
v-model:page-size="pagination.pageSize"
|
|
||||||
:page-sizes="[5, 10, 20]"
|
|
||||||
:total="filteredItems.length"
|
|
||||||
layout="prev, pager, next, sizes, total"
|
|
||||||
small
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="contextMenu.visible"
|
||||||
|
class="subject-context-menu"
|
||||||
|
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button type="button" @click="openSelectedSubject">打开详情</button>
|
||||||
|
<button type="button" @click="copySelectedSubjectNo">复制编号</button>
|
||||||
|
<button
|
||||||
|
v-if="canDeleteSubject"
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
|
||||||
|
@click="selectedSubject && remove(selectedSubject)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
|
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus } from "@element-plus/icons-vue";
|
import { Plus } from "@element-plus/icons-vue";
|
||||||
@@ -114,6 +178,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
|||||||
import { isSystemAdmin } from "../../utils/roles";
|
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";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -121,6 +186,9 @@ const study = useStudyStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const subjectDrawerVisible = ref(false);
|
const subjectDrawerVisible = ref(false);
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
|
const selectedSubjectId = ref("");
|
||||||
|
const selectedRows = ref<any[]>([]);
|
||||||
|
const contextMenu = ref({ visible: false, x: 0, y: 0 });
|
||||||
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
|
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
|
||||||
const siteMap = ref<Record<string, string>>({});
|
const siteMap = ref<Record<string, string>>({});
|
||||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||||
@@ -133,6 +201,7 @@ const pagination = ref({
|
|||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
pageSize: 20,
|
pageSize: 20,
|
||||||
});
|
});
|
||||||
|
let desktopRefreshCleanup: (() => void) | undefined;
|
||||||
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
|
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
filters.value.keyword = "";
|
filters.value.keyword = "";
|
||||||
@@ -185,8 +254,45 @@ const load = async () => {
|
|||||||
const goNew = () => { subjectDrawerVisible.value = true; };
|
const goNew = () => { 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 onRowClick = (row: any) => { if (!row?.id) return; goDetail(row.id); };
|
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
|
||||||
|
const selectSubject = (row: any) => {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
if (!row?.id) return;
|
||||||
|
selectedSubjectId.value = row.id;
|
||||||
|
};
|
||||||
|
const openSubjectDetail = (row: any) => {
|
||||||
|
if (!row?.id) return;
|
||||||
|
goDetail(row.id);
|
||||||
|
};
|
||||||
|
const openSelectedSubject = () => {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
|
||||||
|
};
|
||||||
|
const onSelectionChange = (rows: any[]) => {
|
||||||
|
selectedRows.value = rows;
|
||||||
|
};
|
||||||
|
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!row?.id) return;
|
||||||
|
selectedSubjectId.value = row.id;
|
||||||
|
contextMenu.value = {
|
||||||
|
visible: true,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const closeSubjectContextMenu = () => {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
};
|
||||||
|
const copySelectedSubjectNo = async () => {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
|
const subjectNo = selectedSubject.value?.subject_no;
|
||||||
|
if (!subjectNo) return;
|
||||||
|
await navigator.clipboard?.writeText(subjectNo);
|
||||||
|
ElMessage.success("编号已复制");
|
||||||
|
};
|
||||||
const remove = async (row: any) => {
|
const remove = async (row: any) => {
|
||||||
|
contextMenu.value.visible = false;
|
||||||
const studyId = study.currentStudy?.id;
|
const studyId = study.currentStudy?.id;
|
||||||
if (!studyId) return;
|
if (!studyId) return;
|
||||||
if (!canDeleteSubject.value) return;
|
if (!canDeleteSubject.value) return;
|
||||||
@@ -230,7 +336,11 @@ const pagedItems = computed(() => {
|
|||||||
return sortedItems.value.slice(start, end);
|
return sortedItems.value.slice(start, end);
|
||||||
});
|
});
|
||||||
const subjectRowClass = ({ row }: { row: any }) =>
|
const subjectRowClass = ({ row }: { row: any }) =>
|
||||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
[
|
||||||
|
row?.id ? "clickable-row" : "",
|
||||||
|
row?.id && row.id === selectedSubjectId.value ? "row-selected" : "",
|
||||||
|
isInactiveSite(row?.site_id) ? "row-inactive" : "",
|
||||||
|
].filter(Boolean).join(" ");
|
||||||
|
|
||||||
watch(() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], () => { pagination.value.currentPage = 1; });
|
watch(() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], () => { pagination.value.currentPage = 1; });
|
||||||
watch(() => filteredItems.value.length, (total) => {
|
watch(() => filteredItems.value.length, (total) => {
|
||||||
@@ -238,8 +348,30 @@ watch(() => filteredItems.value.length, (total) => {
|
|||||||
if (pagination.value.currentPage > maxPage) pagination.value.currentPage = maxPage;
|
if (pagination.value.currentPage > maxPage) pagination.value.currentPage = maxPage;
|
||||||
});
|
});
|
||||||
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
|
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
|
||||||
|
watch(pagedItems, (rows) => {
|
||||||
|
if (!rows.length) {
|
||||||
|
selectedSubjectId.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rows.some((item) => item.id === selectedSubjectId.value)) {
|
||||||
|
selectedSubjectId.value = rows[0].id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => { await loadSites(); load(); });
|
onMounted(async () => {
|
||||||
|
document.addEventListener("click", closeSubjectContextMenu);
|
||||||
|
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
|
||||||
|
loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
await loadSites();
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener("click", closeSubjectContextMenu);
|
||||||
|
desktopRefreshCleanup?.();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -256,6 +388,24 @@ onMounted(async () => { await loadSites(); load(); });
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subject-workbench {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 300px;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-table-pane {
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid #edf1f7;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-preview-pane {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fbfcff;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== Toolbar ==================== */
|
/* ==================== Toolbar ==================== */
|
||||||
.table-card-toolbar {
|
.table-card-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -281,6 +431,13 @@ onMounted(async () => { await loadSites(); load(); });
|
|||||||
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;
|
||||||
@@ -342,6 +499,10 @@ onMounted(async () => { await loadSites(); load(); });
|
|||||||
|
|
||||||
.subject-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
|
.subject-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
|
||||||
|
|
||||||
|
.subject-table :deep(.el-table__body tr.row-selected > td) {
|
||||||
|
background: #edf5ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== Cell Helpers ==================== */
|
/* ==================== Cell Helpers ==================== */
|
||||||
.cell-nowrap { white-space: nowrap; }
|
.cell-nowrap { white-space: nowrap; }
|
||||||
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; color: #0a0a0a; font-weight: 600; }
|
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; color: #0a0a0a; font-weight: 600; }
|
||||||
@@ -357,6 +518,106 @@ onMounted(async () => { await loadSites(); load(); });
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-kicker {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px minmax(0, 1fr);
|
||||||
|
gap: 10px 12px;
|
||||||
|
margin: 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-list dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
min-height: 360px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-context-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 3000;
|
||||||
|
min-width: 132px;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid #d7e2f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-context-menu button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-context-menu button:hover:not(:disabled) {
|
||||||
|
background: #eef4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-context-menu button.danger {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subject-context-menu button:disabled {
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.ae-badge {
|
.ae-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -419,6 +680,9 @@ onMounted(async () => { await loadSites(); load(); });
|
|||||||
|
|
||||||
/* ==================== Responsive ==================== */
|
/* ==================== Responsive ==================== */
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
|
.subject-workbench { grid-template-columns: 1fr; }
|
||||||
|
.subject-table-pane { border-right: 0; }
|
||||||
|
.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%; }
|
||||||
.table-card-toolbar { flex-direction: column; gap: 12px; align-items: stretch; }
|
.table-card-toolbar { flex-direction: column; gap: 12px; align-items: stretch; }
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { defineConfig } from "vite";
|
|||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET || "http://backend:8000";
|
|
||||||
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT || "");
|
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT || "");
|
||||||
|
const defaultDevApiProxyTarget = hmrClientPort ? "http://backend:8000" : "http://127.0.0.1:8000";
|
||||||
|
const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET || defaultDevApiProxyTarget;
|
||||||
const tauriDevHost = process.env.TAURI_DEV_HOST;
|
const tauriDevHost = process.env.TAURI_DEV_HOST;
|
||||||
const tauriPlatform = process.env.TAURI_ENV_PLATFORM;
|
const tauriPlatform = process.env.TAURI_ENV_PLATFORM;
|
||||||
const tauriDebug = process.env.TAURI_ENV_DEBUG === "true";
|
const tauriDebug = process.env.TAURI_ENV_DEBUG === "true";
|
||||||
|
|||||||
Reference in New Issue
Block a user