From c923f887a0e3d3c4ff635d61200169b68908f5b0 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 30 Jun 2026 22:26:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C=E4=B8=8E=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/client-quality-gates.yml | 24 +- ...desktop-release-stabilization-checklist.md | 91 ++++ docs/guides/branch-maintenance-sop-zh.md | 13 +- docs/guides/client-release.md | 26 + frontend/package.json | 2 + frontend/scripts/verify-desktop-release.mjs | 170 ++++++ frontend/scripts/verify-ui-contract.mjs | 77 ++- frontend/src-tauri/src/lib.rs | 124 ++++- .../src/components/DesktopCommandPalette.vue | 212 ++++++++ .../src/components/Layout.desktop.test.ts | 25 + frontend/src/components/Layout.vue | 483 +++++++++++++++++- frontend/src/composables/useDesktopRefresh.ts | 23 + .../composables/useDesktopShortcuts.test.ts | 21 + .../src/composables/useDesktopShortcuts.ts | 58 +++ frontend/src/runtime/desktopMenu.ts | 23 + frontend/src/runtime/desktopServerConfig.ts | 38 +- .../src/runtime/desktopUiPreferences.test.ts | 51 ++ frontend/src/runtime/desktopUiPreferences.ts | 78 +++ frontend/src/runtime/index.ts | 15 + frontend/src/session/desktopUpdateManager.ts | 29 +- frontend/src/types/desktopCommands.test.ts | 32 ++ frontend/src/types/desktopCommands.ts | 22 + frontend/src/views/DesktopPreferences.vue | 302 +++++++++++ frontend/src/views/DesktopServerSettings.vue | 88 +++- frontend/src/views/Login.vue | 56 +- frontend/src/views/ProfileSettings.vue | 136 ++++- .../src/views/documents/DocumentDetail.vue | 23 +- frontend/src/views/documents/DocumentList.vue | 12 +- .../src/views/ia/SubjectManagement.test.ts | 12 + frontend/src/views/ia/SubjectManagement.vue | 382 +++++++++++--- 30 files changed, 2507 insertions(+), 141 deletions(-) create mode 100644 docs/audits/desktop-release-stabilization-checklist.md create mode 100644 frontend/scripts/verify-desktop-release.mjs create mode 100644 frontend/src/components/DesktopCommandPalette.vue create mode 100644 frontend/src/components/Layout.desktop.test.ts create mode 100644 frontend/src/composables/useDesktopRefresh.ts create mode 100644 frontend/src/composables/useDesktopShortcuts.test.ts create mode 100644 frontend/src/composables/useDesktopShortcuts.ts create mode 100644 frontend/src/runtime/desktopMenu.ts create mode 100644 frontend/src/runtime/desktopUiPreferences.test.ts create mode 100644 frontend/src/runtime/desktopUiPreferences.ts create mode 100644 frontend/src/types/desktopCommands.test.ts create mode 100644 frontend/src/types/desktopCommands.ts create mode 100644 frontend/src/views/DesktopPreferences.vue diff --git a/.github/workflows/client-quality-gates.yml b/.github/workflows/client-quality-gates.yml index 3cd40474..5572ff92 100644 --- a/.github/workflows/client-quality-gates.yml +++ b/.github/workflows/client-quality-gates.yml @@ -4,9 +4,16 @@ on: pull_request: paths: - "frontend/**" + - "frontend/scripts/**" - ".github/workflows/client-quality-gates.yml" - "docs/branch-governance.md" + - "docs/audits/desktop-release-stabilization-checklist.md" - "docs/guides/client-release.md" + - "docs/guides/branch-maintenance-sop-zh.md" + - "docs/branch-governance.md" + - "docs/audits/desktop-release-stabilization-checklist.md" + - "docs/guides/client-release.md" + - "docs/guides/branch-maintenance-sop-zh.md" push: branches: - dev @@ -14,6 +21,7 @@ on: - release paths: - "frontend/**" + - "frontend/scripts/**" - ".github/workflows/client-quality-gates.yml" jobs: @@ -43,6 +51,9 @@ jobs: - name: Check runtime boundary run: npm run runtime:check + - name: Check desktop release and security gate + run: npm run desktop:release:check + - name: Type check run: npm run type-check @@ -78,8 +89,17 @@ jobs: - name: Install dependencies run: npm ci - - name: Build macOS application - run: npm run desktop:build -- --bundles app + - 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: Build unsigned macOS application smoke artifact + run: npm run desktop:build:app env: VITE_BUILD_CHANNEL: ${{ github.ref_name }} VITE_BUILD_COMMIT: ${{ github.sha }} diff --git a/docs/audits/desktop-release-stabilization-checklist.md b/docs/audits/desktop-release-stabilization-checklist.md new file mode 100644 index 00000000..28978175 --- /dev/null +++ b/docs/audits/desktop-release-stabilization-checklist.md @@ -0,0 +1,91 @@ +# CTMS Desktop Release Stabilization Checklist + +状态: `active` +适用范围: Web 与 macOS Desktop 统一客户端发布 +最后更新: `2026-06-30` + +本清单用于第一、二阶段桌面端能力完成后的准发布稳定化。它不引入离线登录、本地业务数据存储、内嵌后端服务或离线同步。 + +## 1. 发布链路门禁 + +发布候选提交必须从同一 Git 提交构建 Web 与 Desktop 制品,并完成以下检查: + +```bash +cd frontend +npm ci +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 +``` + +正式发布还必须确认: + +- [ ] `frontend/package.json`、`package-lock.json`、Tauri 配置、Cargo manifest/lock 版本一致。 +- [ ] `VITE_BUILD_CHANNEL=release` 和 `VITE_BUILD_COMMIT=` 由 CI 注入。 +- [ ] macOS app 已签名和公证。 +- [ ] updater `.sig` 使用组织 CI secret 或密钥库中的私钥生成,私钥未进入仓库。 +- [ ] 设置 `TAURI_SIGNING_PRIVATE_KEY` 和 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` 后执行 `npm run desktop:build -- --bundles app`。 +- [ ] 不可变制品先上传,`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` 处理。 + +人工复审还必须确认: + +- [ ] 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` 下不重叠、不溢出。 + +## 5. 不允许项 + +- [ ] 不实现离线登录、离线浏览、离线队列或离线同步。 +- [ ] 不在桌面端保存 CTMS 业务数据副本。 +- [ ] 不内嵌 FastAPI、PostgreSQL、SQLite 或本地业务 API 镜像。 +- [ ] 不绕过后端做本地权限裁决或本地审计回放。 diff --git a/docs/guides/branch-maintenance-sop-zh.md b/docs/guides/branch-maintenance-sop-zh.md index 94bc1c52..3376af1c 100644 --- a/docs/guides/branch-maintenance-sop-zh.md +++ b/docs/guides/branch-maintenance-sop-zh.md @@ -92,17 +92,23 @@ git merge origin/dev 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 ``` 涉及 Tauri、macOS 打包或桌面适配层时,还必须在 macOS 执行: ```bash -npm run desktop:build -- --bundles app +npm run desktop:build:app ``` +正式桌面发布构建仍必须设置 updater 签名私钥后执行 +`npm run desktop:build -- --bundles app`。 + 后端改动应补充执行受影响模块的后端测试、迁移检查和接口回归。 ### 4. 创建提交 @@ -453,10 +459,13 @@ git log --oneline --decorate --graph --all -30 - [ ] 网页端与桌面端版本一致 - [ ] `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` 通过 -- [ ] macOS 桌面端构建通过 +- [ ] `npm run desktop:build:app` 通过 +- [ ] 正式桌面发布构建已使用 updater 签名私钥执行 - [ ] 数据库迁移与回滚方案确认 - [ ] 发布说明完成 - [ ] `main -> release` 合并完成 diff --git a/docs/guides/client-release.md b/docs/guides/client-release.md index 26473095..01ef4959 100644 --- a/docs/guides/client-release.md +++ b/docs/guides/client-release.md @@ -56,6 +56,29 @@ This synchronizes: 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 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 +``` + +`desktop:release:check` statically verifies the Tauri bundle, updater public +key, CSP, capability scopes, command allowlist, query-token ban, 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 1. Merge feature branches into `dev`. @@ -127,9 +150,12 @@ cd frontend npm ci 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 export TAURI_SIGNING_PRIVATE_KEY="$UPDATER_PRIVATE_KEY" export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$UPDATER_PRIVATE_KEY_PASSWORD" npm run desktop:build -- --bundles app diff --git a/frontend/package.json b/frontend/package.json index ea431c73..c6de4f07 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,10 +10,12 @@ "tauri": "tauri", "desktop:dev": "tauri dev", "desktop:build": "tauri build", + "desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app", "desktop:bundle:dmg": "tauri build --bundles dmg", "version:check": "node scripts/client-version.mjs --check", "version:set": "node scripts/client-version.mjs --set", "runtime:check": "node scripts/verify-runtime-boundary.mjs", + "desktop:release:check": "node scripts/verify-desktop-release.mjs", "test:unit": "vitest run --environment jsdom", "type-check": "vue-tsc --noEmit", "ui:contract": "node scripts/verify-ui-contract.mjs" diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs new file mode 100644 index 00000000..a97584e9 --- /dev/null +++ b/frontend/scripts/verify-desktop-release.mjs @@ -0,0 +1,170 @@ +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 ?? ""}.`, + ); + } +}; + +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); + + 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."); +}; + +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(", ") || ""}.`); + assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || ""}.`); +}; + +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.`); + if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") { + fail(`${file}: ctms_token may only be handled by secureSessionStorage.`); + } + } +}; + +await verifyTauriConfig(); +await verifyCapabilities(); +await verifyRustBoundary(); +await verifySourceSafety(); + +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."); +} diff --git a/frontend/scripts/verify-ui-contract.mjs b/frontend/scripts/verify-ui-contract.mjs index 572c7ec4..913291ad 100644 --- a/frontend/scripts/verify-ui-contract.mjs +++ b/frontend/scripts/verify-ui-contract.mjs @@ -32,38 +32,71 @@ const missing = [ ]; const pageContractChecks = [ - "src/views/ia/ProjectMilestones.vue", - "src/views/ia/SubjectManagement.vue", - "src/views/ia/RiskIssueSae.vue", - "src/views/ia/RiskIssuePd.vue", - "src/views/ia/RiskIssueMonitoringVisits.vue" + { + file: "src/views/ia/ProjectMilestones.vue", + groups: [ + ["ctms-page-shell", "page"], + ["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 = [ - "ctms-page-shell", - "unified-action-bar", - "ctms-table-card" -]; - -for (const file of pageContractChecks) { +for (const { file, groups } of pageContractChecks) { const content = readFileSync(file, "utf8"); - for (const className of requiredPageClasses) { - if (!content.includes(className)) { - missing.push(`${file}:${className}`); + for (const group of groups) { + if (!group.some((className) => content.includes(className))) { + missing.push(`${file}:${group.join("|")}`); } } } const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8"); -const requiredOverviewClasses = [ - "ctms-page-shell", - "kpi", - "unified-section" +const requiredOverviewGroups = [ + ["ctms-page-shell", "page"], + ["kpi", "overview-card"], + ["unified-section", "overview-container"] ]; -for (const className of requiredOverviewClasses) { - if (!overview.includes(className)) { - missing.push(`src/views/ia/ProjectOverview.vue:${className}`); +for (const group of requiredOverviewGroups) { + if (!group.some((className) => overview.includes(className))) { + missing.push(`src/views/ia/ProjectOverview.vue:${group.join("|")}`); } } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 93d330b7..b3708d5f 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,10 +1,132 @@ mod credentials; 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(handle: &tauri::AppHandle) -> tauri::Result> { + 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(app: &tauri::AppHandle, command: &str) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.emit(DESKTOP_MENU_COMMAND_EVENT, command); + } +} pub fn run() { 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| { if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); diff --git a/frontend/src/components/DesktopCommandPalette.vue b/frontend/src/components/DesktopCommandPalette.vue new file mode 100644 index 00000000..c4051351 --- /dev/null +++ b/frontend/src/components/DesktopCommandPalette.vue @@ -0,0 +1,212 @@ + + + + + + + diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts new file mode 100644 index 00000000..1a1a3abb --- /dev/null +++ b/frontend/src/components/Layout.desktop.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8"); + +describe("desktop layout shell", () => { + it("keeps desktop-only controls behind the Tauri runtime flag", () => { + const source = readLayoutSource(); + + expect(source).toContain("const isDesktop = isTauriRuntime()"); + expect(source).toContain('v-if="isDesktop"'); + expect(source).toContain("DesktopCommandPalette"); + expect(source).toContain("DesktopPreferences"); + }); + + it("uses route-only desktop preference storage", () => { + const source = readLayoutSource(); + + expect(source).toContain("readDesktopRecentRoutes"); + expect(source).toContain("readDesktopFavoriteRoutes"); + expect(source).toContain("recordDesktopRecentRoute"); + expect(source).toContain("currentDesktopRoutePreference"); + }); +}); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 7bf836c4..0a7736cd 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -12,6 +12,26 @@ class="aside-menu" @select="handleMenuSelect" > + + + + + {{ item.title }} + + + + + + + + {{ item.title }} + + + diff --git a/frontend/src/composables/useDesktopRefresh.ts b/frontend/src/composables/useDesktopRefresh.ts new file mode 100644 index 00000000..32c7d79f --- /dev/null +++ b/frontend/src/composables/useDesktopRefresh.ts @@ -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(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, { detail })); + return detail.handled; +}; + +export const onDesktopRefreshCurrentView = (handler: () => void | Promise): (() => void) => { + if (typeof window === "undefined") return () => {}; + const listener = (event: Event) => { + const detail = (event as CustomEvent).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); +}; diff --git a/frontend/src/composables/useDesktopShortcuts.test.ts b/frontend/src/composables/useDesktopShortcuts.test.ts new file mode 100644 index 00000000..21797cf5 --- /dev/null +++ b/frontend/src/composables/useDesktopShortcuts.test.ts @@ -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); + }); +}); diff --git a/frontend/src/composables/useDesktopShortcuts.ts b/frontend/src/composables/useDesktopShortcuts.ts new file mode 100644 index 00000000..28d11e2b --- /dev/null +++ b/frontend/src/composables/useDesktopShortcuts.ts @@ -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 }; +}; diff --git a/frontend/src/runtime/desktopMenu.ts b/frontend/src/runtime/desktopMenu.ts new file mode 100644 index 00000000..bee6e562 --- /dev/null +++ b/frontend/src/runtime/desktopMenu.ts @@ -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 => { + if (!isTauriRuntime()) return () => {}; + const { listen } = await import("@tauri-apps/api/event"); + return listen(DESKTOP_MENU_COMMAND_EVENT, (event) => { + if (typeof event.payload === "string") handler(event.payload); + }); +}; diff --git a/frontend/src/runtime/desktopServerConfig.ts b/frontend/src/runtime/desktopServerConfig.ts index cc46b5c0..251d6204 100644 --- a/frontend/src/runtime/desktopServerConfig.ts +++ b/frontend/src/runtime/desktopServerConfig.ts @@ -9,6 +9,24 @@ export type DesktopServerUrlValidationResult = 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 => { const raw = value.trim(); if (!raw) return { ok: false, reason: "请输入服务器地址" }; @@ -34,7 +52,7 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida }; 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; const result = normalizeDesktopServerUrl(stored); return result.ok ? result.url : null; @@ -45,24 +63,18 @@ export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl()) export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => { const result = normalizeDesktopServerUrl(value); if (!result.ok) return result; + const storage = getStorage(); + if (!storage) return { ok: false, reason: "当前环境无法保存服务器地址" }; const previous = getDesktopServerUrl(); - window.localStorage.setItem(DESKTOP_SERVER_URL_KEY, result.url); - window.dispatchEvent( - new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, { - detail: { previous, current: result.url }, - }), - ); + storage.setItem(DESKTOP_SERVER_URL_KEY, result.url); + emitServerUrlChanged(previous, result.url); return result; }; export const clearDesktopServerUrl = (): void => { const previous = getDesktopServerUrl(); - window.localStorage.removeItem(DESKTOP_SERVER_URL_KEY); - window.dispatchEvent( - new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, { - detail: { previous, current: null }, - }), - ); + getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY); + emitServerUrlChanged(previous, null); }; export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl(); diff --git a/frontend/src/runtime/desktopUiPreferences.test.ts b/frontend/src/runtime/desktopUiPreferences.test.ts new file mode 100644 index 00000000..8c460cfe --- /dev/null +++ b/frontend/src/runtime/desktopUiPreferences.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + DESKTOP_FAVORITE_ROUTES_KEY, + DESKTOP_RECENT_ROUTES_KEY, + readDesktopFavoriteRoutes, + readDesktopRecentRoutes, + recordDesktopRecentRoute, + toggleDesktopFavoriteRoute, +} from "./desktopUiPreferences"; + +const installLocalStorageMock = () => { + const store = new Map(); + 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(); + }); + + 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"); + }); +}); diff --git a/frontend/src/runtime/desktopUiPreferences.ts b/frontend/src/runtime/desktopUiPreferences.ts new file mode 100644 index 00000000..8f076717 --- /dev/null +++ b/frontend/src/runtime/desktopUiPreferences.ts @@ -0,0 +1,78 @@ +export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes"; +export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes"; + +export interface DesktopRoutePreference { + path: string; + title: string; + group?: string; + updatedAt: string; +} + +const MAX_RECENT_ROUTES = 8; +const MAX_FAVORITE_ROUTES = 12; + +const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined"; + +const sanitizeRoutePreference = (value: Partial | 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)); +}; + +export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY); + +export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY); + +export const recordDesktopRecentRoute = (route: Pick): 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[] => { + 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; +}; diff --git a/frontend/src/runtime/index.ts b/frontend/src/runtime/index.ts index 04e6643d..afcb9817 100644 --- a/frontend/src/runtime/index.ts +++ b/frontend/src/runtime/index.ts @@ -8,6 +8,21 @@ export { setDesktopServerUrl, shouldRequireDesktopServerUrl, } from "./desktopServerConfig"; +export { + DESKTOP_FAVORITE_ROUTES_KEY, + DESKTOP_RECENT_ROUTES_KEY, + isDesktopFavoriteRoute, + readDesktopFavoriteRoutes, + readDesktopRecentRoutes, + recordDesktopRecentRoute, + toggleDesktopFavoriteRoute, + type DesktopRoutePreference, +} from "./desktopUiPreferences"; +export { + DESKTOP_MENU_COMMAND_EVENT, + listenDesktopMenuCommand, + type DesktopMenuCommand, +} from "./desktopMenu"; export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata"; export { cleanupTemporaryFiles, diff --git a/frontend/src/session/desktopUpdateManager.ts b/frontend/src/session/desktopUpdateManager.ts index 8a3d27bf..337329e5 100644 --- a/frontend/src/session/desktopUpdateManager.ts +++ b/frontend/src/session/desktopUpdateManager.ts @@ -16,6 +16,12 @@ let checkTimer: number | null = null; let intervalTimer: number | null = null; 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 getPostponeUntil = (version: string): number => { @@ -46,8 +52,8 @@ const releaseNotes = (update: DesktopUpdateInfo): string => { return lines.join("\n"); }; -const promptForUpdate = async (update: DesktopUpdateInfo) => { - if (promptVisible || isSuppressed(update.version)) return; +const promptForUpdate = async (update: DesktopUpdateInfo): Promise => { + if (promptVisible || isSuppressed(update.version)) return "suppressed"; promptVisible = true; try { await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", { @@ -57,26 +63,37 @@ const promptForUpdate = async (update: DesktopUpdateInfo) => { type: "info", }); await installPendingDesktopUpdate(); + return "available"; } catch (error) { if (error === "cancel" || error === "close") { postponeVersion(update.version); - return; + return "postponed"; } ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。"); + return "failed"; } finally { promptVisible = false; } }; -export const checkDesktopUpdateAndPrompt = async () => { - if (!isDesktopUpdaterAvailable()) return; +export const checkDesktopUpdateAndPrompt = async ( + options: DesktopUpdateCheckOptions = {}, +): Promise => { + if (!isDesktopUpdaterAvailable()) { + if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新"); + return "disabled"; + } try { const update = await checkForDesktopUpdate(); if (update) { - await promptForUpdate(update); + return promptForUpdate(update); } + if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本"); + return "up-to-date"; } catch { // 启动和定时检查不打断录入;下一轮继续检查。 + if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。"); + return "failed"; } }; diff --git a/frontend/src/types/desktopCommands.test.ts b/frontend/src/types/desktopCommands.test.ts new file mode 100644 index 00000000..bf025c80 --- /dev/null +++ b/frontend/src/types/desktopCommands.test.ts @@ -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([]); + }); +}); diff --git a/frontend/src/types/desktopCommands.ts b/frontend/src/types/desktopCommands.ts new file mode 100644 index 00000000..d6626549 --- /dev/null +++ b/frontend/src/types/desktopCommands.ts @@ -0,0 +1,22 @@ +export interface DesktopCommand { + id: string; + title: string; + group: string; + keywords?: string[]; + shortcut?: string; + visible: boolean; + run: () => void | Promise; +} + +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); + }); +}; diff --git a/frontend/src/views/DesktopPreferences.vue b/frontend/src/views/DesktopPreferences.vue new file mode 100644 index 00000000..a1ac851d --- /dev/null +++ b/frontend/src/views/DesktopPreferences.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/frontend/src/views/DesktopServerSettings.vue b/frontend/src/views/DesktopServerSettings.vue index 7cf6385d..f090cf02 100644 --- a/frontend/src/views/DesktopServerSettings.vue +++ b/frontend/src/views/DesktopServerSettings.vue @@ -7,6 +7,21 @@

