发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
@@ -1,6 +1,9 @@
|
||||
# Frontend feature flags and timeline overrides
|
||||
VITE_RUNTIME_ENV=production
|
||||
VITE_ALLOW_INSECURE_DEV_LOGIN=false
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
|
||||
VITE_HMR_CLIENT_PORT=
|
||||
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
||||
VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3
|
||||
VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"name": "ctms-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
@@ -18,6 +24,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
@@ -1354,6 +1361,293 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
|
||||
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
|
||||
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
|
||||
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-updater": {
|
||||
"version": "2.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
|
||||
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
||||
@@ -7,11 +7,28 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
"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: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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.0",
|
||||
"@tauri-apps/plugin-notification": "^2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"@tauri-apps/plugin-updater": "^2.9.0",
|
||||
"axios": "^1.6.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"echarts": "^6.0.0",
|
||||
@@ -22,6 +39,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const paths = {
|
||||
packageJson: new URL("../package.json", import.meta.url),
|
||||
packageLock: new URL("../package-lock.json", import.meta.url),
|
||||
tauriConfig: new URL("../src-tauri/tauri.conf.json", import.meta.url),
|
||||
cargoToml: new URL("../src-tauri/Cargo.toml", import.meta.url),
|
||||
cargoLock: new URL("../src-tauri/Cargo.lock", import.meta.url),
|
||||
};
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||
const writeJson = async (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
|
||||
const readCargoPackageVersion = async () => {
|
||||
const cargo = await readFile(paths.cargoToml, "utf8");
|
||||
const packageSection = cargo.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1];
|
||||
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (!version) throw new Error("Cannot find [package].version in src-tauri/Cargo.toml");
|
||||
return version;
|
||||
};
|
||||
|
||||
const readCargoLockPackageVersion = async () => {
|
||||
const cargoLock = await readFile(paths.cargoLock, "utf8");
|
||||
const packageSection = cargoLock
|
||||
.match(/\[\[package\]\]([\s\S]*?)(?=\n\[\[package\]\]|$)/g)
|
||||
?.find((section) => /^name\s*=\s*"ctms-desktop"$/m.test(section));
|
||||
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
||||
if (!version) throw new Error("Cannot find ctms-desktop version in src-tauri/Cargo.lock");
|
||||
return version;
|
||||
};
|
||||
|
||||
const readVersions = async () => {
|
||||
const [packageJson, packageLock, tauriConfig, cargoVersion, cargoLockVersion] = await Promise.all([
|
||||
readJson(paths.packageJson),
|
||||
readJson(paths.packageLock),
|
||||
readJson(paths.tauriConfig),
|
||||
readCargoPackageVersion(),
|
||||
readCargoLockPackageVersion(),
|
||||
]);
|
||||
return {
|
||||
"package.json": packageJson.version,
|
||||
"package-lock.json": packageLock.version,
|
||||
"package-lock.json root": packageLock.packages?.[""]?.version,
|
||||
"tauri.conf.json": tauriConfig.version,
|
||||
"Cargo.toml": cargoVersion,
|
||||
"Cargo.lock": cargoLockVersion,
|
||||
};
|
||||
};
|
||||
|
||||
const assertVersionsMatch = async () => {
|
||||
const versions = await readVersions();
|
||||
const uniqueVersions = new Set(Object.values(versions));
|
||||
if (uniqueVersions.size !== 1 || uniqueVersions.has(undefined)) {
|
||||
const details = Object.entries(versions)
|
||||
.map(([file, version]) => ` ${file}: ${version ?? "<missing>"}`)
|
||||
.join("\n");
|
||||
throw new Error(`Client versions are not synchronized:\n${details}`);
|
||||
}
|
||||
const [version] = uniqueVersions;
|
||||
console.log(`Client version ${version} is synchronized.`);
|
||||
};
|
||||
|
||||
const setVersion = async (version) => {
|
||||
if (!SEMVER_PATTERN.test(version)) {
|
||||
throw new Error(`Invalid semantic version: ${version}`);
|
||||
}
|
||||
|
||||
const [packageJson, packageLock, tauriConfig, tauriConfigSource, cargo, cargoLock] = await Promise.all([
|
||||
readJson(paths.packageJson),
|
||||
readJson(paths.packageLock),
|
||||
readJson(paths.tauriConfig),
|
||||
readFile(paths.tauriConfig, "utf8"),
|
||||
readFile(paths.cargoToml, "utf8"),
|
||||
readFile(paths.cargoLock, "utf8"),
|
||||
]);
|
||||
|
||||
packageJson.version = version;
|
||||
packageLock.version = version;
|
||||
packageLock.packages[""].version = version;
|
||||
|
||||
const tauriVersionPattern = /("version"\s*:\s*")[^"]+(")/;
|
||||
const packageSectionPattern = /(\[package\][\s\S]*?^version\s*=\s*")[^"]+(")/m;
|
||||
const lockPackagePattern =
|
||||
/(\[\[package\]\]\nname\s*=\s*"ctms-desktop"\nversion\s*=\s*")[^"]+(")/m;
|
||||
if (typeof tauriConfig.version !== "string" || !tauriVersionPattern.test(tauriConfigSource)) {
|
||||
throw new Error("Cannot update version in src-tauri/tauri.conf.json");
|
||||
}
|
||||
if (!packageSectionPattern.test(cargo)) {
|
||||
throw new Error("Cannot update [package].version in src-tauri/Cargo.toml");
|
||||
}
|
||||
if (!lockPackagePattern.test(cargoLock)) {
|
||||
throw new Error("Cannot update ctms-desktop version in src-tauri/Cargo.lock");
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
writeJson(paths.packageJson, packageJson),
|
||||
writeJson(paths.packageLock, packageLock),
|
||||
writeFile(paths.tauriConfig, tauriConfigSource.replace(tauriVersionPattern, `$1${version}$2`)),
|
||||
writeFile(paths.cargoToml, cargo.replace(packageSectionPattern, `$1${version}$2`)),
|
||||
writeFile(paths.cargoLock, cargoLock.replace(lockPackagePattern, `$1${version}$2`)),
|
||||
]);
|
||||
console.log(`Updated CTMS Web and Desktop client version to ${version} in ${frontendDir}`);
|
||||
await assertVersionsMatch();
|
||||
};
|
||||
|
||||
const [, , command = "--check", value] = process.argv;
|
||||
|
||||
try {
|
||||
if (command === "--check") {
|
||||
await assertVersionsMatch();
|
||||
} else if (command === "--set" && value) {
|
||||
await setVersion(value);
|
||||
} else {
|
||||
throw new Error("Usage: node scripts/client-version.mjs [--check | --set <semver>]");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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 sourceDir = resolve(frontendDir, "src");
|
||||
const runtimeDir = resolve(sourceDir, "runtime");
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
|
||||
const violations = [];
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
for (const path of await walk(sourceDir)) {
|
||||
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
|
||||
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(frontendDir, path);
|
||||
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
|
||||
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
|
||||
}
|
||||
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
|
||||
violations.push(`${file}: import platform behavior through src/runtime/index.ts`);
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`Runtime boundary violations:\n${violations.map((item) => ` ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Runtime boundary is respected.");
|
||||
}
|
||||
@@ -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("|")}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "ctms-desktop"
|
||||
version = "0.1.0"
|
||||
description = "CTMS desktop client"
|
||||
authors = ["Huapont"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ctms_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] }
|
||||
url = "2"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "4.1.2", default-features = false, features = ["v1", "apple-native-keyring-store"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
keyring = { version = "4.1.2", default-features = false, features = ["v1", "windows-native-keyring-store"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the CTMS desktop window.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-read-file",
|
||||
"fs:allow-write-file",
|
||||
"fs:allow-remove",
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [
|
||||
{ "path": "$TEMP/ctms-desktop/**" }
|
||||
]
|
||||
},
|
||||
"notification:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{ "path": "$TEMP/ctms-desktop/**" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 293 B |
|
After Width: | Height: | Size: 659 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 322 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 846 B |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 194 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,114 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use url::Url;
|
||||
|
||||
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
|
||||
|
||||
fn credential_account(server_origin: &str) -> Result<String, String> {
|
||||
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
|
||||
let is_https = parsed.scheme() == "https";
|
||||
let is_local_http = parsed.scheme() == "http"
|
||||
&& matches!(
|
||||
parsed.host_str(),
|
||||
Some("localhost") | Some("127.0.0.1") | Some("::1")
|
||||
);
|
||||
if !is_https && !is_local_http {
|
||||
return Err("非本地服务必须使用 HTTPS".to_string());
|
||||
}
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("服务器地址不能包含凭据".to_string());
|
||||
}
|
||||
let origin = parsed.origin().ascii_serialization();
|
||||
let digest = Sha256::digest(origin.as_bytes());
|
||||
Ok(format!("{digest:x}"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
|
||||
let account = credential_account(server_origin)?;
|
||||
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
|
||||
.map_err(|error| format!("无法访问系统凭据库:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_get(server_origin: String) -> Result<Option<String>, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.get_password() {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(error) => Err(format!("读取系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("读取系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_set(server_origin: String, token: String) -> Result<(), String> {
|
||||
if token.trim().is_empty() {
|
||||
return Err("拒绝保存空凭据".to_string());
|
||||
}
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
return get_entry(&server_origin)?
|
||||
.set_password(&token)
|
||||
.map_err(|error| format!("保存系统凭据失败:{error}"));
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("保存系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn credential_delete(server_origin: String) -> Result<(), String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
{
|
||||
let entry = get_entry(&server_origin)?;
|
||||
return match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(error) => Err(format!("删除系统凭据失败:{error}")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
{
|
||||
let _ = credential_account(&server_origin)?;
|
||||
Err("当前平台不支持系统凭据存储".to_string())
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::credential_account;
|
||||
|
||||
#[test]
|
||||
fn account_is_stable_for_same_origin() {
|
||||
assert_eq!(
|
||||
credential_account("https://ctms.example.com/path").unwrap(),
|
||||
credential_account("https://ctms.example.com/other").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_insecure_remote_origin() {
|
||||
assert!(credential_account("http://ctms.example.com").is_err());
|
||||
assert!(credential_account("http://localhost:8000").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
mod credentials;
|
||||
mod updates;
|
||||
|
||||
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() {
|
||||
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();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.manage(updates::PendingUpdate::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
credentials::credential_get,
|
||||
credentials::credential_set,
|
||||
credentials::credential_delete,
|
||||
updates::desktop_update_check,
|
||||
updates::desktop_update_install,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CTMS desktop application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ctms_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PendingUpdate(pub Mutex<Option<Update>>);
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DesktopUpdateMetadata {
|
||||
version: String,
|
||||
current_version: String,
|
||||
notes: Option<String>,
|
||||
date: Option<String>,
|
||||
}
|
||||
|
||||
fn normalized_origin(raw: &str) -> Result<Url, String> {
|
||||
let parsed = Url::parse(raw).map_err(|_| "invalid server origin".to_string())?;
|
||||
let scheme = parsed.scheme();
|
||||
if parsed.username() != "" || parsed.password().is_some() {
|
||||
return Err("server origin must not include credentials".to_string());
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "server origin must include a host".to_string())?;
|
||||
let localhost = matches!(host, "localhost" | "127.0.0.1" | "::1");
|
||||
if scheme != "https" && !(scheme == "http" && localhost) {
|
||||
return Err("desktop updates require HTTPS outside localhost".to_string());
|
||||
}
|
||||
let port = parsed.port().map(|value| format!(":{value}")).unwrap_or_default();
|
||||
Url::parse(&format!("{scheme}://{host}{port}/")).map_err(|_| "invalid normalized origin".to_string())
|
||||
}
|
||||
|
||||
fn update_endpoint(server_origin: &str) -> Result<Url, String> {
|
||||
let origin = normalized_origin(server_origin)?;
|
||||
origin
|
||||
.join("desktop-updates/stable/latest.json")
|
||||
.map_err(|_| "invalid update endpoint".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_check(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
server_origin: String,
|
||||
) -> Result<Option<DesktopUpdateMetadata>, String> {
|
||||
let endpoint = update_endpoint(&server_origin)?;
|
||||
let update = app
|
||||
.updater_builder()
|
||||
.endpoints(vec![endpoint])
|
||||
.map_err(|err| err.to_string())?
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?
|
||||
.check()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let metadata = update.as_ref().map(|update| DesktopUpdateMetadata {
|
||||
version: update.version.clone(),
|
||||
current_version: update.current_version.clone(),
|
||||
notes: update.body.clone(),
|
||||
date: update.date.map(|value| value.to_string()),
|
||||
});
|
||||
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
*guard = update;
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn desktop_update_install(
|
||||
app: AppHandle,
|
||||
pending_update: State<'_, PendingUpdate>,
|
||||
) -> Result<(), String> {
|
||||
let update = {
|
||||
let mut guard = pending_update
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|_| "pending update lock poisoned".to_string())?;
|
||||
guard
|
||||
.take()
|
||||
.ok_or_else(|| "there is no pending update".to_string())?
|
||||
};
|
||||
|
||||
update
|
||||
.download_and_install(|_, _| {}, || {})
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
app.restart();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::update_endpoint;
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_uses_fixed_https_path() {
|
||||
let endpoint = update_endpoint("https://ctms.example.com/app/").unwrap();
|
||||
assert_eq!(
|
||||
endpoint.as_str(),
|
||||
"https://ctms.example.com/desktop-updates/stable/latest.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_endpoint_allows_localhost_http_only() {
|
||||
assert!(update_endpoint("http://localhost:8888").is_ok());
|
||||
assert!(update_endpoint("http://ctms.example.com").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CTMS",
|
||||
"version": "0.1.0",
|
||||
"identifier": "cn.huapont.ctms.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CTMS",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 1180,
|
||||
"minHeight": 760
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"],
|
||||
"createUpdaterArtifacts": true
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { initSessionManager } from "./session/sessionManager";
|
||||
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
|
||||
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
|
||||
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
|
||||
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
applyDesktopThemePreference();
|
||||
}
|
||||
initSessionManager();
|
||||
initDesktopNotificationManager();
|
||||
initDesktopUpdateManager();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -19,3 +19,8 @@ export const uploadAttachment = (
|
||||
};
|
||||
|
||||
export const deleteAttachment = (attachmentId: string) => apiDelete(`/api/v1/attachments/${attachmentId}`);
|
||||
|
||||
export const downloadAttachment = (attachmentId: string) =>
|
||||
apiGet<Blob>(`/api/v1/attachments/${attachmentId}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
|
||||
@@ -68,4 +68,12 @@ export const updateProfile = (payload: {
|
||||
password?: string;
|
||||
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
|
||||
|
||||
export const uploadAvatar = (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return apiPost<UserMeResponse>("/api/v1/auth/me/avatar", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
|
||||
|
||||
const authClient = axios.create({
|
||||
baseURL: "/",
|
||||
baseURL: clientRuntime.apiBaseUrl(),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
export const refreshAuthClientBaseUrl = (): void => {
|
||||
authClient.defaults.baseURL = clientRuntime.apiBaseUrl();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshAuthClientBaseUrl);
|
||||
}
|
||||
|
||||
authClient.interceptors.request.use((config) => {
|
||||
config.headers = config.headers || {};
|
||||
Object.assign(config.headers, getAppMetadataHeaders());
|
||||
return config;
|
||||
});
|
||||
|
||||
export type ExtendResponse = {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
|
||||
@@ -3,12 +3,21 @@ import { ElMessage } from "element-plus";
|
||||
import { getToken } from "../utils/auth";
|
||||
import type { ApiError } from "../types/api";
|
||||
import { TEXT } from "../locales";
|
||||
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: "/",
|
||||
baseURL: clientRuntime.apiBaseUrl(),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
export const refreshApiBaseUrl = (): void => {
|
||||
instance.defaults.baseURL = clientRuntime.apiBaseUrl();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl);
|
||||
}
|
||||
|
||||
const NETWORK_RETRY_LIMIT = 10;
|
||||
const NETWORK_RETRY_DELAY_MS = 30000;
|
||||
|
||||
@@ -16,6 +25,7 @@ export type ApiRequestConfig = AxiosRequestConfig & {
|
||||
suppressErrorMessage?: boolean;
|
||||
_retry?: boolean;
|
||||
_networkRetryCount?: number;
|
||||
disableNetworkRetry?: boolean;
|
||||
};
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
@@ -35,13 +45,14 @@ const clearInvalidStudyAndNavigate = async () => {
|
||||
|
||||
const forceAuthExpiredLogout = async () => {
|
||||
const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager");
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
};
|
||||
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
|
||||
config.headers = config.headers || {};
|
||||
Object.assign(config.headers, getAppMetadataHeaders());
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
@@ -62,7 +73,7 @@ instance.interceptors.response.use(
|
||||
// 认证相关的错误由具体页面自行处理,避免重复提示
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (!status && error.config) {
|
||||
if (!status && error.config && !(error.config as ApiRequestConfig).disableNetworkRetry) {
|
||||
const config = error.config as ApiRequestConfig;
|
||||
const retryCount = config._networkRetryCount || 0;
|
||||
if (retryCount < NETWORK_RETRY_LIMIT) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { apiGet, apiPost, apiPut } from "./axios";
|
||||
import type { NotificationItem } from "../types/notifications";
|
||||
|
||||
export interface DesktopNotificationSubscription {
|
||||
enabled: boolean;
|
||||
enabled_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopNotificationClaim {
|
||||
claim_token?: string | null;
|
||||
lease_expires_at?: string | null;
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export const getDesktopNotificationSubscription = () =>
|
||||
apiGet<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const setDesktopNotificationSubscription = (enabled: boolean) =>
|
||||
apiPut<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", { enabled }, {
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const claimDesktopNotifications = (limit = 20) =>
|
||||
apiPost<DesktopNotificationClaim>("/api/v1/desktop-notifications/claim", { limit }, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const acknowledgeDesktopNotifications = (claimToken: string, deliveredIds: string[]) =>
|
||||
apiPost<void>("/api/v1/desktop-notifications/ack", {
|
||||
claim_token: claimToken,
|
||||
delivered_ids: deliveredIds,
|
||||
}, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
|
||||
export const markDesktopNotificationRead = (distributionId: string) =>
|
||||
apiPost<void>(`/api/v1/desktop-notifications/${distributionId}/read`, undefined, {
|
||||
suppressErrorMessage: true,
|
||||
disableNetworkRetry: true,
|
||||
});
|
||||
@@ -71,6 +71,8 @@ export const fetchAccessLogs = (params: {
|
||||
export const fetchSecurityAccessLogs = (params?: {
|
||||
status_min?: number;
|
||||
auth_status?: string;
|
||||
client_type?: string;
|
||||
client_version?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auditExportColumns } from "./auditExportColumns";
|
||||
import { formatAuditRows } from "./auditExportFormatter";
|
||||
import type { AuditEvent } from "..";
|
||||
import { saveFile } from "../../runtime";
|
||||
|
||||
const BOM = "\ufeff";
|
||||
|
||||
@@ -19,13 +20,13 @@ export interface AuditExportOptions {
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export const exportAuditCsv = (events: AuditEvent[], options: AuditExportOptions) => {
|
||||
export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportOptions) => {
|
||||
const rows = formatAuditRows(events);
|
||||
const csv = buildCsv(rows);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
await saveFile({
|
||||
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
|
||||
mimeType: "text/csv;charset=utf-8",
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -154,6 +154,7 @@ import type {
|
||||
} from "@/types/api";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import { saveFile } from "@/runtime";
|
||||
|
||||
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
|
||||
showSecurityLog: false,
|
||||
@@ -311,7 +312,8 @@ const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
|
||||
const account = row.account_label || "未知账号";
|
||||
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
|
||||
const userAgent = row.user_agent || "-";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const terminalLogRows = computed(() =>
|
||||
@@ -515,14 +517,9 @@ const openSecurityLogDialog = () => {
|
||||
scrollSecurityTerminalToBottom();
|
||||
};
|
||||
|
||||
const downloadLogFile = (fileName: string, lines: string[]) => {
|
||||
const downloadLogFile = async (fileName: string, lines: string[]) => {
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
|
||||
};
|
||||
|
||||
const downloadInterfaceLog = () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ const continueSession = () => {
|
||||
};
|
||||
|
||||
const logoutNow = () => {
|
||||
forceLogout(LOGOUT_REASON_MANUAL);
|
||||
void forceLogout(LOGOUT_REASON_MANUAL);
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
<el-input v-model="contentProxy" type="textarea" rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<div v-if="allowAttachments" class="upload-row">
|
||||
<div class="upload-label">{{ TEXT.common.labels.attachments }}</div>
|
||||
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-upload v-if="!nativeFiles" v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-button size="small" class="upload-button">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else size="small" class="upload-button" @click="pickNativeAttachments">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -26,6 +29,7 @@
|
||||
import { computed } from "vue";
|
||||
import type { UploadUserFile } from "element-plus";
|
||||
import { displayDateTime, displayUser } from "../utils/display";
|
||||
import { clientRuntime, pickFiles } from "../runtime";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -46,6 +50,7 @@ const emit = defineEmits<{
|
||||
(e: "cancel"): void;
|
||||
(e: "clear-quote"): void;
|
||||
}>();
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
|
||||
const contentProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -58,6 +63,21 @@ const fileListProxy = computed({
|
||||
});
|
||||
|
||||
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
|
||||
|
||||
const pickNativeAttachments = async () => {
|
||||
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
|
||||
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
|
||||
const additions: UploadUserFile[] = files
|
||||
.filter((file) => !existing.has(`${file.name}:${file.size}`))
|
||||
.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
raw: file as any,
|
||||
status: "ready",
|
||||
uid: Date.now() + Math.floor(Math.random() * 100000),
|
||||
}));
|
||||
emit("update:fileList", [...props.fileList, ...additions]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, reactive, watch } from "vue";
|
||||
import { downloadAttachment } from "../api/attachments";
|
||||
import { saveFile } from "../runtime";
|
||||
import { displayDateTime, displayUser } from "../utils/display";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
@@ -68,13 +71,37 @@ const quoteContent = (item: any) =>
|
||||
const contentText = (item: any) =>
|
||||
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
|
||||
|
||||
const attachmentUrl = (id: string) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
|
||||
const attachmentObjectUrls = reactive<Record<string, string>>({});
|
||||
const attachmentUrl = (id: string) => attachmentObjectUrls[id] || "";
|
||||
|
||||
const revokeAttachmentUrls = () => {
|
||||
Object.values(attachmentObjectUrls).forEach((url) => URL.revokeObjectURL(url));
|
||||
Object.keys(attachmentObjectUrls).forEach((key) => delete attachmentObjectUrls[key]);
|
||||
};
|
||||
|
||||
const download = (id: string) => {
|
||||
window.open(attachmentUrl(id), "_blank");
|
||||
const loadImageUrls = async () => {
|
||||
revokeAttachmentUrls();
|
||||
const files = Object.values(props.attachmentsMap).flat().filter(isImage);
|
||||
await Promise.all(files.map(async (file) => {
|
||||
try {
|
||||
const response = await downloadAttachment(file.id);
|
||||
attachmentObjectUrls[file.id] = URL.createObjectURL(
|
||||
new Blob([response.data], { type: response.headers?.["content-type"] || file.content_type }),
|
||||
);
|
||||
} catch {
|
||||
attachmentObjectUrls[file.id] = "";
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const download = async (id: string) => {
|
||||
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
|
||||
const response = await downloadAttachment(id);
|
||||
await saveFile({
|
||||
suggestedName: file?.filename || "attachment",
|
||||
mimeType: response.headers?.["content-type"] || file?.content_type,
|
||||
data: response.data,
|
||||
});
|
||||
};
|
||||
|
||||
const isImage = (file: any) => {
|
||||
@@ -88,6 +115,9 @@ const isHighlighted = (item: any) => {
|
||||
if (!props.highlightIds?.length) return false;
|
||||
return props.highlightIds.includes(item?.id);
|
||||
};
|
||||
|
||||
watch(() => props.attachmentsMap, () => void loadImageUrls(), { deep: true, immediate: true });
|
||||
onBeforeUnmount(revokeAttachmentUrls);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<span>{{ headerTitle }}</span>
|
||||
<div v-if="canShowUploader && tableUploadGroup" class="immediate-upload">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
:http-request="uploadImmediate"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
@@ -13,6 +14,9 @@
|
||||
>
|
||||
<el-button type="primary" :loading="isImmediateUploading">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else type="primary" :loading="isImmediateUploading" @click="pickImmediateNative">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
<el-progress v-if="tableProgress > 0 && tableProgress < 100" :percentage="tableProgress" :stroke-width="6" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,6 +45,7 @@
|
||||
{{ TEXT.common.labels.preview }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="openExternally(scope.row)">打开</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(scope.row)"
|
||||
link
|
||||
@@ -58,6 +63,7 @@
|
||||
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
|
||||
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
class="attachment-card-upload"
|
||||
:show-file-list="false"
|
||||
multiple
|
||||
@@ -79,6 +85,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
v-else
|
||||
class="attachment-upload-card"
|
||||
:class="{
|
||||
'is-disabled': !canUploadGroup(group),
|
||||
'attachment-upload-card--centered': centerUploadCardContent,
|
||||
}"
|
||||
@click="pickPendingNative(group)"
|
||||
>
|
||||
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
|
||||
<div class="upload-trigger">
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<span class="upload-text">{{ group.uploadText || "点击上传文件" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingMap[group.key]?.length" class="pending-list">
|
||||
<div
|
||||
v-for="item in pendingMap[group.key]"
|
||||
@@ -122,7 +143,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { formatFileSize } from "./attachmentUtils";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -131,6 +152,7 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
|
||||
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
|
||||
|
||||
type AttachmentEntityGroup = {
|
||||
entityType: string;
|
||||
@@ -182,6 +204,7 @@ const previewType = ref<"image" | "pdf" | "other">("other");
|
||||
const previewTitle = ref(TEXT.common.labels.preview);
|
||||
const previewError = ref("");
|
||||
const previewLoading = ref(false);
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
|
||||
const displayMode = computed(() => props.mode || "table");
|
||||
const maxSize = computed(() => props.maxSizeMb ?? 50);
|
||||
@@ -240,10 +263,9 @@ const validateFile = (file: File) => {
|
||||
|
||||
const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const queuePendingFile = (fileType: string, file: File) => {
|
||||
const group = uploadGroups.value.find((item) => item.key === fileType);
|
||||
if (!group || !canUploadGroup(group)) return;
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (!file || !validateFile(file)) return;
|
||||
const next = pendingMap[fileType] || [];
|
||||
if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) {
|
||||
@@ -251,6 +273,17 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (file) queuePendingFile(fileType, file);
|
||||
};
|
||||
|
||||
const pickPendingNative = async (group: UploadGroup) => {
|
||||
if (!canUploadGroup(group)) return;
|
||||
const selected = await pickFiles({ multiple: true, title: group.label });
|
||||
selected.forEach((file) => queuePendingFile(group.key, file));
|
||||
};
|
||||
|
||||
const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
|
||||
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
|
||||
};
|
||||
@@ -317,6 +350,11 @@ const uploadImmediate = async (options: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pickImmediateNative = async () => {
|
||||
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
|
||||
if (file) await uploadImmediate({ file });
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!props.studyId || !props.entityId) return;
|
||||
loading.value = true;
|
||||
@@ -352,25 +390,35 @@ const uploaderLabel = (row: any) => {
|
||||
return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const getDownloadUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
|
||||
const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
|
||||
const response = await downloadAttachment(row.id);
|
||||
return new Blob([response.data], {
|
||||
type: response.headers?.["content-type"] || row?.content_type || "application/octet-stream",
|
||||
});
|
||||
};
|
||||
|
||||
const getPreviewUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
|
||||
const download = async (row: any) => {
|
||||
try {
|
||||
await saveFile({
|
||||
suggestedName: row?.filename || "download",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const download = (row: any) => {
|
||||
const url = getDownloadUrl(row);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = row?.filename || "download";
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
const openExternally = async (row: any) => {
|
||||
try {
|
||||
await openFile({
|
||||
suggestedName: row?.filename || "attachment",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.previewNotSupported);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
|
||||
@@ -386,37 +434,29 @@ const detectPreviewType = (row: any) => {
|
||||
return "other";
|
||||
};
|
||||
|
||||
const previewImage = async (downloadUrl: string) => {
|
||||
const previewImage = async (row: any) => {
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(downloadUrl, { headers });
|
||||
if (!response.ok) throw new Error("preview failed");
|
||||
const blob = await response.blob();
|
||||
const blob = await fetchAttachmentBlob(row);
|
||||
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
|
||||
previewObjectUrl.value = URL.createObjectURL(blob);
|
||||
previewUrl.value = previewObjectUrl.value;
|
||||
} catch {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const preview = (row: any) => {
|
||||
const preview = async (row: any) => {
|
||||
previewError.value = "";
|
||||
previewType.value = detectPreviewType(row);
|
||||
previewTitle.value = row?.filename || TEXT.common.labels.preview;
|
||||
const downloadUrl = getDownloadUrl(row);
|
||||
if (previewType.value === "image") {
|
||||
if (previewType.value === "image" || previewType.value === "pdf") {
|
||||
previewUrl.value = "";
|
||||
previewImage(downloadUrl);
|
||||
} else if (previewType.value === "pdf") {
|
||||
previewUrl.value = getPreviewUrl(row);
|
||||
await previewImage(row);
|
||||
} else {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewUrl.value = "";
|
||||
}
|
||||
if (previewType.value === "other") {
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
|
||||
@@ -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,5 +1,14 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_BUILD_CHANNEL?: "dev" | "main" | "release" | "local";
|
||||
readonly VITE_BUILD_COMMIT?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
|
||||
@@ -12,22 +12,33 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { getToken } from "./utils/auth";
|
||||
import { useStudyStore } from "./store/study";
|
||||
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime";
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
dayjs.locale("zh-cn");
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
const bootstrap = async () => {
|
||||
await initializeSecureSessionStorage().catch((error) => {
|
||||
console.error("Secure session storage initialization failed", error);
|
||||
});
|
||||
await cleanupTemporaryFiles().catch((error) => {
|
||||
console.warn("Desktop temporary file cleanup failed", error);
|
||||
});
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
dayjs.locale("zh-cn");
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.loadCurrentStudy();
|
||||
if (getToken()) {
|
||||
await studyStore.rehydrateStudyForLastUser();
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.loadCurrentStudy();
|
||||
if (getToken() && !shouldRequireDesktopServerUrl()) {
|
||||
await studyStore.rehydrateStudyForLastUser();
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
|
||||
app.mount("#app");
|
||||
app.mount("#app");
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import { hasDesktopServerUrl, isTauriRuntime, shouldRequireDesktopServerUrl } from "../runtime";
|
||||
|
||||
const DESKTOP_SETTINGS_PATH = "/desktop/server-settings";
|
||||
|
||||
export const resolveDesktopRouteRedirect = (to: RouteLocationNormalized): string | null => {
|
||||
const isSettingsRoute = to.path === DESKTOP_SETTINGS_PATH;
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
return isSettingsRoute ? "/login" : null;
|
||||
}
|
||||
|
||||
if (shouldRequireDesktopServerUrl()) {
|
||||
return isSettingsRoute ? null : DESKTOP_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
if (isSettingsRoute || hasDesktopServerUrl()) return null;
|
||||
return DESKTOP_SETTINGS_PATH;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import Layout from "../components/Layout.vue";
|
||||
import Login from "../views/Login.vue";
|
||||
import Register from "../views/Register.vue";
|
||||
import ForgotPassword from "../views/ForgotPassword.vue";
|
||||
import DesktopServerSettings from "../views/DesktopServerSettings.vue";
|
||||
import StudyHome from "../views/StudyHome.vue";
|
||||
import FaqDetail from "../views/FaqDetail.vue";
|
||||
import AuditLogs from "../views/admin/AuditLogs.vue";
|
||||
@@ -55,6 +56,7 @@ import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
|
||||
import SubjectForm from "../views/subjects/SubjectForm.vue";
|
||||
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
|
||||
import { TEXT } from "../locales";
|
||||
import { resolveDesktopRouteRedirect } from "./desktopGuard";
|
||||
|
||||
const SYSTEM_PERMISSION_READ = "system:permissions:read";
|
||||
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
|
||||
@@ -78,6 +80,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: ForgotPassword,
|
||||
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
|
||||
},
|
||||
{
|
||||
path: "/desktop/server-settings",
|
||||
name: "DesktopServerSettings",
|
||||
component: DesktopServerSettings,
|
||||
meta: { public: true, title: "服务器设置" },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: Layout,
|
||||
@@ -504,6 +512,12 @@ const ensureProjectPermissionAccess = async (
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const desktopRedirect = resolveDesktopRouteRedirect(to);
|
||||
if (desktopRedirect) {
|
||||
next({ path: desktopRedirect });
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
const getToken = () => auth.token;
|
||||
@@ -513,7 +527,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
|
||||
auth.logout();
|
||||
await auth.logout();
|
||||
next({ path: "/login" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const resolveApiBaseUrl = (): string => {
|
||||
if (!isTauriRuntime()) return "/";
|
||||
return getDesktopServerUrl() || "/";
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import packageInfo from "../../package.json";
|
||||
import { clientRuntime } from "./clientRuntime";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("client runtime", () => {
|
||||
it("reports a web runtime with desktop capabilities disabled", () => {
|
||||
expect(getAppMetadata()).toMatchObject({
|
||||
version: packageInfo.version,
|
||||
commit: "local",
|
||||
channel: "local",
|
||||
clientType: "web",
|
||||
platform: "web",
|
||||
});
|
||||
expect(clientRuntime.capabilities()).toEqual({
|
||||
serverConfiguration: false,
|
||||
nativeFiles: false,
|
||||
systemNotifications: false,
|
||||
secureSessionStorage: false,
|
||||
automaticUpdates: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes only the implemented desktop capability", () => {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
|
||||
expect(getAppMetadata().clientType).toBe("desktop");
|
||||
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import packageInfo from "../../package.json";
|
||||
import { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||
|
||||
export type ClientType = "web" | "desktop";
|
||||
export type BuildChannel = "dev" | "main" | "release" | "local";
|
||||
|
||||
export interface AppMetadata {
|
||||
version: string;
|
||||
commit: string;
|
||||
channel: BuildChannel;
|
||||
clientType: ClientType;
|
||||
platform: RuntimePlatform;
|
||||
}
|
||||
|
||||
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 =>
|
||||
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 => ({
|
||||
version: packageInfo.version,
|
||||
commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT),
|
||||
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
|
||||
clientType: isTauriRuntime() ? "desktop" : "web",
|
||||
platform: getRuntimePlatform(),
|
||||
});
|
||||
|
||||
export const getAppMetadataHeaders = (): Record<string, string> => {
|
||||
const metadata = getAppMetadata();
|
||||
return {
|
||||
"X-CTMS-Client-Type": metadata.clientType,
|
||||
"X-CTMS-Client-Version": metadata.version,
|
||||
"X-CTMS-Client-Platform": metadata.platform,
|
||||
"X-CTMS-Build-Channel": metadata.channel,
|
||||
"X-CTMS-Build-Commit": metadata.commit,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { resolveApiBaseUrl } from "./apiBaseUrl";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import * as files from "./files";
|
||||
import * as notifications from "./notifications";
|
||||
import * as updates from "./updates";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
import {
|
||||
clearSessionToken,
|
||||
getSessionToken,
|
||||
initializeSecureSessionStorage,
|
||||
isSecureSessionStorageAvailable,
|
||||
setSessionToken,
|
||||
} from "./secureSessionStorage";
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
serverConfiguration: boolean;
|
||||
nativeFiles: boolean;
|
||||
systemNotifications: boolean;
|
||||
secureSessionStorage: boolean;
|
||||
automaticUpdates: boolean;
|
||||
}
|
||||
|
||||
export interface ClientRuntime {
|
||||
apiBaseUrl(): string;
|
||||
metadata: typeof getAppMetadata;
|
||||
capabilities(): RuntimeCapabilities;
|
||||
secureSessionStorage: {
|
||||
initialize: typeof initializeSecureSessionStorage;
|
||||
get: typeof getSessionToken;
|
||||
set: typeof setSessionToken;
|
||||
clear: typeof clearSessionToken;
|
||||
};
|
||||
files: typeof files;
|
||||
notifications: typeof notifications;
|
||||
updates: typeof updates;
|
||||
}
|
||||
|
||||
export const clientRuntime: ClientRuntime = {
|
||||
apiBaseUrl: resolveApiBaseUrl,
|
||||
metadata: getAppMetadata,
|
||||
secureSessionStorage: {
|
||||
initialize: initializeSecureSessionStorage,
|
||||
get: getSessionToken,
|
||||
set: setSessionToken,
|
||||
clear: clearSessionToken,
|
||||
},
|
||||
files,
|
||||
notifications,
|
||||
updates,
|
||||
capabilities: () => ({
|
||||
serverConfiguration: isTauriRuntime(),
|
||||
nativeFiles: isTauriRuntime(),
|
||||
systemNotifications: isTauriRuntime(),
|
||||
secureSessionStorage: isTauriRuntime() && isSecureSessionStorageAvailable(),
|
||||
automaticUpdates: updates.isDesktopUpdaterAvailable(),
|
||||
}),
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
DESKTOP_SERVER_URL_KEY,
|
||||
getDesktopServerUrl,
|
||||
normalizeDesktopServerUrl,
|
||||
setDesktopServerUrl,
|
||||
shouldRequireDesktopServerUrl,
|
||||
} from "./desktopServerConfig";
|
||||
|
||||
const setTauriRuntime = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
} else {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
}
|
||||
};
|
||||
|
||||
const createMemoryStorage = (): Storage => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: vi.fn(() => store.clear()),
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null),
|
||||
removeItem: vi.fn((key: string) => store.delete(key)),
|
||||
setItem: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: createMemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
setTauriRuntime(false);
|
||||
});
|
||||
|
||||
describe("desktop server config", () => {
|
||||
it("normalizes server URLs to an origin with trailing slash", () => {
|
||||
expect(normalizeDesktopServerUrl("https://ctms.example.com/api")).toEqual({
|
||||
ok: true,
|
||||
url: "https://ctms.example.com/",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local HTTP but rejects non-local HTTP", () => {
|
||||
expect(normalizeDesktopServerUrl("http://localhost:8000")).toEqual({
|
||||
ok: true,
|
||||
url: "http://localhost:8000/",
|
||||
});
|
||||
expect(normalizeDesktopServerUrl("http://ctms.example.com").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("stores valid desktop server URLs and emits a change event", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, listener);
|
||||
|
||||
const result = setDesktopServerUrl("https://ctms.example.com");
|
||||
|
||||
expect(result).toEqual({ ok: true, url: "https://ctms.example.com/" });
|
||||
expect(window.localStorage.getItem(DESKTOP_SERVER_URL_KEY)).toBe("https://ctms.example.com/");
|
||||
expect(getDesktopServerUrl()).toBe("https://ctms.example.com/");
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires a server URL only inside the Tauri runtime", () => {
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
setTauriRuntime(true);
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(true);
|
||||
setDesktopServerUrl("https://ctms.example.com");
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const DESKTOP_SERVER_URL_KEY = "ctms_desktop_server_url";
|
||||
export const DESKTOP_SERVER_URL_CHANGED_EVENT = "ctms:desktop-server-url-changed";
|
||||
|
||||
export type DesktopServerUrlValidationResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
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: "请输入服务器地址" };
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return { ok: false, reason: "服务器地址格式不正确" };
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return { ok: false, reason: "服务器地址不能包含用户名或密码" };
|
||||
}
|
||||
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const isLocalHttp = parsed.protocol === "http:" && LOCAL_HTTP_HOSTS.has(parsed.hostname);
|
||||
if (!isHttps && !isLocalHttp) {
|
||||
return { ok: false, reason: "非本地服务必须使用 HTTPS" };
|
||||
}
|
||||
|
||||
return { ok: true, url: `${parsed.origin}/` };
|
||||
};
|
||||
|
||||
export const getDesktopServerUrl = (): string | null => {
|
||||
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
|
||||
if (!stored) return null;
|
||||
const result = normalizeDesktopServerUrl(stored);
|
||||
return result.ok ? result.url : null;
|
||||
};
|
||||
|
||||
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();
|
||||
storage.setItem(DESKTOP_SERVER_URL_KEY, result.url);
|
||||
emitServerUrlChanged(previous, result.url);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const clearDesktopServerUrl = (): void => {
|
||||
const previous = getDesktopServerUrl();
|
||||
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
|
||||
emitServerUrlChanged(previous, null);
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export interface FilePickerOptions {
|
||||
multiple?: boolean;
|
||||
accept?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface FileOutput {
|
||||
suggestedName: string;
|
||||
mimeType?: string;
|
||||
data: Blob | ArrayBuffer | Uint8Array | string;
|
||||
}
|
||||
|
||||
export type SaveFileResult = "saved" | "cancelled";
|
||||
|
||||
const sanitizeFileName = (value: string): string => {
|
||||
const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim();
|
||||
return normalized.slice(0, 180) || "download";
|
||||
};
|
||||
|
||||
const extensionFilters = (accept: string[] | undefined) => {
|
||||
if (!accept?.length) return undefined;
|
||||
const extensions = accept
|
||||
.map((value) => value.trim().replace(/^\./, ""))
|
||||
.filter((value) => value && !value.includes("/") && /^[a-zA-Z0-9]+$/.test(value));
|
||||
return extensions.length ? [{ name: "可选文件", extensions }] : undefined;
|
||||
};
|
||||
|
||||
const toBlobPart = (data: FileOutput["data"]): BlobPart => {
|
||||
if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) return data;
|
||||
const copy = new Uint8Array(data.byteLength);
|
||||
copy.set(data);
|
||||
return copy;
|
||||
};
|
||||
|
||||
const toBlob = (output: FileOutput): Blob =>
|
||||
output.data instanceof Blob
|
||||
? output.data
|
||||
: new Blob([toBlobPart(output.data)], { type: output.mimeType || "application/octet-stream" });
|
||||
|
||||
const toBytes = async (output: FileOutput): Promise<Uint8Array> =>
|
||||
new Uint8Array(await toBlob(output).arrayBuffer());
|
||||
|
||||
const webPickFiles = (options: FilePickerOptions): Promise<File[]> =>
|
||||
new Promise((resolve) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = Boolean(options.multiple);
|
||||
input.accept = options.accept?.map((item) => (item.includes("/") ? item : `.${item.replace(/^\./, "")}`)).join(",") || "";
|
||||
input.style.display = "none";
|
||||
input.addEventListener(
|
||||
"change",
|
||||
() => {
|
||||
resolve(Array.from(input.files || []));
|
||||
input.remove();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
});
|
||||
|
||||
export const pickFiles = async (options: FilePickerOptions = {}): Promise<File[]> => {
|
||||
if (!isTauriRuntime()) return webPickFiles(options);
|
||||
const [{ open }, { readFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const selected = await open({
|
||||
title: options.title,
|
||||
multiple: Boolean(options.multiple),
|
||||
directory: false,
|
||||
filters: extensionFilters(options.accept),
|
||||
});
|
||||
const paths = selected ? (Array.isArray(selected) ? selected : [selected]) : [];
|
||||
return Promise.all(
|
||||
paths.map(async (path) => {
|
||||
const bytes = await readFile(path);
|
||||
const name = path.split(/[\\/]/).pop() || "upload";
|
||||
return new File([bytes], name, { lastModified: Date.now() });
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
|
||||
const suggestedName = sanitizeFileName(output.suggestedName);
|
||||
if (!isTauriRuntime()) {
|
||||
const url = URL.createObjectURL(toBlob(output));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = suggestedName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
return "saved";
|
||||
}
|
||||
|
||||
const [{ save }, { writeFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const path = await save({ defaultPath: suggestedName });
|
||||
if (!path) return "cancelled";
|
||||
await writeFile(path, await toBytes(output));
|
||||
return "saved";
|
||||
};
|
||||
|
||||
export const openFile = async (output: FileOutput): Promise<void> => {
|
||||
if (!isTauriRuntime()) {
|
||||
const url = URL.createObjectURL(toBlob(output));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const [{ tempDir, join }, { mkdir, writeFile }, { openPath }] = await Promise.all([
|
||||
import("@tauri-apps/api/path"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
import("@tauri-apps/plugin-opener"),
|
||||
]);
|
||||
const root = await join(await tempDir(), "ctms-desktop");
|
||||
const directory = await join(root, crypto.randomUUID());
|
||||
await mkdir(directory, { recursive: true });
|
||||
const path = await join(directory, sanitizeFileName(output.suggestedName));
|
||||
await writeFile(path, await toBytes(output));
|
||||
await openPath(path);
|
||||
};
|
||||
|
||||
export const cleanupTemporaryFiles = async (): Promise<void> => {
|
||||
if (!isTauriRuntime()) return;
|
||||
const [{ tempDir, join }, { exists, remove }] = await Promise.all([
|
||||
import("@tauri-apps/api/path"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const root = await join(await tempDir(), "ctms-desktop");
|
||||
if (await exists(root)) await remove(root, { recursive: true });
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
export { clientRuntime, type ClientRuntime, type RuntimeCapabilities } from "./clientRuntime";
|
||||
export {
|
||||
clearDesktopServerUrl,
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
getDesktopServerUrl,
|
||||
hasDesktopServerUrl,
|
||||
normalizeDesktopServerUrl,
|
||||
setDesktopServerUrl,
|
||||
shouldRequireDesktopServerUrl,
|
||||
} 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 {
|
||||
cleanupTemporaryFiles,
|
||||
openFile,
|
||||
pickFiles,
|
||||
saveFile,
|
||||
type FileOutput,
|
||||
type FilePickerOptions,
|
||||
type SaveFileResult,
|
||||
} from "./files";
|
||||
export {
|
||||
getNotificationPermission,
|
||||
requestNotificationPermission,
|
||||
showSystemNotification,
|
||||
type NotificationPermissionState,
|
||||
} from "./notifications";
|
||||
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
|
||||
export {
|
||||
clearSessionToken,
|
||||
getSessionToken,
|
||||
initializeSecureSessionStorage,
|
||||
isSecureSessionStorageAvailable,
|
||||
setSessionToken,
|
||||
} from "./secureSessionStorage";
|
||||
export {
|
||||
checkForDesktopUpdate,
|
||||
installPendingDesktopUpdate,
|
||||
isDesktopUpdaterAvailable,
|
||||
type DesktopUpdateInfo,
|
||||
} from "./updates";
|
||||
@@ -0,0 +1,25 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export type NotificationPermissionState = "granted" | "denied" | "prompt" | "unsupported";
|
||||
|
||||
export const getNotificationPermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isTauriRuntime()) return "unsupported";
|
||||
const { isPermissionGranted } = await import("@tauri-apps/plugin-notification");
|
||||
return (await isPermissionGranted()) ? "granted" : "prompt";
|
||||
};
|
||||
|
||||
export const requestNotificationPermission = async (): Promise<NotificationPermissionState> => {
|
||||
if (!isTauriRuntime()) return "unsupported";
|
||||
const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification");
|
||||
if (await isPermissionGranted()) return "granted";
|
||||
return (await requestPermission()) === "granted" ? "granted" : "denied";
|
||||
};
|
||||
|
||||
export const showSystemNotification = async (): Promise<void> => {
|
||||
if (!isTauriRuntime()) return;
|
||||
const { sendNotification } = await import("@tauri-apps/plugin-notification");
|
||||
sendNotification({
|
||||
title: "CTMS 文件更新",
|
||||
body: "有新的文件版本待查看",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
export type RuntimePlatform = "web" | "macos" | "windows" | "linux";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI__?: unknown;
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const isTauriRuntime = (): boolean =>
|
||||
typeof window !== "undefined" && Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
|
||||
|
||||
export const getRuntimePlatform = (): RuntimePlatform => {
|
||||
if (!isTauriRuntime() || typeof navigator === "undefined") return "web";
|
||||
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (platform.includes("mac") || userAgent.includes("mac os x")) return "macos";
|
||||
if (platform.includes("win") || userAgent.includes("windows")) return "windows";
|
||||
return "linux";
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
const LEGACY_TOKEN_KEY = "ctms_token";
|
||||
|
||||
let cachedToken: string | null = null;
|
||||
let initialized = false;
|
||||
let activeServerOrigin: string | null = null;
|
||||
let secureStorageAvailable = false;
|
||||
|
||||
const invokeCredential = async <T>(
|
||||
command: "credential_get" | "credential_set" | "credential_delete",
|
||||
args: Record<string, string>,
|
||||
): Promise<T> => {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke<T>(command, args);
|
||||
};
|
||||
|
||||
const isUsableJwt = (token: string): boolean => {
|
||||
const segments = token.split(".");
|
||||
if (segments.length !== 3) return false;
|
||||
try {
|
||||
const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
|
||||
const payload = JSON.parse(atob(padded));
|
||||
return typeof payload.exp !== "number" || payload.exp * 1000 > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeSecureSessionStorage = async (): Promise<void> => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
cachedToken = window.localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
secureStorageAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyToken = window.localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
window.localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
activeServerOrigin = getDesktopServerUrl();
|
||||
if (!activeServerOrigin) {
|
||||
cachedToken = null;
|
||||
secureStorageAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (legacyToken && isUsableJwt(legacyToken)) {
|
||||
await invokeCredential<void>("credential_set", {
|
||||
serverOrigin: activeServerOrigin,
|
||||
token: legacyToken,
|
||||
});
|
||||
cachedToken = legacyToken;
|
||||
} else {
|
||||
cachedToken = await invokeCredential<string | null>("credential_get", {
|
||||
serverOrigin: activeServerOrigin,
|
||||
});
|
||||
}
|
||||
secureStorageAvailable = true;
|
||||
} catch (error) {
|
||||
cachedToken = null;
|
||||
secureStorageAvailable = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSessionToken = (): string | null => {
|
||||
if (!initialized && !isTauriRuntime() && typeof localStorage !== "undefined") {
|
||||
return localStorage.getItem(LEGACY_TOKEN_KEY);
|
||||
}
|
||||
return cachedToken;
|
||||
};
|
||||
|
||||
export const setSessionToken = async (token: string): Promise<void> => {
|
||||
if (!token) throw new Error("拒绝保存空 token");
|
||||
if (!isTauriRuntime()) {
|
||||
window.localStorage.setItem(LEGACY_TOKEN_KEY, token);
|
||||
cachedToken = token;
|
||||
initialized = true;
|
||||
secureStorageAvailable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const serverOrigin = getDesktopServerUrl();
|
||||
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
|
||||
await invokeCredential<void>("credential_set", { serverOrigin, token });
|
||||
activeServerOrigin = serverOrigin;
|
||||
cachedToken = token;
|
||||
initialized = true;
|
||||
secureStorageAvailable = true;
|
||||
};
|
||||
|
||||
export const clearSessionToken = async (): Promise<void> => {
|
||||
cachedToken = null;
|
||||
if (!isTauriRuntime()) {
|
||||
if (typeof localStorage !== "undefined") localStorage.removeItem(LEGACY_TOKEN_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const serverOrigin = activeServerOrigin || getDesktopServerUrl();
|
||||
activeServerOrigin = getDesktopServerUrl();
|
||||
if (!serverOrigin) return;
|
||||
await invokeCredential<void>("credential_delete", { serverOrigin });
|
||||
};
|
||||
|
||||
export const isSecureSessionStorageAvailable = (): boolean => secureStorageAvailable;
|
||||
|
||||
export const resetSecureSessionStorageForTests = (): void => {
|
||||
cachedToken = null;
|
||||
initialized = false;
|
||||
activeServerOrigin = null;
|
||||
secureStorageAvailable = false;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export interface DesktopUpdateInfo {
|
||||
version: string;
|
||||
currentVersion: string;
|
||||
notes?: string | null;
|
||||
date?: string | null;
|
||||
}
|
||||
|
||||
type TauriCore = typeof import("@tauri-apps/api/core");
|
||||
|
||||
const loadTauriCore = async (): Promise<TauriCore | null> => {
|
||||
if (!isTauriRuntime()) return null;
|
||||
return import("@tauri-apps/api/core");
|
||||
};
|
||||
|
||||
export const isDesktopUpdaterAvailable = (): boolean => {
|
||||
if (!isTauriRuntime()) return false;
|
||||
const metadata = getAppMetadata();
|
||||
return metadata.channel === "release" || import.meta.env.VITE_ENABLE_DESKTOP_UPDATES === "true";
|
||||
};
|
||||
|
||||
export const checkForDesktopUpdate = async (): Promise<DesktopUpdateInfo | null> => {
|
||||
if (!isDesktopUpdaterAvailable()) return null;
|
||||
const serverOrigin = getDesktopServerUrl();
|
||||
if (!serverOrigin) return null;
|
||||
const tauri = await loadTauriCore();
|
||||
if (!tauri) return null;
|
||||
return tauri.invoke<DesktopUpdateInfo | null>("desktop_update_check", { serverOrigin });
|
||||
};
|
||||
|
||||
export const installPendingDesktopUpdate = async (): Promise<void> => {
|
||||
if (!isDesktopUpdaterAvailable()) return;
|
||||
const tauri = await loadTauriCore();
|
||||
if (!tauri) return;
|
||||
await tauri.invoke("desktop_update_install");
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
acknowledgeDesktopNotifications,
|
||||
claimDesktopNotifications,
|
||||
getDesktopNotificationSubscription,
|
||||
} from "../api/desktopNotifications";
|
||||
import { getToken } from "../utils/auth";
|
||||
import {
|
||||
getNotificationPermission,
|
||||
isTauriRuntime,
|
||||
showSystemNotification,
|
||||
} from "../runtime";
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const MAX_BACKOFF_MS = 15 * 60_000;
|
||||
|
||||
let initialized = false;
|
||||
let timer: number | null = null;
|
||||
let failureCount = 0;
|
||||
|
||||
const schedule = (delay: number) => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void poll(), delay);
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
if (!getToken()) {
|
||||
failureCount = 0;
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const permission = await getNotificationPermission();
|
||||
if (permission !== "granted") {
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
const { data: subscription } = await getDesktopNotificationSubscription();
|
||||
if (!subscription.enabled) {
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
const { data } = await claimDesktopNotifications();
|
||||
const deliveredIds: string[] = [];
|
||||
for (const item of data.items) {
|
||||
await showSystemNotification();
|
||||
deliveredIds.push(item.id);
|
||||
}
|
||||
if (data.claim_token && deliveredIds.length) {
|
||||
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
|
||||
}
|
||||
failureCount = 0;
|
||||
schedule(POLL_INTERVAL_MS);
|
||||
} catch {
|
||||
failureCount += 1;
|
||||
schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS));
|
||||
}
|
||||
};
|
||||
|
||||
export const initDesktopNotificationManager = (): void => {
|
||||
if (initialized || !isTauriRuntime()) return;
|
||||
initialized = true;
|
||||
schedule(5_000);
|
||||
};
|
||||
|
||||
export const triggerDesktopNotificationPoll = (): void => {
|
||||
if (!initialized) return;
|
||||
failureCount = 0;
|
||||
schedule(0);
|
||||
};
|
||||
|
||||
export const stopDesktopNotificationManager = (): void => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = null;
|
||||
initialized = false;
|
||||
failureCount = 0;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
checkForDesktopUpdate,
|
||||
installPendingDesktopUpdate,
|
||||
isDesktopUpdaterAvailable,
|
||||
type DesktopUpdateInfo,
|
||||
} from "../runtime";
|
||||
|
||||
const INITIAL_CHECK_DELAY_MS = 30_000;
|
||||
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
|
||||
const POSTPONE_MS = 24 * 60 * 60 * 1000;
|
||||
const POSTPONE_PREFIX = "ctms_desktop_update_postponed:";
|
||||
|
||||
let initialized = false;
|
||||
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 => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(postponeKey(version));
|
||||
return raw ? Number(raw) || 0 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const postponeVersion = (version: string) => {
|
||||
try {
|
||||
window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now();
|
||||
|
||||
const releaseNotes = (update: DesktopUpdateInfo): string => {
|
||||
const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`];
|
||||
if (update.notes?.trim()) {
|
||||
lines.push("", "发布说明:", update.notes.trim());
|
||||
}
|
||||
lines.push("", "确认后将下载、验签、安装并重启应用。");
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdateCheckStatus> => {
|
||||
if (promptVisible || isSuppressed(update.version)) return "suppressed";
|
||||
promptVisible = true;
|
||||
try {
|
||||
await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", {
|
||||
confirmButtonText: "立即更新并重启",
|
||||
cancelButtonText: "稍后",
|
||||
distinguishCancelAndClose: true,
|
||||
type: "info",
|
||||
});
|
||||
await installPendingDesktopUpdate();
|
||||
return "available";
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") {
|
||||
postponeVersion(update.version);
|
||||
return "postponed";
|
||||
}
|
||||
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
|
||||
return "failed";
|
||||
} finally {
|
||||
promptVisible = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkDesktopUpdateAndPrompt = async (
|
||||
options: DesktopUpdateCheckOptions = {},
|
||||
): Promise<DesktopUpdateCheckStatus> => {
|
||||
if (!isDesktopUpdaterAvailable()) {
|
||||
if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新");
|
||||
return "disabled";
|
||||
}
|
||||
try {
|
||||
const update = await checkForDesktopUpdate();
|
||||
if (update) {
|
||||
return promptForUpdate(update);
|
||||
}
|
||||
if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本");
|
||||
return "up-to-date";
|
||||
} catch {
|
||||
// 启动和定时检查不打断录入;下一轮继续检查。
|
||||
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
export const initDesktopUpdateManager = () => {
|
||||
if (initialized || !isDesktopUpdaterAvailable()) return;
|
||||
initialized = true;
|
||||
checkTimer = window.setTimeout(() => {
|
||||
void checkDesktopUpdateAndPrompt();
|
||||
}, INITIAL_CHECK_DELAY_MS);
|
||||
intervalTimer = window.setInterval(() => {
|
||||
void checkDesktopUpdateAndPrompt();
|
||||
}, CHECK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
export const stopDesktopUpdateManager = () => {
|
||||
if (checkTimer) {
|
||||
window.clearTimeout(checkTimer);
|
||||
checkTimer = null;
|
||||
}
|
||||
if (intervalTimer) {
|
||||
window.clearInterval(intervalTimer);
|
||||
intervalTimer = null;
|
||||
}
|
||||
initialized = false;
|
||||
promptVisible = false;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import router from "../router";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useSessionStore } from "../store/session";
|
||||
import { getToken, setToken, clearToken } from "../utils/auth";
|
||||
import { getToken, setToken } from "../utils/auth";
|
||||
import { extendToken } from "../api/authClient";
|
||||
import { parseJwtExp } from "./jwt";
|
||||
|
||||
@@ -30,7 +30,7 @@ const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) =>
|
||||
const reconcileSessionState = (now: number = Date.now()) => {
|
||||
const session = useSessionStore();
|
||||
if (now >= getTimeoutAt(session)) {
|
||||
forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
void forceLogout(LOGOUT_REASON_TIMEOUT);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -56,11 +56,11 @@ const handleBroadcast = (message: BroadcastMessage) => {
|
||||
}
|
||||
if (message.type === "TOKEN_UPDATED") {
|
||||
auth.setToken(message.token);
|
||||
setToken(message.token);
|
||||
void setToken(message.token);
|
||||
session.resetActivity();
|
||||
}
|
||||
if (message.type === "LOGOUT") {
|
||||
performLogout(message.reason);
|
||||
void performLogout(message.reason);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,25 +156,25 @@ export const consumeLogoutReason = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const performLogout = (reason?: string) => {
|
||||
const performLogout = async (reason?: string) => {
|
||||
setLogoutReason(reason);
|
||||
const auth = useAuthStore();
|
||||
const session = useSessionStore();
|
||||
auth.logout();
|
||||
const logoutPromise = auth.logout();
|
||||
session.resetActivity();
|
||||
clearToken();
|
||||
router.replace("/login");
|
||||
await logoutPromise;
|
||||
};
|
||||
|
||||
export const forceLogout = (reason?: string) => {
|
||||
performLogout(reason);
|
||||
export const forceLogout = async (reason?: string) => {
|
||||
await performLogout(reason);
|
||||
broadcast({ type: "LOGOUT", reason });
|
||||
};
|
||||
|
||||
const updateToken = (token: string) => {
|
||||
const updateToken = async (token: string) => {
|
||||
const auth = useAuthStore();
|
||||
auth.setToken(token);
|
||||
setToken(token);
|
||||
await setToken(token);
|
||||
broadcast({ type: "TOKEN_UPDATED", token });
|
||||
};
|
||||
|
||||
@@ -194,15 +194,15 @@ export const extendAccessToken = async (reason: "early" | "response-401") => {
|
||||
try {
|
||||
const { data } = await extendToken(token);
|
||||
session.setLastExtendAt(Date.now());
|
||||
updateToken(data.accessToken);
|
||||
await updateToken(data.accessToken);
|
||||
return { token: data.accessToken, authFailed: false };
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 401) {
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
authFailed = true;
|
||||
} else if (status === 403) {
|
||||
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
|
||||
authFailed = true;
|
||||
}
|
||||
return { token: null, authFailed };
|
||||
|
||||
@@ -92,7 +92,7 @@ describe("auth store logout", () => {
|
||||
session.recordUserActivity(staleTs);
|
||||
session.setLastExtendAt(staleTs);
|
||||
session.showTimeoutWarning(staleTs + 30_000);
|
||||
auth.logout();
|
||||
await auth.logout();
|
||||
|
||||
expect(session.lastUserActiveAt).toBeGreaterThan(staleTs);
|
||||
expect(session.lastExtendAt).toBe(0);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
? await devLogin({ email, password })
|
||||
: await encryptedLogin(email, password);
|
||||
token.value = data.access_token;
|
||||
setToken(data.access_token);
|
||||
await setToken(data.access_token);
|
||||
useSessionStore().resetActivity();
|
||||
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
|
||||
const me = await fetchMeAction();
|
||||
@@ -62,7 +62,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
const studyStore = useStudyStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
|
||||
@@ -71,7 +71,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
user.value = null;
|
||||
forceLogin.value = false;
|
||||
sessionStore.resetActivity();
|
||||
clearToken();
|
||||
await clearToken();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
|
||||
@@ -70,6 +70,54 @@
|
||||
--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,
|
||||
body,
|
||||
@@ -85,6 +133,90 @@ body {
|
||||
-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 全局覆盖 */
|
||||
|
||||
/* 按钮美化 */
|
||||
|
||||
@@ -376,6 +376,11 @@ export interface SecurityAccessLogItem {
|
||||
ip_city: string;
|
||||
ip_isp: string;
|
||||
user_agent: string | null;
|
||||
client_type: string | null;
|
||||
client_version: string | null;
|
||||
client_platform: string | null;
|
||||
build_channel: string | null;
|
||||
build_commit: string | null;
|
||||
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
|
||||
user_identifier: string | null;
|
||||
account_label: string;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
@@ -8,4 +8,8 @@ export interface NotificationItem {
|
||||
change_summary?: string | null;
|
||||
effective_at?: string | null;
|
||||
created_at: string;
|
||||
study_id?: string | null;
|
||||
study_name?: string | null;
|
||||
delivered_at?: string | null;
|
||||
read_at?: string | null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
const TOKEN_KEY = "ctms_token";
|
||||
import { clearSessionToken, getSessionToken, setSessionToken } from "../runtime";
|
||||
|
||||
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
|
||||
export const getToken = (): string | null => getSessionToken();
|
||||
|
||||
export const setToken = (token: string): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
};
|
||||
export const setToken = (token: string): Promise<void> => setSessionToken(token);
|
||||
|
||||
export const clearToken = (): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
};
|
||||
export const clearToken = (): Promise<void> => clearSessionToken();
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="desktop-settings-page">
|
||||
<section class="settings-panel">
|
||||
<div class="panel-header">
|
||||
<p class="eyebrow">CTMS Desktop</p>
|
||||
<h1>服务器设置</h1>
|
||||
<p class="description">配置桌面客户端要连接的 CTMS 服务端入口。业务数据仍由服务端统一保存和裁决。</p>
|
||||
</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-item label="服务器地址" :error="urlError">
|
||||
<el-input
|
||||
v-model.trim="serverUrl"
|
||||
size="large"
|
||||
placeholder="https://ctms.example.com"
|
||||
autocomplete="url"
|
||||
@keyup.enter="save"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="hint">
|
||||
允许 HTTPS 服务地址;本地开发可使用 http://localhost 或 http://127.0.0.1。
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
|
||||
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
|
||||
保存并检查连接
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
|
||||
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 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);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSessionForServerChange = async () => {
|
||||
await auth.logout();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
urlError.value = "";
|
||||
connectionStatus.value = null;
|
||||
const normalized = normalizeDesktopServerUrl(serverUrl.value);
|
||||
if (!normalized.ok) {
|
||||
urlError.value = normalized.reason;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await checkHealth(normalized.url);
|
||||
const previous = getDesktopServerUrl();
|
||||
const result = setDesktopServerUrl(normalized.url);
|
||||
if (!result.ok) {
|
||||
urlError.value = result.reason;
|
||||
return;
|
||||
}
|
||||
if (previous !== result.url) {
|
||||
await clearSessionForServerChange();
|
||||
}
|
||||
connectionStatus.value = {
|
||||
type: "success",
|
||||
title: "连接已确认",
|
||||
message: result.url,
|
||||
};
|
||||
ElMessage.success("服务器连接已确认");
|
||||
router.replace("/login");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
|
||||
connectionStatus.value = {
|
||||
type: "error",
|
||||
title: "连接检查失败",
|
||||
message,
|
||||
};
|
||||
urlError.value = message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-settings-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
width: min(100%, 520px);
|
||||
padding: 32px;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 12px 0 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
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;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -95,4 +95,16 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
||||
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>
|
||||
</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 class="ln-icon">
|
||||
@@ -153,22 +159,14 @@
|
||||
<span class="email-at-sign">@</span>
|
||||
<div class="email-domain-wrapper">
|
||||
<select
|
||||
v-if="showDomainSelect"
|
||||
v-model="form.emailDomain"
|
||||
class="email-domain-field"
|
||||
aria-label="邮箱域名"
|
||||
:disabled="availableEmailDomains.length === 0"
|
||||
>
|
||||
<option v-for="domain in availableEmailDomains" :key="domain" :value="domain">{{ domain }}</option>
|
||||
</select>
|
||||
<input
|
||||
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>
|
||||
<svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -195,7 +193,7 @@
|
||||
<span>登 录</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 注册与忘记密码(左右分立) -->
|
||||
<!-- 忘记密码与注册 -->
|
||||
<div class="forgot-register-row">
|
||||
<RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink>
|
||||
<RouterLink to="/register" class="register-link">新用户注册</RouterLink>
|
||||
@@ -246,7 +244,7 @@
|
||||
</template>
|
||||
|
||||
<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 { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
// 从 package.json 读取版本号,构建时由 Vite 注入
|
||||
@@ -257,6 +255,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchEmailDomains } from "../api/auth";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime";
|
||||
import {
|
||||
consumeLogoutReason,
|
||||
LOGOUT_REASON_AUTH_EXPIRED,
|
||||
@@ -272,7 +271,6 @@ const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false });
|
||||
const configuredEmailDomains = ref<string[]>([]);
|
||||
const preservedEmailDomain = ref("");
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
email: [
|
||||
@@ -287,15 +285,15 @@ const protocolDialogVisible = ref(false);
|
||||
const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: string } | null>(null);
|
||||
const loginError = ref<{ title: string; message?: string } | null>(null);
|
||||
const protocolSections = authProtocolSections;
|
||||
const showDesktopServerSettings = isTauriRuntime();
|
||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||
|
||||
const refreshDesktopServerUrl = () => {
|
||||
desktopServerUrl.value = getDesktopServerUrl();
|
||||
};
|
||||
|
||||
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
||||
const availableEmailDomains = computed(() => Array.from(new Set([
|
||||
...configuredEmailDomains.value,
|
||||
preservedEmailDomain.value,
|
||||
].filter(Boolean))));
|
||||
const showDomainSelect = computed(
|
||||
() => configuredEmailDomains.value.length > 0 || Boolean(preservedEmailDomain.value)
|
||||
);
|
||||
const availableEmailDomains = computed(() => configuredEmailDomains.value);
|
||||
|
||||
const syncEmailFromParts = () => {
|
||||
const local = form.emailLocal.trim().toLowerCase();
|
||||
@@ -309,14 +307,13 @@ const applyEmailValue = (email: string) => {
|
||||
const separator = normalized.lastIndexOf("@");
|
||||
if (separator > 0) {
|
||||
form.emailLocal = normalized.slice(0, separator);
|
||||
form.emailDomain = normalizeDomain(normalized.slice(separator + 1));
|
||||
preservedEmailDomain.value = configuredEmailDomains.value.includes(form.emailDomain)
|
||||
? ""
|
||||
: form.emailDomain;
|
||||
const domain = normalizeDomain(normalized.slice(separator + 1));
|
||||
form.emailDomain = configuredEmailDomains.value.includes(domain)
|
||||
? domain
|
||||
: configuredEmailDomains.value[0] || "";
|
||||
} else {
|
||||
form.emailLocal = normalized;
|
||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||
preservedEmailDomain.value = "";
|
||||
}
|
||||
syncEmailFromParts();
|
||||
};
|
||||
@@ -327,8 +324,12 @@ const loadEmailDomains = async () => {
|
||||
configuredEmailDomains.value = Array.from(new Set(
|
||||
data.items.map(normalizeDomain).filter(Boolean)
|
||||
));
|
||||
if (!configuredEmailDomains.value.includes(form.emailDomain)) {
|
||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||
}
|
||||
} catch {
|
||||
configuredEmailDomains.value = [];
|
||||
form.emailDomain = "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -340,6 +341,7 @@ const handleAccountPaste = (event: ClipboardEvent) => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||
await loadEmailDomains();
|
||||
const reason = consumeLogoutReason();
|
||||
if (reason === LOGOUT_REASON_TIMEOUT) {
|
||||
@@ -353,7 +355,11 @@ onMounted(async () => {
|
||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||
});
|
||||
|
||||
watch(() => form.agreeProtocol, (v) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(v)));
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||
});
|
||||
|
||||
watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
|
||||
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
|
||||
|
||||
const openProtocolDialog = () => { protocolDialogVisible.value = true; };
|
||||
@@ -371,7 +377,11 @@ const onSubmit = async () => {
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = auth.user?.email || form.email;
|
||||
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
|
||||
router.push(studyStore.currentStudy ? "/project/overview" : auth.user?.is_admin ? "/admin/users" : "/admin/projects");
|
||||
if (studyStore.currentStudy) {
|
||||
router.push("/project/overview");
|
||||
} else {
|
||||
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
const detail: string = error?.response?.data?.detail || error?.response?.data?.message || "";
|
||||
@@ -704,8 +714,8 @@ const onSubmit = async () => {
|
||||
|
||||
/* 卡片容器 */
|
||||
.login-card-container {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
width: clamp(480px, 34vw, 560px);
|
||||
max-width: calc(100vw - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -777,6 +787,42 @@ const onSubmit = async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
/* ═══════════════════════
|
||||
通知与错误提示
|
||||
═══════════════════════ */
|
||||
@@ -919,8 +965,7 @@ const onSubmit = async () => {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.email-local-field::placeholder,
|
||||
.email-domain-input::placeholder {
|
||||
.email-local-field::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@@ -952,10 +997,9 @@ const onSubmit = async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.email-domain-input {
|
||||
width: 150px;
|
||||
padding-right: 4px;
|
||||
cursor: text;
|
||||
.email-domain-field:disabled {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.email-domain-chevron {
|
||||
|
||||
@@ -10,19 +10,9 @@
|
||||
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
|
||||
<div class="avatar-email">{{ form.email }}</div>
|
||||
</div>
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
action="/api/v1/auth/me/avatar"
|
||||
name="file"
|
||||
:headers="uploadHeaders"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
:on-success="onAvatarUploaded"
|
||||
:on-error="onAvatarError"
|
||||
>
|
||||
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
|
||||
</el-upload>
|
||||
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
|
||||
{{ TEXT.modules.profile.uploadAvatar }}
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -74,6 +64,45 @@
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="form-section form-section--desktop">
|
||||
<div class="section-heading">
|
||||
<span class="section-kicker">Desktop</span>
|
||||
<h4>客户端与通知</h4>
|
||||
</div>
|
||||
<el-form-item v-if="isDesktop" label="系统通知">
|
||||
<div class="desktop-setting-stack">
|
||||
<div class="desktop-setting-row">
|
||||
<el-switch
|
||||
v-model="desktopNotificationsEnabled"
|
||||
:loading="desktopNotificationLoading"
|
||||
@change="onDesktopNotificationChange"
|
||||
/>
|
||||
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
|
||||
</div>
|
||||
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isDesktop && desktopUpdaterAvailable" label="桌面更新">
|
||||
<div class="desktop-setting-row">
|
||||
<el-button size="small" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
|
||||
检查更新
|
||||
</el-button>
|
||||
<span class="desktop-setting-hint">正式版本会按发布源检查签名更新</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户端信息">
|
||||
<div class="client-metadata-panel">
|
||||
<dl class="client-metadata-list">
|
||||
<template v-for="row in clientMetadataRows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<el-button size="small" @click="copyClientMetadata">复制</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
@@ -85,12 +114,28 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close, Upload } from "@element-plus/icons-vue";
|
||||
import { updateProfile, fetchMe } from "../api/auth";
|
||||
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
|
||||
import {
|
||||
getDesktopNotificationSubscription,
|
||||
setDesktopNotificationSubscription,
|
||||
} from "../api/desktopNotifications";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { getToken } from "../utils/auth";
|
||||
import {
|
||||
clientRuntime,
|
||||
getAppMetadata,
|
||||
getDesktopServerUrl,
|
||||
getNotificationPermission,
|
||||
isDesktopUpdaterAvailable,
|
||||
isTauriRuntime,
|
||||
pickFiles,
|
||||
requestNotificationPermission,
|
||||
type NotificationPermissionState,
|
||||
} from "../runtime";
|
||||
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
||||
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -99,6 +144,31 @@ const emit = defineEmits<{
|
||||
saved: [];
|
||||
}>();
|
||||
const auth = useAuthStore();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const clientMetadata = getAppMetadata();
|
||||
const desktopCapabilities = clientRuntime.capabilities();
|
||||
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
||||
const clientMetadataRows = [
|
||||
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
|
||||
{ label: "平台", value: clientMetadata.platform },
|
||||
{ label: "构建通道", value: clientMetadata.channel },
|
||||
{ label: "提交", value: clientMetadata.commit },
|
||||
{ label: "服务器", value: getDesktopServerUrl() || "未配置" },
|
||||
{
|
||||
label: "能力",
|
||||
value: [
|
||||
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
|
||||
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
|
||||
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
|
||||
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
|
||||
].join(" / "),
|
||||
},
|
||||
];
|
||||
const clientMetadataText = clientMetadataRows.map((row) => `${row.label}: ${row.value}`).join("\n");
|
||||
const desktopNotificationsEnabled = ref(false);
|
||||
const desktopNotificationLoading = ref(false);
|
||||
const desktopUpdateChecking = ref(false);
|
||||
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
@@ -114,9 +184,6 @@ const savedProfile = ref({
|
||||
clinical_department: "",
|
||||
});
|
||||
const avatarPreview = ref<string | undefined>();
|
||||
const uploadHeaders = computed<Record<string, string>>(() => ({
|
||||
Authorization: `Bearer ${getToken() || ""}`,
|
||||
}));
|
||||
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
|
||||
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||
const hasUnsavedChanges = computed(
|
||||
@@ -180,6 +247,72 @@ const loadProfile = async () => {
|
||||
avatarPreview.value = data.avatar_url || undefined;
|
||||
};
|
||||
|
||||
const loadDesktopNotificationSubscription = async () => {
|
||||
if (!isDesktop) return;
|
||||
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
|
||||
const { data } = await getDesktopNotificationSubscription();
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
};
|
||||
|
||||
const notificationPermissionText = computed(() => {
|
||||
if (!isDesktop) return "不可用";
|
||||
if (notificationPermission.value === "granted") return "系统已允许";
|
||||
if (notificationPermission.value === "denied") return "系统已拒绝";
|
||||
if (notificationPermission.value === "prompt") return "等待授权";
|
||||
return "不可用";
|
||||
});
|
||||
|
||||
const notificationPermissionTagType = computed<"success" | "warning" | "danger" | "info">(() => {
|
||||
if (notificationPermission.value === "granted") return "success";
|
||||
if (notificationPermission.value === "denied") return "danger";
|
||||
if (notificationPermission.value === "prompt") return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
||||
if (!isDesktop || desktopNotificationLoading.value) return;
|
||||
desktopNotificationLoading.value = true;
|
||||
try {
|
||||
const enable = Boolean(value);
|
||||
if (enable) {
|
||||
const permission = await requestNotificationPermission();
|
||||
notificationPermission.value = permission;
|
||||
if (permission !== "granted") {
|
||||
desktopNotificationsEnabled.value = false;
|
||||
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { data } = await setDesktopNotificationSubscription(enable);
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
if (data.enabled) triggerDesktopNotificationPoll();
|
||||
} catch (error: any) {
|
||||
desktopNotificationsEnabled.value = !Boolean(value);
|
||||
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
|
||||
} finally {
|
||||
desktopNotificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyClientMetadata = async () => {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
ElMessage.warning("当前环境无法访问剪贴板");
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(clientMetadataText);
|
||||
ElMessage.success("客户端信息已复制");
|
||||
};
|
||||
|
||||
const checkDesktopUpdateNow = async () => {
|
||||
if (desktopUpdateChecking.value) return;
|
||||
desktopUpdateChecking.value = true;
|
||||
try {
|
||||
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||
} finally {
|
||||
desktopUpdateChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
@@ -211,24 +344,31 @@ const onSubmit = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onAvatarUploaded = async () => {
|
||||
await auth.fetchMe();
|
||||
avatarPreview.value = auth.user?.avatar_url || undefined;
|
||||
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
||||
};
|
||||
|
||||
const beforeAvatarUpload = (file: UploadRawFile) => {
|
||||
if (avatarAcceptedTypes.has(file.type)) return true;
|
||||
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
||||
return false;
|
||||
};
|
||||
|
||||
const onAvatarError = (err: any) => {
|
||||
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
||||
const selectAndUploadAvatar = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||
title: TEXT.modules.profile.uploadAvatar,
|
||||
});
|
||||
if (!file) return;
|
||||
const extensionAllowed = /\.(png|jpe?g|gif|webp)$/i.test(file.name);
|
||||
if (!avatarAcceptedTypes.has(file.type) && !extensionAllowed) {
|
||||
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
await auth.fetchMe();
|
||||
avatarPreview.value = auth.user?.avatar_url || undefined;
|
||||
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile();
|
||||
loadDesktopNotificationSubscription().catch(() => {});
|
||||
});
|
||||
|
||||
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
|
||||
@@ -343,6 +483,59 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.form-section--desktop {
|
||||
margin-top: 26px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.desktop-setting-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.desktop-setting-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.desktop-setting-hint {
|
||||
color: #7f92ad;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-metadata-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-metadata-list {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 6px 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #40566f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-metadata-list dt {
|
||||
color: #7f92ad;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.client-metadata-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 18px;
|
||||
padding-left: 112px;
|
||||
|
||||
@@ -449,12 +449,14 @@ const loadEmailDomains = async () => {
|
||||
try {
|
||||
const { data } = await fetchEmailDomains();
|
||||
emailDomains.value = normalizeDomains(data.items);
|
||||
if (!form.emailDomain && emailDomains.value.length > 0) {
|
||||
form.emailDomain = emailDomains.value[0];
|
||||
syncEmailFromParts();
|
||||
if (!emailDomains.value.includes(form.emailDomain)) {
|
||||
form.emailDomain = emailDomains.value[0] || "";
|
||||
}
|
||||
syncEmailFromParts();
|
||||
} catch {
|
||||
emailDomains.value = [];
|
||||
form.emailDomain = "";
|
||||
syncEmailFromParts();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<el-skeleton v-if="loading.notifications" :rows="3" animated />
|
||||
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
|
||||
<div v-else class="notification-list">
|
||||
<div class="notification-item" v-for="item in notifications" :key="item.id">
|
||||
<div class="notification-item" :class="{ 'is-unread': !item.read_at }" v-for="item in notifications" :key="item.id">
|
||||
<div class="notification-main">
|
||||
<div class="notification-title">
|
||||
<span class="notification-doc">{{ item.document_title }}</span>
|
||||
@@ -87,7 +87,7 @@
|
||||
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button link type="primary" @click="openDocument(item.document_id)">
|
||||
<el-button link type="primary" @click="openDocument(item)">
|
||||
{{ TEXT.common.actions.view }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -111,6 +111,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
|
||||
import { listNotifications } from "../api/notifications";
|
||||
import { markDesktopNotificationRead } from "../api/desktopNotifications";
|
||||
import KpiCard from "../components/KpiCard.vue";
|
||||
import QuickActions from "../components/QuickActions.vue";
|
||||
import { List, Warning, Money } from "@element-plus/icons-vue";
|
||||
@@ -202,8 +203,10 @@ const formatDate = (value?: string | null) => {
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
const openDocument = (documentId: string) => {
|
||||
router.push(`/documents/${documentId}`);
|
||||
const openDocument = async (item: NotificationItem) => {
|
||||
await markDesktopNotificationRead(item.id).catch(() => {});
|
||||
item.read_at = new Date().toISOString();
|
||||
router.push(`/documents/${item.document_id}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1954,6 +1954,7 @@ import {
|
||||
type SetupWorkflowTagMeta,
|
||||
} from "../../utils/setupPublishWorkflow";
|
||||
import { useSetupConfig } from "../../composables/useSetupConfig";
|
||||
import { saveFile } from "../../runtime";
|
||||
|
||||
type SetupStepKey =
|
||||
| "project-info"
|
||||
@@ -5174,7 +5175,7 @@ const buildExcelWorksheetXml = (
|
||||
return `<Worksheet ss:Name="${excelEscapeXml(sheetName)}"><Table>${metaXml}<Row/>${headerXml}${dataXml}</Table></Worksheet>`;
|
||||
};
|
||||
|
||||
const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
|
||||
const downloadVersionRaw = async (versionItem: StudySetupConfigVersionItem) => {
|
||||
if (!project.value) return;
|
||||
const displayVersion = getDisplayVersionLabel(versionItem.version);
|
||||
const branchName = versionItem.branch_name || "main";
|
||||
@@ -5342,16 +5343,13 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
|
||||
</Workbook>`;
|
||||
|
||||
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
|
||||
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
await saveFile({
|
||||
suggestedName: filename,
|
||||
mimeType: "application/vnd.ms-excel;charset=utf-8",
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
const removeVersion = async (targetVersion: number) => {
|
||||
|
||||
@@ -4,7 +4,11 @@ import { resolve } from "node:path";
|
||||
|
||||
const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "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", () => {
|
||||
it("shows project management to all signed-in users while keeping system operations admin-only", () => {
|
||||
|
||||
@@ -121,7 +121,10 @@
|
||||
{{ TEXT.common.labels.preview }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.download }}
|
||||
另存为
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openVersion(row)" v-if="canReadDocument">
|
||||
打开
|
||||
</el-button>
|
||||
<el-button v-if="canDeleteDocument" link type="danger" size="small" @click="confirmDeleteVersion(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
@@ -261,8 +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>
|
||||
上传文件
|
||||
</div>
|
||||
<div class="upload-zone" :class="{ 'has-file': uploadFile }" @click="triggerFileInput">
|
||||
<input type="file" ref="fileInputRef" @change="onFileChange" class="file-input-hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.png,.jpg" />
|
||||
<div
|
||||
class="upload-zone"
|
||||
:class="{ 'has-file': uploadFile }"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleUploadDrop"
|
||||
>
|
||||
<template v-if="!uploadFile">
|
||||
<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>
|
||||
@@ -342,7 +350,7 @@
|
||||
</template>
|
||||
|
||||
<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 { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
||||
@@ -370,6 +378,8 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import { openFile, pickFiles, saveFile } from "../../runtime";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
@@ -377,6 +387,7 @@ const study = useStudyStore();
|
||||
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
const { can } = usePermission();
|
||||
const documentId = computed(() => route.params.id as string);
|
||||
let desktopRefreshCleanup: (() => void) | undefined;
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
@@ -494,7 +505,6 @@ const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
|
||||
const uploadVisible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadFormRef = ref<FormInstance>();
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadForm = reactive({
|
||||
version_no: "",
|
||||
version_date: "",
|
||||
@@ -513,8 +523,19 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||
: null,
|
||||
}));
|
||||
|
||||
const triggerFileInput = () => { fileInputRef.value?.click(); };
|
||||
const removeFile = () => { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = ""; };
|
||||
const triggerFileInput = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
|
||||
title: "选择文档版本",
|
||||
});
|
||||
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 distributeVisible = ref(false);
|
||||
const distributing = ref(false);
|
||||
@@ -706,11 +727,6 @@ const openUpload = () => {
|
||||
uploadVisible.value = true;
|
||||
};
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) uploadFile.value = target.files[0];
|
||||
};
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||
if (!uploadFormRef.value) return;
|
||||
@@ -797,13 +813,25 @@ const downloadVersion = async (version: DocumentVersion) => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a"); link.href = url; link.download = filename;
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
|
||||
};
|
||||
|
||||
const openVersion = async (version: DocumentVersion) => {
|
||||
try {
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
await openFile({
|
||||
suggestedName: filename,
|
||||
mimeType: contentType,
|
||||
data: new Blob([response.data], { type: contentType }),
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteVersion = async (version: DocumentVersion) => {
|
||||
if (!version?.id) return;
|
||||
if (!canDeleteDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||
@@ -871,9 +899,14 @@ watch(previewVisible, (visible) => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
desktopRefreshCleanup = onDesktopRefreshCurrentView(loadDetail);
|
||||
await loadRoleTemplates();
|
||||
await loadDetail();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
desktopRefreshCleanup?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
</template>
|
||||
|
||||
<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 { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
@@ -178,6 +178,7 @@ import type { DocumentSummary } from "../../types/documents";
|
||||
import type { Site } from "../../types/api";
|
||||
import { displayDateTime, displayEnum, displayText } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -191,6 +192,7 @@ const editorVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingDocumentId = ref("");
|
||||
const editorFormRef = ref<FormInstance>();
|
||||
let desktopRefreshCleanup: (() => void) | undefined;
|
||||
|
||||
const trialId = computed(() => (route.params.trialId as string) || "");
|
||||
|
||||
@@ -436,6 +438,14 @@ onMounted(() => {
|
||||
ensureTrialRoute();
|
||||
load();
|
||||
loadSites();
|
||||
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
|
||||
load();
|
||||
loadSites();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
desktopRefreshCleanup?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -132,20 +132,17 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>问题导入模板</span>
|
||||
</el-button>
|
||||
<el-upload
|
||||
<el-button
|
||||
v-if="canCreateIssue"
|
||||
ref="importUploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.csv"
|
||||
:on-change="onImportChange"
|
||||
:disabled="importing"
|
||||
class="toolbar-button toolbar-button-warning"
|
||||
type="warning"
|
||||
plain
|
||||
:loading="importing"
|
||||
@click="selectImportFile"
|
||||
>
|
||||
<el-button class="toolbar-button toolbar-button-warning" type="warning" plain :loading="importing">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>导入问题</span>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>导入问题</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="toolbar-icons">
|
||||
<el-button circle title="刷新" @click="loadIssues">
|
||||
@@ -455,7 +452,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { CircleCheck, Delete, Document, Download, Location, Plus, Refresh, Search, Upload } from "@element-plus/icons-vue";
|
||||
import {
|
||||
createMonitoringVisitIssue,
|
||||
@@ -469,6 +466,7 @@ import {
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { pickFiles, saveFile } from "../../runtime";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import type { Site } from "../../types/api";
|
||||
@@ -523,7 +521,6 @@ const viewDialogVisible = ref(false);
|
||||
const formMode = ref<"create" | "edit">("create");
|
||||
const editingIssueId = ref("");
|
||||
const createFormRef = ref<FormInstance>();
|
||||
const importUploadRef = ref<UploadInstance>();
|
||||
const allItems = ref<MonitoringIssueRow[]>([]);
|
||||
const viewIssue = ref<MonitoringIssueRow | null>(null);
|
||||
const sitesLoading = ref(false);
|
||||
@@ -981,14 +978,7 @@ const handleExportExcel = async () => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
ElMessage.success("导出成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
|
||||
@@ -997,11 +987,9 @@ const handleExportExcel = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onImportChange = async (uploadFile: UploadFile) => {
|
||||
const importIssueFile = async (file: File) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || !canCreateIssue.value) return;
|
||||
const file = uploadFile.raw;
|
||||
if (!file) return;
|
||||
|
||||
importing.value = true;
|
||||
try {
|
||||
@@ -1018,10 +1006,18 @@ const onImportChange = async (uploadFile: UploadFile) => {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
|
||||
} finally {
|
||||
importing.value = false;
|
||||
importUploadRef.value?.clearFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const selectImportFile = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["xlsx", "csv"],
|
||||
title: "导入监查访视问题",
|
||||
});
|
||||
if (file) await importIssueFile(file);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => study.currentStudy?.id,
|
||||
(studyId) => {
|
||||
|
||||
@@ -27,3 +27,15 @@ describe("SubjectManagement drawer editor", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||