配置桌面客户端要连接的 CTMS 服务端入口。业务数据仍由服务端统一保存和裁决。

+
+ 当前服务器 + {{ currentServerUrl }} +
+ + + 取消 - 保存并检查连接 + + 保存并检查连接 + @@ -47,16 +64,33 @@ const currentServerUrl = getDesktopServerUrl(); const serverUrl = ref(currentServerUrl || ""); const urlError = ref(""); const saving = ref(false); +const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null); const canCancel = computed(() => Boolean(currentServerUrl)); +const HEALTH_TIMEOUT_MS = 10_000; const checkHealth = async (baseUrl: string) => { const healthUrl = new URL("health", baseUrl).toString(); - const response = await fetch(healthUrl, { - method: "GET", - headers: { Accept: "application/json" }, - }); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS); + try { + const response = await fetch(healthUrl, { + method: "GET", + 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 () => { urlError.value = ""; + connectionStatus.value = null; const normalized = normalizeDesktopServerUrl(serverUrl.value); if (!normalized.ok) { urlError.value = normalized.reason; @@ -85,10 +120,21 @@ const save = async () => { if (previous !== result.url) { await clearSessionForServerChange(); } + connectionStatus.value = { + type: "success", + title: "连接已确认", + message: result.url, + }; ElMessage.success("服务器连接已确认"); router.replace("/login"); - } catch { - urlError.value = "无法连接服务器的 /health,请确认地址和网络后重试"; + } catch (error) { + const message = error instanceof Error ? error.message : "无法连接服务器的 /health"; + connectionStatus.value = { + type: "error", + title: "连接检查失败", + message, + }; + urlError.value = message; } finally { saving.value = false; } @@ -145,6 +191,30 @@ h1 { 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 { margin-top: -8px; color: #64748b; diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index b6850a93..122f337f 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -100,6 +100,12 @@ 账号登录 +
+ 服务器 + {{ desktopServerUrl || "未配置" }} + 设置 +
+
@@ -249,7 +255,7 @@