release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (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

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS build
FROM node:22.13-alpine AS build
WORKDIR /app
+73 -2
View File
@@ -4,12 +4,83 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<meta name="theme-color" content="#13243a" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/icons/ctms-icon-192.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>CTMS</title>
</head>
<body>
<div id="app"></div>
<div id="app">
<style>
.ctms-boot-splash {
display: grid;
min-height: 100vh;
min-height: 100dvh;
place-items: center;
background:
radial-gradient(circle at 22% 12%, rgba(58, 120, 183, 0.14), transparent 30%),
linear-gradient(135deg, #f4f7fb 0%, #e8eef5 100%);
color: #142033;
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.ctms-boot-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px 22px;
border: 1px solid rgba(191, 203, 217, 0.78);
border-radius: 18px;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 20px 48px rgba(43, 63, 87, 0.12);
}
.ctms-boot-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 10px 22px rgba(15, 35, 58, 0.16);
overflow: hidden;
}
.ctms-boot-mark img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.ctms-boot-title {
margin: 0;
font-size: 15px;
font-weight: 800;
}
.ctms-boot-subtitle {
margin: 4px 0 0;
color: #66758b;
font-size: 12px;
}
</style>
<div class="ctms-boot-splash">
<div class="ctms-boot-card">
<div class="ctms-boot-mark" aria-hidden="true">
<img src="/icons/ctms-icon-192.png" alt="" />
</div>
<div>
<p class="ctms-boot-title">正在启动 CTMS</p>
<p class="ctms-boot-subtitle">正在加载系统...</p>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+136 -753
View File
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
@@ -11,8 +14,11 @@
"desktop:dev": "tauri dev",
"desktop:build": "tauri build",
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
"desktop:build:macos-release": "tauri build --target universal-apple-darwin --bundles app,dmg",
"desktop:update-feed:create": "node scripts/create-desktop-update-feed.mjs",
"desktop:bundle:dmg": "tauri build --bundles dmg",
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
"desktop:release-readiness:check": "node scripts/verify-desktop-release-readiness.mjs",
"release:env:check": "node scripts/verify-release-build-env.mjs",
"version:check": "node scripts/client-version.mjs --check",
"version:set": "node scripts/client-version.mjs --set",
@@ -28,11 +34,10 @@
"@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",
"element-plus": "^2.4.4",
"pdfjs-dist": "6.1.200",
"pinia": "^2.1.7",
"vue": "^3.4.15",
"vue-echarts": "^8.0.1",
@@ -44,7 +49,6 @@
"@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"sass-embedded": "^1.77.0",
"typescript": "^5.3.3",
"vite": "^7.3.1",
"vitest": "^3.2.4",
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 B

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS Office Preview</title>
<style>
html,
body,
#onlyoffice-editor {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: #fff;
}
</style>
</head>
<body>
<div id="onlyoffice-editor" aria-label="Office 文档预览"></div>
<script src="/onlyoffice-host.js" defer></script>
</body>
</html>
+228
View File
@@ -0,0 +1,228 @@
(() => {
"use strict";
const MESSAGE = Object.freeze({
HOST_READY: "ctms.onlyoffice.host-ready",
INIT: "ctms.onlyoffice.init",
DOCUMENT_READY: "ctms.onlyoffice.document-ready",
WARNING: "ctms.onlyoffice.warning",
ERROR: "ctms.onlyoffice.error",
DOCUMENT_STATE_CHANGE: "ctms.onlyoffice.document-state-change",
SAVE_AS: "ctms.onlyoffice.save-as",
SAVE_AS_ERROR: "ctms.onlyoffice.save-as-error",
DOWNLOAD: "ctms.onlyoffice.download",
DOWNLOAD_ERROR: "ctms.onlyoffice.download-error",
REQUEST_EDIT_RIGHTS: "ctms.onlyoffice.request-edit-rights",
});
const API_SCRIPT_PATH = "/onlyoffice/web-apps/apps/api/documents/api.js";
const MAX_SAVE_AS_BYTES = 64 * 1024 * 1024;
const ALLOWED_TAURI_ORIGINS = new Set([
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
]);
let initialized = false;
let editor = null;
let parentOrigin = null;
let requestNonce = null;
let apiScriptPromise = null;
let readyTimer = null;
let readyDeadlineTimer = null;
let documentTitle = "download";
const isLoopbackOrigin = (origin) => {
try {
const url = new URL(origin);
return url.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname);
} catch {
return false;
}
};
const isAllowedParentOrigin = (origin) =>
origin === window.location.origin || ALLOWED_TAURI_ORIGINS.has(origin) || isLoopbackOrigin(origin);
const postToParent = (type, detail, transfer = []) => {
if (!parentOrigin || !requestNonce) return;
window.parent.postMessage({ type, nonce: requestNonce, detail }, parentOrigin, transfer);
};
const safeEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : event;
if (!raw || typeof raw !== "object") return {};
const detail = {};
if (typeof raw.errorCode === "number" || typeof raw.errorCode === "string") detail.errorCode = raw.errorCode;
if (typeof raw.errorDescription === "string") detail.errorDescription = raw.errorDescription.slice(0, 500);
if (typeof raw.warningCode === "number" || typeof raw.warningCode === "string") detail.warningCode = raw.warningCode;
if (typeof raw.warningDescription === "string") detail.warningDescription = raw.warningDescription.slice(0, 500);
return detail;
};
const loadOnlyOfficeApi = () => {
if (window.DocsAPI?.DocEditor) return Promise.resolve();
if (apiScriptPromise) return apiScriptPromise;
apiScriptPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = API_SCRIPT_PATH;
script.async = true;
script.addEventListener("load", () => {
if (window.DocsAPI?.DocEditor) resolve();
else reject(new Error("ONLYOFFICE API 未正确加载"));
}, { once: true });
script.addEventListener("error", () => reject(new Error("ONLYOFFICE API 加载失败")), { once: true });
document.head.appendChild(script);
});
return apiScriptPromise;
};
const saveAsEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : null;
if (!raw || typeof raw !== "object") throw new Error("另存为数据格式不正确");
const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : "";
const title = typeof raw.title === "string" ? raw.title.trim().slice(0, 240) : "";
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || typeof raw.url !== "string") {
throw new Error("另存为文件信息不完整");
}
const url = new URL(raw.url, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) {
throw new Error("另存为文件地址不可信");
}
return { fileType, title, url };
};
const handleRequestSaveAs = async (event) => {
try {
const { fileType, title, url } = saveAsEventDetail(event);
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
});
if (!response.ok) throw new Error("另存为文件生成失败");
const declaredSize = Number(response.headers.get("content-length") || "0");
if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("另存为文件超出大小限制");
const data = await response.arrayBuffer();
if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("另存为文件内容无效");
const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120);
postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data]);
} catch (error) {
postToParent(MESSAGE.SAVE_AS_ERROR, {
message: error instanceof Error ? error.message.slice(0, 200) : "另存为失败",
});
}
};
const downloadEventDetail = (event) => {
const raw = event && typeof event === "object" && "data" in event ? event.data : null;
if (!raw || typeof raw !== "object") throw new Error("下载文件数据格式不正确");
const fileType = typeof raw.fileType === "string" ? raw.fileType.toLowerCase() : "";
if (!/^[a-z0-9]{1,16}$/.test(fileType) || typeof raw.url !== "string") {
throw new Error("下载文件信息不完整");
}
const url = new URL(raw.url, window.location.origin);
if (url.origin !== window.location.origin || !url.pathname.startsWith("/onlyoffice/")) {
throw new Error("下载文件地址不可信");
}
const suffix = `.${fileType}`;
const stem = documentTitle.toLowerCase().endsWith(suffix)
? documentTitle.slice(0, -suffix.length)
: documentTitle.replace(/\.[^.]+$/, "");
return { fileType, title: `${stem}${suffix}`, url };
};
const handleDownloadAs = async (event) => {
try {
const { fileType, title, url } = downloadEventDetail(event);
const response = await fetch(url, {
cache: "no-store",
credentials: "omit",
redirect: "error",
});
if (!response.ok) throw new Error("下载文件生成失败");
const declaredSize = Number(response.headers.get("content-length") || "0");
if (declaredSize > MAX_SAVE_AS_BYTES) throw new Error("下载文件超出大小限制");
const data = await response.arrayBuffer();
if (!data.byteLength || data.byteLength > MAX_SAVE_AS_BYTES) throw new Error("下载文件内容无效");
const mimeType = (response.headers.get("content-type") || "application/octet-stream").slice(0, 120);
postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data]);
} catch (error) {
postToParent(MESSAGE.DOWNLOAD_ERROR, {
message: error instanceof Error ? error.message.slice(0, 200) : "下载失败",
});
}
};
const destroyEditor = () => {
if (editor && typeof editor.destroyEditor === "function") {
try {
editor.destroyEditor();
} catch {
/* The remote editor may already have been torn down. */
}
}
editor = null;
};
const stopReadyAnnouncements = () => {
if (readyTimer !== null) window.clearInterval(readyTimer);
if (readyDeadlineTimer !== null) window.clearTimeout(readyDeadlineTimer);
readyTimer = null;
readyDeadlineTimer = null;
};
const announceReady = () => {
if (!initialized) window.parent.postMessage({ type: MESSAGE.HOST_READY }, "*");
};
const initializeEditor = async (message) => {
const config = message?.config;
if (!config || typeof config !== "object" || typeof message?.nonce !== "string" || !message.nonce) {
throw new Error("预览配置格式不正确");
}
requestNonce = message.nonce;
documentTitle = typeof config.document?.title === "string" && config.document.title.trim()
? config.document.title.trim().slice(0, 240)
: "download";
await loadOnlyOfficeApi();
const events = {
onDocumentReady: () => postToParent(MESSAGE.DOCUMENT_READY),
onWarning: (event) => postToParent(MESSAGE.WARNING, safeEventDetail(event)),
onError: (event) => postToParent(MESSAGE.ERROR, safeEventDetail(event)),
onDocumentStateChange: (event) => postToParent(MESSAGE.DOCUMENT_STATE_CHANGE, {
changed: Boolean(event && typeof event === "object" && "data" in event ? event.data : event),
}),
onRequestEditRights: () => postToParent(MESSAGE.REQUEST_EDIT_RIGHTS),
};
if (message?.allowSaveAs === true) events.onRequestSaveAs = handleRequestSaveAs;
if (message?.allowDownload === true) events.onDownloadAs = handleDownloadAs;
const editorConfig = {
...config,
events,
};
editor = new window.DocsAPI.DocEditor("onlyoffice-editor", editorConfig);
};
window.addEventListener("message", async (event) => {
if (event.source !== window.parent || initialized || !isAllowedParentOrigin(event.origin)) return;
if (event.data?.type !== MESSAGE.INIT) return;
initialized = true;
stopReadyAnnouncements();
parentOrigin = event.origin;
requestNonce = typeof event.data?.nonce === "string" ? event.data.nonce : null;
try {
await initializeEditor(event.data);
} catch (error) {
postToParent(MESSAGE.ERROR, {
errorDescription: error instanceof Error ? error.message.slice(0, 500) : "Office 预览初始化失败",
});
}
});
window.addEventListener("pagehide", () => {
stopReadyAnnouncements();
destroyEditor();
}, { once: true });
announceReady();
readyTimer = window.setInterval(announceReady, 250);
readyDeadlineTimer = window.setTimeout(stopReadyAnnouncements, 15_000);
})();
+19
View File
@@ -0,0 +1,19 @@
{
"name": "CTMS",
"short_name": "CTMS",
"icons": [
{
"src": "/icons/ctms-icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/ctms-icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#13243a",
"background_color": "#f4f7fb",
"display": "standalone"
}
@@ -0,0 +1,156 @@
import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const args = process.argv.slice(2);
const values = (name) => {
const found = [];
for (let index = 0; index < args.length; index += 1) {
if (args[index] === name && args[index + 1]) {
found.push(args[index + 1]);
index += 1;
}
}
return found;
};
const value = (name) => values(name)[0];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const artifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT;
const outputDir = resolve(
frontendDir,
value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed",
);
const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString();
const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES;
const includes = values("--include").map((path) => resolve(frontendDir, path));
assert(Boolean(artifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required.");
assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required.");
const resolvedArtifactPath = artifactPath ? resolve(frontendDir, artifactPath) : undefined;
const artifactName = resolvedArtifactPath ? basename(resolvedArtifactPath) : undefined;
const signaturePath = resolvedArtifactPath ? `${resolvedArtifactPath}.sig` : undefined;
const uploadFiles = [];
const readRequiredFile = async (path, description) => {
try {
return await readFile(path);
} catch (error) {
fail(`${description} cannot be read: ${path} (${error.message})`);
return undefined;
}
};
const sha256 = async (path) => {
const data = await readFile(path);
return createHash("sha256").update(data).digest("hex");
};
const normalizedBaseUrl = () => {
if (!baseUrlRaw) return undefined;
try {
const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`);
assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS.");
assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters.");
assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`);
return url;
} catch (error) {
fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`);
return undefined;
}
};
const copyIntoOutput = async (sourcePath) => {
const destination = resolve(outputDir, basename(sourcePath));
if (sourcePath !== destination) {
await copyFile(sourcePath, destination);
}
return destination;
};
if (resolvedArtifactPath && signaturePath) {
await readRequiredFile(resolvedArtifactPath, "Updater artifact");
const signature = await readRequiredFile(signaturePath, "Updater artifact signature");
if (signature) {
const signatureText = signature.toString("utf8").trim();
assert(signatureText.length > 80, "Updater artifact signature is unexpectedly short.");
}
}
for (const includePath of includes) {
try {
const metadata = await stat(includePath);
assert(metadata.isFile(), `Included release file must be a file: ${includePath}`);
} catch (error) {
fail(`Included release file cannot be read: ${includePath} (${error.message})`);
}
}
const baseUrl = normalizedBaseUrl();
if (failures.length === 0 && resolvedArtifactPath && signaturePath && artifactName && baseUrl) {
await mkdir(outputDir, { recursive: true });
const artifactUrl = new URL(artifactName, baseUrl).toString();
assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json.");
assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters.");
const signature = (await readFile(signaturePath, "utf8")).trim();
const latest = {
version: packageInfo.version,
pub_date: pubDate,
platforms: {
"darwin-aarch64": {
signature,
url: artifactUrl,
},
"darwin-x86_64": {
signature,
url: artifactUrl,
},
},
};
if (notes) {
latest.notes = notes;
}
uploadFiles.push(await copyIntoOutput(resolvedArtifactPath));
uploadFiles.push(await copyIntoOutput(signaturePath));
for (const includePath of includes) {
uploadFiles.push(await copyIntoOutput(includePath));
}
const latestPath = resolve(outputDir, "latest.json");
await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`);
uploadFiles.push(latestPath);
const uniqueFiles = [...new Map(uploadFiles.map((path) => [basename(path), path])).values()];
const checksumLines = [];
for (const filePath of uniqueFiles) {
checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`);
}
const checksumPath = resolve(outputDir, "SHA256SUMS.txt");
await writeFile(checksumPath, `${checksumLines.join("\n")}\n`);
console.log(`Desktop update feed created in ${outputDir}`);
console.log(` - ${uniqueFiles.map((path) => basename(path)).join("\n - ")}`);
console.log(` - SHA256SUMS.txt`);
}
if (failures.length > 0) {
console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
}
@@ -0,0 +1,101 @@
import { execFileSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const env = process.env;
const fullShaPattern = /^[0-9a-f]{40}$/i;
const expectedTag = `v${packageInfo.version}`;
const requiredSecretLikeEnv = [
"TAURI_SIGNING_PRIVATE_KEY",
"TAURI_SIGNING_PRIVATE_KEY_PASSWORD",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
];
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const git = (args) =>
execFileSync("git", args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const gitMaybe = (args) => {
try {
return git(args);
} catch {
return undefined;
}
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release readiness.`);
};
const validateBaseUrl = () => {
const raw = env.DESKTOP_UPDATE_BASE_URL;
requireEnv("DESKTOP_UPDATE_BASE_URL");
if (!raw) return;
let url;
try {
url = new URL(raw.endsWith("/") ? raw : `${raw}/`);
} catch (error) {
fail(`DESKTOP_UPDATE_BASE_URL is invalid: ${error.message}`);
return;
}
assert(url.protocol === "https:", "DESKTOP_UPDATE_BASE_URL must use HTTPS.");
assert(url.username === "" && url.password === "", "DESKTOP_UPDATE_BASE_URL must not include credentials.");
assert(!/[?&]token=/i.test(url.search), "DESKTOP_UPDATE_BASE_URL must not include token query parameters.");
assert(
url.pathname.includes(packageInfo.version),
`DESKTOP_UPDATE_BASE_URL must include the immutable version segment ${packageInfo.version}.`,
);
};
const headSha = gitMaybe(["rev-parse", "HEAD"]);
const exactTag = gitMaybe(["describe", "--tags", "--exact-match", "HEAD"]);
const status = gitMaybe(["status", "--porcelain"]);
assert(Boolean(headSha), "Current Git commit cannot be resolved.");
assert(exactTag === expectedTag, `Current commit must be exactly tagged ${expectedTag}; found ${exactTag || "<none>"}.`);
assert(status === "", "Release readiness requires a clean working tree.");
assert(env.VITE_BUILD_CHANNEL === "release", "VITE_BUILD_CHANNEL must be release.");
assert(fullShaPattern.test(env.VITE_BUILD_COMMIT || ""), "VITE_BUILD_COMMIT must be the full release commit SHA.");
if (headSha && env.VITE_BUILD_COMMIT) {
assert(env.VITE_BUILD_COMMIT === headSha, "VITE_BUILD_COMMIT must match the current release commit.");
}
for (const name of requiredSecretLikeEnv) {
requireEnv(name);
}
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
validateBaseUrl();
if (failures.length > 0) {
console.error(`Desktop release readiness check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop release readiness check passed.");
}
+304 -6
View File
@@ -29,6 +29,13 @@ const walk = async (directory) => {
const permissionIdentifier = (permission) =>
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
const toPosixPath = (path) => path.split("\\").join("/");
const cspDirective = (csp, name) =>
csp
.split(";")
.map((directive) => directive.trim().split(/\s+/))
.find(([directiveName]) => directiveName === name)
?.slice(1) || [];
const assertPathScope = (permission, expectedPrefix, description) => {
const allow = Array.isArray(permission.allow) ? permission.allow : [];
@@ -69,8 +76,31 @@ const verifyTauriConfig = async () => {
"Tauri CSP must not use wildcard sources.",
);
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
const scriptSources = cspDirective(csp, "script-src");
const frameSources = cspDirective(csp, "frame-src");
assert(
scriptSources.length === 1 && scriptSources[0] === "'self'",
"Tauri script-src must remain self-only when ONLYOFFICE preview is enabled.",
);
assert(
JSON.stringify(frameSources) ===
JSON.stringify(["'self'", "blob:", "https:", "http://localhost:*", "http://127.0.0.1:*"]),
"Tauri frame-src may only add HTTPS and local development servers for the isolated ONLYOFFICE host.",
);
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
assert(mainWindow?.decorations === true, "Main desktop window must keep native window decorations enabled.");
assert(mainWindow?.titleBarStyle === "Overlay", "Main desktop window must use the macOS overlay title bar.");
assert(mainWindow?.hiddenTitle === true, "Main desktop window must hide the native title text.");
assert(mainWindow?.backgroundColor === "#f9fafb", "Main desktop window must use a non-black WebView background.");
assert(
mainWindow?.backgroundThrottling === "disabled",
"Main desktop window must disable background suspend to avoid macOS resume black flashes.",
);
assert(
mainWindow?.trafficLightPosition?.x === 16 && mainWindow?.trafficLightPosition?.y === 18,
"Main desktop window must keep traffic light controls at x=16 y=18.",
);
};
const verifyCapabilities = async () => {
@@ -86,10 +116,35 @@ const verifyCapabilities = async () => {
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"notification:default",
"opener:default",
"opener:allow-open-url",
"opener:allow-reveal-item-in-dir",
"updater:default",
"updater:allow-check",
"updater:allow-download",
"updater:allow-install",
"updater:allow-download-and-install",
]);
const requiredNotificationPermissions = [
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
];
const requiredTemporaryFilePermissions = [
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:allow-remove",
];
for (const file of files) {
const capability = await readJson(resolve(capabilitiesDir, file));
assert(!capability.remote, `${file}: remote origins must not receive Tauri capabilities.`);
assert(
Array.isArray(capability.windows) && capability.windows.length === 1 && capability.windows[0] === "main",
`${file}: capabilities must remain scoped to the main local window.`,
);
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
@@ -97,6 +152,25 @@ const verifyCapabilities = async () => {
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
assert(!identifier.startsWith("updater:"), `${file}: updater permissions must not be exposed directly to WebView.`);
if (identifier.startsWith("notification:")) {
assert(
requiredNotificationPermissions.includes(identifier),
`${file}: notification permission ${identifier} is broader than the CTMS Desktop notification boundary.`,
);
}
if (identifier.startsWith("core:window:")) {
fail(`${file}: window permissions are not allowed; use Tauri overlay drag regions instead.`);
}
if (identifier.startsWith("opener:")) {
assert(identifier === "opener:allow-open-path", `${file}: opener permission ${identifier} is not allowed.`);
}
}
for (const identifier of requiredNotificationPermissions) {
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
}
for (const identifier of requiredTemporaryFilePermissions) {
assert(identifiers.includes(identifier), `${file}: missing ${identifier}.`);
}
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
@@ -113,6 +187,35 @@ const verifyCapabilities = async () => {
}
};
const verifyOnlyOfficeBoundary = async () => {
const runtimeSource = await readFile(resolve(sourceDir, "runtime/onlyoffice.ts"), "utf8");
const pageSource = await readFile(resolve(sourceDir, "views/OfficePreviewWorkspace.vue"), "utf8");
const viewerSource = await readFile(resolve(sourceDir, "components/OnlyOfficeViewer.vue"), "utf8");
const apiSource = await readFile(resolve(sourceDir, "api/onlyoffice.ts"), "utf8");
const hostSource = await readFile(resolve(frontendDir, "public/onlyoffice-host.js"), "utf8");
assert(runtimeSource.includes('ONLYOFFICE_HOST_PATH = "/onlyoffice-host.html"'), "ONLYOFFICE host path must remain fixed.");
assert(runtimeSource.includes("resolveApiBaseUrl()"), "ONLYOFFICE host must derive from the validated runtime server base URL.");
assert(!runtimeSource.includes("url:"), "ONLYOFFICE runtime URL resolver must not accept a business-provided URL.");
assert(pageSource.includes("response.data.host_path !== ONLYOFFICE_HOST_PATH"), "ONLYOFFICE config host path must be checked against the fixed host path.");
assert(pageSource.includes("onDeactivated") && pageSource.includes("destroyViewer()"), "ONLYOFFICE viewer must be destroyed when a desktop task is deactivated.");
assert(viewerSource.includes("event.source !== frameWindow"), "ONLYOFFICE bridge must validate the message source window.");
assert(viewerSource.includes("event.origin !== hostUrl.origin"), "ONLYOFFICE bridge must validate message origin.");
assert(viewerSource.includes("message.nonce !== nonce"), "ONLYOFFICE bridge must validate its in-memory nonce.");
assert(hostSource.includes("event.source !== window.parent"), "ONLYOFFICE host must only accept parent-window messages.");
assert(hostSource.includes("!isAllowedParentOrigin(event.origin)"), "ONLYOFFICE host must validate the parent origin.");
assert(hostSource.includes("initialized ||"), "ONLYOFFICE host must only accept one initialization.");
assert(hostSource.includes("message?.nonce"), "ONLYOFFICE host must require the in-memory nonce.");
assert(hostSource.includes("url.origin !== window.location.origin"), "ONLYOFFICE save-as URL must remain same-origin.");
assert(hostSource.includes('url.pathname.startsWith("/onlyoffice/")'), "ONLYOFFICE save-as URL must remain under the fixed proxy path.");
assert(hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])"), "ONLYOFFICE save-as bridge must transfer validated file bytes.");
assert(!hostSource.includes("postToParent(MESSAGE.SAVE_AS, { fileType, title, url"), "ONLYOFFICE save-as bridge must not expose the signed result URL to business pages.");
assert(viewerSource.includes("allowSaveAs: props.allowSaveAs"), "ONLYOFFICE save-as must stay behind an explicit server capability.");
assert(!/\b(?:localStorage|sessionStorage|console\.)\b/.test(hostSource), "ONLYOFFICE host must not persist or log signed configuration.");
assert(apiSource.includes("cache: false"), "ONLYOFFICE signed config must not enter the desktop data cache.");
assert(apiSource.includes("disableRequestDedupe: true"), "ONLYOFFICE signed config requests must not be deduplicated.");
};
const verifyRustBoundary = async () => {
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
@@ -127,20 +230,99 @@ const verifyRustBoundary = async () => {
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
"Single-instance plugin must be registered before other desktop plugins.",
);
const singleInstanceSource = libSource.slice(
singleInstanceIndex,
dialogIndex > singleInstanceIndex ? dialogIndex : singleInstanceIndex + 600,
);
const restoreWindowStart = libSource.indexOf("fn restore_main_window");
const restoreWindowEnd = libSource.indexOf("fn is_allowed_shortcut_key", restoreWindowStart);
const restoreWindowSource = libSource.slice(restoreWindowStart, restoreWindowEnd);
const restoreWindowTokens = ['get_webview_window("main")', "window.unminimize()", "window.show()", "window.set_focus()"];
assert(restoreWindowStart >= 0, "Desktop runtime must define a shared main-window restore helper.");
assert(
singleInstanceSource.includes("restore_main_window(app)"),
"Single-instance duplicate launch handler must use the shared main-window restore helper.",
);
for (const token of restoreWindowTokens) {
assert(restoreWindowSource.includes(token), `Main-window restore helper must call ${token}.`);
}
assert(
restoreWindowSource.indexOf("window.unminimize()") < restoreWindowSource.indexOf("window.show()") &&
restoreWindowSource.indexOf("window.show()") < restoreWindowSource.indexOf("window.set_focus()"),
"Main-window restore helper must restore, show, then focus the main window.",
);
assert(libSource.includes("RunEvent::Reopen"), "macOS Dock reopen events must restore the main window.");
assert(libSource.includes("WebviewWindowBuilder::from_config"), "Dock reopen must rebuild a main window that was actually closed.");
assert(libSource.includes('find(|config| config.label == "main")'), "Main-window rebuild must use only the configured main window.");
assert(!libSource.includes("WindowEvent::CloseRequested"), "The macOS red close button must keep its native close-window semantics.");
assert(!libSource.includes("api.prevent_close()"), "The macOS red close button must not be converted into a hide action.");
assert(!libSource.includes("window.hide()"), "The macOS red close button must not hide the main window.");
const appMenuIndex = libSource.indexOf('package.name.clone()');
const fileMenuIndex = libSource.indexOf('"文件"');
assert(appMenuIndex >= 0 && appMenuIndex < fileMenuIndex, "macOS application menu must precede the File menu.");
for (const token of [
'Some("关于 CTMS")',
"short_version: Some(String::new())",
"icon: handle.default_window_icon().cloned()",
'"ctms.desktop.preferences"',
"PredefinedMenuItem::services",
"PredefinedMenuItem::hide",
"PredefinedMenuItem::hide_others",
"PredefinedMenuItem::show_all",
"PredefinedMenuItem::quit",
"WINDOW_SUBMENU_ID",
"HELP_SUBMENU_ID",
"TOGGLE_SIDEBAR_COMMAND_ID",
'"显示/隐藏导航栏"',
]) {
assert(libSource.includes(token), `Desktop menu must include ${token}.`);
}
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
const commands = handlerSource
.split(",")
.map((command) => command.trim())
.filter(Boolean);
const allowedCommands = [
"credentials::credential_get",
"credentials::credential_set",
"credentials::credential_delete",
"credentials::login_credential_get",
"credentials::login_credential_set",
"credentials::login_credential_delete",
"updates::desktop_update_check",
"updates::desktop_update_install",
"desktop_menu_set_shortcuts",
"desktop_window_set_theme",
"desktop_window_get_fullscreen",
"desktop_window_set_fullscreen",
];
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>"}.`);
assert(libSource.includes("window.is_fullscreen()"), "Desktop fullscreen state must be read from the native window.");
assert(libSource.includes(".set_fullscreen(fullscreen)"), "Desktop fullscreen changes must target the native window.");
assert(libSource.includes("DESKTOP_FULLSCREEN_CHANGED_EVENT"), "Native fullscreen state must be emitted back to the WebView.");
};
const verifyDesktopUiNativeSync = async () => {
const menuSource = await readFile(resolve(sourceDir, "runtime/desktopMenu.ts"), "utf8");
const preferencesSource = await readFile(resolve(sourceDir, "runtime/desktopUiPreferences.ts"), "utf8");
const fullscreenSource = await readFile(resolve(sourceDir, "runtime/webFullscreen.ts"), "utf8");
const appSource = await readFile(resolve(sourceDir, "App.vue"), "utf8");
assert(menuSource.includes('invoke("desktop_menu_set_shortcuts"'), "Desktop shortcuts must sync through the controlled Tauri command.");
assert(menuSource.includes("DESKTOP_SHORTCUTS_CHANGED_EVENT"), "Native menu shortcuts must track preference changes.");
assert(preferencesSource.includes('"system" | "light" | "dark"'), "Desktop theme must support following the system appearance.");
assert(preferencesSource.includes('invoke("desktop_window_set_theme"'), "Desktop window theme must sync through the controlled Tauri command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_get_fullscreen"'), "Desktop fullscreen state must use the controlled Tauri query command.");
assert(fullscreenSource.includes('invoke<boolean>("desktop_window_set_fullscreen"'), "Desktop fullscreen changes must use the controlled Tauri mutation command.");
assert(fullscreenSource.includes("ctms:desktop-fullscreen-changed"), "Desktop fullscreen changes must synchronize native window state back to the WebView.");
assert(preferencesSource.includes('matchMedia("(prefers-color-scheme: dark)")'), "System theme changes must update the WebView appearance.");
assert(appSource.includes("initializeDesktopThemePreference()"), "Desktop theme synchronization must initialize at app startup.");
assert(appSource.includes("initializeDesktopMenuShortcutSync()"), "Native menu shortcut synchronization must initialize at app startup.");
};
const verifySourceSafety = async () => {
@@ -152,28 +334,52 @@ const verifySourceSafety = async () => {
for (const path of files) {
const source = await readFile(path, "utf8");
const file = relative(rootDir, path);
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
const file = toPosixPath(relative(rootDir, path));
const isRuntimeFile = file.startsWith("frontend/src/runtime/");
assert(!/[?&](?:token|access_token)=/i.test(source), `${file}: token must not be passed through query parameters.`);
assert(
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
!/console\.(log|debug|info|warn|error)\s*\([^)]*(?:token|access_token|authorization|bearer)/i.test(source),
`${file}: token-related values must not be written to console logs.`,
);
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
}
assert(
!/(?:localStorage|sessionStorage)\.setItem\([^)]*password/i.test(source),
`${file}: passwords must not be written to browser storage.`,
);
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
}
if (!isRuntimeFile) {
assert(
!/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source),
`${file}: local data cache storage must be routed through frontend/src/runtime.`,
);
}
}
};
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(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 verifySessionBoundary = async () => {
const source = await readFile(resolve(sourceDir, "session/sessionManager.ts"), "utf8");
const tokenBroadcastIndex = source.indexOf('message.type === "TOKEN_UPDATED"');
const storageBroadcastIndex = source.indexOf('localStorage.setItem("ctms_auth_broadcast"');
assert(tokenBroadcastIndex >= 0, "Session manager must branch TOKEN_UPDATED broadcasts before storage fallback.");
assert(storageBroadcastIndex >= 0, "Session manager must keep storage fallback for non-token auth broadcasts.");
assert(
tokenBroadcastIndex >= 0 && storageBroadcastIndex >= 0 && tokenBroadcastIndex < storageBroadcastIndex,
"Session manager must not persist TOKEN_UPDATED payloads through localStorage broadcast fallback.",
);
};
const verifyUpdaterBoundary = async () => {
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
@@ -182,6 +388,20 @@ const verifyUpdaterBoundary = async () => {
};
const verifyWorkflowGates = async () => {
const packageInfo = await readJson(resolve(frontendDir, "package.json"));
assert(packageInfo.engines?.node === ">=22.13.0", "package.json must require Node.js >=22.13.0.");
const requiredScripts = [
"desktop:build:macos-release",
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
"release:env:check",
];
for (const script of requiredScripts) {
assert(Boolean(packageInfo.scripts?.[script]), `package.json must define ${script}.`);
}
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
@@ -200,14 +420,92 @@ const verifyWorkflowGates = async () => {
}
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
assert(workflow.match(/node-version: "22\.13"/g)?.length === 2, "Client quality gates must use Node.js 22.13 for Web and Desktop jobs.");
const releaseWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-release-candidate.yml"), "utf8");
const requiredReleaseWorkflowTokens = [
"REQUIRE_DESKTOP_SIGNING",
"TAURI_SIGNING_PRIVATE_KEY",
"APPLE_ID",
"APPLE_PASSWORD",
"APPLE_TEAM_ID",
"npm run desktop:build:macos-release",
"npm run desktop:update-feed:create",
"npm run desktop:update-feed:check",
"npm run desktop:release-readiness:check",
"actions/upload-artifact",
];
for (const token of requiredReleaseWorkflowTokens) {
assert(releaseWorkflow.includes(token), `Desktop release candidate workflow must include ${token}.`);
}
assert(releaseWorkflow.includes('node-version: "22.13"'), "Desktop release candidates must use Node.js 22.13.");
const windowsInternalWorkflow = await readFile(resolve(rootDir, ".github/workflows/desktop-windows-internal.yml"), "utf8");
const requiredWindowsInternalWorkflowTokens = [
"workflow_dispatch",
"runs-on: windows-latest",
"VITE_BUILD_CHANNEL",
"VITE_BUILD_COMMIT",
"npm run release:env:check",
"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",
"createUpdaterArtifacts",
"false",
"--bundles nsis",
"SHA256SUMS.txt",
"actions/upload-artifact",
];
for (const token of requiredWindowsInternalWorkflowTokens) {
assert(windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must include ${token}.`);
}
const forbiddenWindowsInternalWorkflowTokens = [
"desktop:update-feed:create",
"desktop:update-feed:check",
"desktop:release-readiness:check",
"TAURI_SIGNING_PRIVATE_KEY",
"REQUIRE_DESKTOP_SIGNING",
"latest.json",
];
for (const token of forbiddenWindowsInternalWorkflowTokens) {
assert(!windowsInternalWorkflow.includes(token), `Desktop Windows internal workflow must not include ${token}.`);
}
assert(windowsInternalWorkflow.includes('node-version: "22.13"'), "Desktop Windows internal builds must use Node.js 22.13.");
for (const dockerfile of [resolve(frontendDir, "Dockerfile"), resolve(rootDir, "nginx/Dockerfile")]) {
const dockerSource = await readFile(dockerfile, "utf8");
assert(dockerSource.startsWith("FROM node:22.13-alpine"), `${relative(rootDir, dockerfile)} must build with Node.js 22.13.`);
}
const developmentCompose = await readFile(resolve(rootDir, "docker-compose.dev.yaml"), "utf8");
assert(developmentCompose.includes("image: node:22.13-alpine"), "Development frontend container must use Node.js 22.13.");
assert(developmentCompose.includes('dependency_hash="$$lock_hash:$$(node --version)"'), "Development dependency cache must include the Node.js version.");
assert(developmentCompose.includes("pdfjs-dist"), "Development dependency checks must include PDF.js.");
assert(
developmentCompose.includes("frontend_node_modules_node22:/app/node_modules") &&
developmentCompose.includes("frontend_node_modules_node22:"),
"Development dependencies must use the Node.js 22-specific volume.",
);
};
await verifyTauriConfig();
await verifyCapabilities();
await verifyRustBoundary();
await verifyDesktopUiNativeSync();
await verifySourceSafety();
await verifyNotificationBoundary();
await verifySessionBoundary();
await verifyUpdaterBoundary();
await verifyOnlyOfficeBoundary();
await verifyWorkflowGates();
if (failures.length > 0) {
@@ -1,4 +1,5 @@
import { access, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@@ -18,6 +19,10 @@ const feedPath = resolve(
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const checksumManifestPath =
optionValue("--checksum-manifest") ||
process.env.DESKTOP_UPDATE_CHECKSUM_MANIFEST ||
(artifactDir ? resolve(artifactDir, "SHA256SUMS.txt") : undefined);
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
@@ -32,6 +37,55 @@ const assertFileExists = async (path, description) => {
}
};
const sha256 = async (path) => createHash("sha256").update(await readFile(path)).digest("hex");
const readChecksumManifest = async () => {
if (!checksumManifestPath) return undefined;
try {
const source = await readFile(checksumManifestPath, "utf8");
const checksums = new Map();
for (const line of source.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([a-f0-9]{64})\s+\*?(.+)$/i);
if (!match) {
fail(`Checksum manifest contains an invalid line: ${line}`);
continue;
}
checksums.set(basename(match[2]), match[1].toLowerCase());
}
return checksums;
} catch (error) {
fail(`Cannot read checksum manifest ${checksumManifestPath}: ${error.message}`);
return undefined;
}
};
const assertChecksum = async (checksums, path, description) => {
if (!checksums) return;
const name = basename(path);
const expected = checksums.get(name);
assert(Boolean(expected), `Checksum manifest must include ${description}: ${name}`);
if (expected) {
const actual = await sha256(path);
assert(actual === expected, `${description} checksum mismatch for ${name}.`);
}
};
const assertManifestEntries = async (checksums) => {
if (!checksums || !artifactDir) return;
for (const [name, expected] of checksums.entries()) {
const path = resolve(artifactDir, name);
await assertFileExists(path, `checksum manifest entry ${name}`);
try {
const actual = await sha256(path);
assert(actual === expected, `Checksum manifest entry mismatch for ${name}.`);
} catch (error) {
fail(`Cannot verify checksum manifest entry ${name}: ${error.message}`);
}
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
@@ -40,6 +94,8 @@ try {
}
if (feed) {
const checksums = await readChecksumManifest();
await assertManifestEntries(checksums);
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
@@ -80,14 +136,21 @@ if (feed) {
if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname));
const signaturePath = `${artifactPath}.sig`;
await assertFileExists(artifactPath, `${platform} updater artifact`);
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
await assertFileExists(signaturePath, `${platform} updater artifact signature`);
await assertChecksum(checksums, artifactPath, `${platform} updater artifact`);
await assertChecksum(checksums, signaturePath, `${platform} updater artifact signature`);
}
}
if (darwinArm?.url && darwinIntel?.url) {
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
}
if (artifactDir) {
await assertChecksum(checksums, feedPath, "latest.json");
}
}
if (failures.length > 0) {
@@ -47,6 +47,9 @@ if (isTagBuild) {
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
if (isCi) {
assert(isTagBuild, "Signed desktop release candidate builds in CI must run from a release tag.");
}
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("APPLE_ID");
@@ -56,6 +59,9 @@ if (env.REQUIRE_DESKTOP_SIGNING === "true") {
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
if (env.APPLE_CERTIFICATE) {
requireEnv("APPLE_CERTIFICATE_PASSWORD");
}
}
if (failures.length > 0) {
+18 -4
View File
@@ -1,5 +1,5 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, relative, resolve } from "node:path";
import { extname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
@@ -7,6 +7,15 @@ const sourceDir = resolve(frontendDir, "src");
const runtimeDir = resolve(sourceDir, "runtime");
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
const violations = [];
const toPosixPath = (path) => path.split("\\").join("/");
const platformSource = await readFile(resolve(runtimeDir, "platform.ts"), "utf8");
if (!platformSource.includes("runtimeGlobal.isTauri")) {
violations.push("src/runtime/platform.ts: Tauri v2 runtime detection must use the official isTauri marker");
}
if (platformSource.includes("__TAURI")) {
violations.push("src/runtime/platform.ts: Tauri runtime detection must not depend on legacy or internal markers");
}
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
@@ -21,16 +30,21 @@ const walk = async (directory) => {
};
for (const path of await walk(sourceDir)) {
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
const runtimeRelativePath = relative(runtimeDir, path);
const isRuntimeFile = Boolean(runtimeRelativePath) && !runtimeRelativePath.startsWith("..") && !isAbsolute(runtimeRelativePath);
if (!sourceExtensions.has(extname(path)) || isRuntimeFile) continue;
const source = await readFile(path, "utf8");
const file = relative(frontendDir, path);
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
const file = toPosixPath(relative(frontendDir, path));
if (source.includes("@tauri-apps/") || source.includes("__TAURI") || /(?:globalThis|window)\.isTauri/.test(source)) {
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 (/\bindexedDB\b|\bcaches\.open\s*\(|\bCacheStorage\b|\bsqlite\b/i.test(source)) {
violations.push(`${file}: local data cache storage must be routed through src/runtime`);
}
}
if (violations.length > 0) {
+49 -5
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
const main = readFileSync("src/styles/main.css", "utf8");
const unified = readFileSync("src/styles/unified-page.css", "utf8");
const webLayout = readFileSync("src/components/WebLayout.vue", "utf8");
const requiredMainTokens = [
"--ctms-primary",
@@ -31,6 +32,29 @@ const missing = [
...requiredUnifiedTokens.filter((t) => !unified.includes(t))
];
const requiredWebEdgeTokens = [
".web-layout-container .ctms-route-shell > *",
".web-layout-container .ctms-route-shell > .page > .table-card"
];
for (const token of requiredWebEdgeTokens) {
if (!main.includes(token)) {
missing.push(`src/styles/main.css:${token}`);
}
}
if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*padding:\s*0;/s.test(webLayout)) {
missing.push("src/components/WebLayout.vue:web content wrapper edge alignment");
}
if (!/\.web-layout-container \.content-wrapper\s*\{[^}]*height:\s*100%;[^}]*min-height:\s*0;/s.test(webLayout)) {
missing.push("src/components/WebLayout.vue:web content height chain");
}
if (webLayout.includes("padding: 18px 24px 24px;") || webLayout.includes("padding: 22px 32px 32px;")) {
missing.push("src/components/WebLayout.vue:large-screen content padding override");
}
const pageContractChecks = [
{
file: "src/views/ia/ProjectMilestones.vue",
@@ -100,26 +124,46 @@ for (const group of requiredOverviewGroups) {
}
}
const financeAndFilePages = [
const unifiedShellPages = [
"src/views/fees/ContractFees.vue",
"src/views/ia/FileVersionManagement.vue"
"src/views/documents/DocumentList.vue"
];
const requiredFinanceAndFileClasses = [
const requiredUnifiedShellClasses = [
"ctms-page-shell",
"unified-action-bar",
"unified-shell"
];
for (const file of financeAndFilePages) {
for (const file of unifiedShellPages) {
const content = readFileSync(file, "utf8");
for (const className of requiredFinanceAndFileClasses) {
for (const className of requiredUnifiedShellClasses) {
if (!content.includes(className)) {
missing.push(`${file}:${className}`);
}
}
}
const router = readFileSync("src/router/index.ts", "utf8");
const legacyFileVersionRouteIndex = router.indexOf('name: "FileVersionManagement"');
const canonicalDocumentRouteIndex = router.indexOf('path: "trial/:trialId/documents"', legacyFileVersionRouteIndex);
const legacyFileVersionRoute =
legacyFileVersionRouteIndex >= 0 && canonicalDocumentRouteIndex > legacyFileVersionRouteIndex
? router.slice(legacyFileVersionRouteIndex, canonicalDocumentRouteIndex)
: "";
if (router.includes("FileVersionManagement.vue")) {
missing.push("src/router/index.ts:legacy FileVersionManagement component import");
}
if (!legacyFileVersionRoute.includes("component: DocumentList")) {
missing.push("src/router/index.ts:FileVersionManagement->DocumentList");
}
if (!router.includes('next({ name: "DocumentList", params: { trialId: studyStore.currentStudy.id }, replace: true });')) {
missing.push("src/router/index.ts:legacy file version canonical redirect");
}
const adminProjectDetail = readFileSync("src/views/admin/ProjectDetail.vue", "utf8");
const requiredAdminProjectDetailClasses = [
"ctms-page-shell",
+4 -1
View File
@@ -9,6 +9,7 @@
"dialog:allow-save",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-mkdir",
"fs:allow-remove",
{
"identifier": "fs:scope",
@@ -16,7 +17,9 @@
{ "path": "$TEMP/ctms-desktop/**" }
]
},
"notification:default",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"notification:allow-notify",
{
"identifier": "opener:allow-open-path",
"allow": [
Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 B

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 B

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 B

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 B

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 374 KiB

+91 -7
View File
@@ -1,7 +1,8 @@
use sha2::{Digest, Sha256};
use url::Url;
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
const SESSION_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
const LOGIN_CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.login";
fn credential_account(server_origin: &str) -> Result<String, String> {
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
@@ -23,9 +24,9 @@ fn credential_account(server_origin: &str) -> Result<String, String> {
}
#[cfg(any(target_os = "macos", windows))]
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
fn get_entry(service: &str, server_origin: &str) -> Result<keyring::Entry, String> {
let account = credential_account(server_origin)?;
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
keyring::Entry::new(service, &account)
.map_err(|error| format!("无法访问系统凭据库:{error}"))
}
@@ -34,7 +35,7 @@ pub async fn credential_get(server_origin: String) -> Result<Option<String>, Str
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(&server_origin)?;
let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
@@ -59,7 +60,7 @@ pub async fn credential_set(server_origin: String, token: String) -> Result<(),
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
return get_entry(&server_origin)?
return get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?
.set_password(&token)
.map_err(|error| format!("保存系统凭据失败:{error}"));
}
@@ -78,7 +79,7 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(&server_origin)?;
let entry = get_entry(SESSION_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除系统凭据失败:{error}")),
@@ -94,9 +95,74 @@ pub async fn credential_delete(server_origin: String) -> Result<(), String> {
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_get(server_origin: String) -> Result<Option<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.get_password() {
Ok(credential) => Ok(Some(credential)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(format!("读取登录凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("读取登录凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_set(server_origin: String, credential: String) -> Result<(), String> {
if credential.trim().is_empty() {
return Err("拒绝保存空登录凭据".to_string());
}
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
return get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?
.set_password(&credential)
.map_err(|error| format!("保存登录凭据失败:{error}"));
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("保存登录凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn login_credential_delete(server_origin: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(LOGIN_CREDENTIAL_SERVICE, &server_origin)?;
return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除登录凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("删除登录凭据任务失败:{error}"))?
}
#[cfg(test)]
mod tests {
use super::credential_account;
use super::{credential_account, LOGIN_CREDENTIAL_SERVICE, SESSION_CREDENTIAL_SERVICE};
#[test]
fn account_is_stable_for_same_origin() {
@@ -111,4 +177,22 @@ mod tests {
assert!(credential_account("http://ctms.example.com").is_err());
assert!(credential_account("http://localhost:8000").is_ok());
}
#[test]
fn rejects_origins_with_embedded_credentials() {
assert!(credential_account("https://user:secret@ctms.example.com").is_err());
}
#[test]
fn account_does_not_expose_server_origin() {
let account = credential_account("https://ctms.example.com/path").unwrap();
assert!(!account.contains("ctms.example.com"));
assert!(!account.contains("https"));
}
#[test]
fn login_credentials_use_a_separate_keyring_service() {
assert_ne!(SESSION_CREDENTIAL_SERVICE, LOGIN_CREDENTIAL_SERVICE);
}
}
+326 -30
View File
@@ -1,15 +1,91 @@
mod credentials;
mod updates;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
use tauri::{Emitter, Manager, Runtime};
use std::collections::HashSet;
use serde::Deserialize;
use tauri::menu::{
AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID,
};
use tauri::{
Emitter, Manager, RunEvent, Runtime, Theme, WebviewWindow, WebviewWindowBuilder, WindowEvent,
};
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
const VIEW_MENU_ID: &str = "ctms.menu.view";
const NAVIGATION_MENU_ID: &str = "ctms.menu.navigation";
const REFRESH_COMMAND_ID: &str = "ctms.desktop.refresh";
const TOGGLE_SIDEBAR_COMMAND_ID: &str = "ctms.desktop.toggleSidebar";
const BACK_COMMAND_ID: &str = "ctms.desktop.back";
const FORWARD_COMMAND_ID: &str = "ctms.desktop.forward";
const DESKTOP_FULLSCREEN_CHANGED_EVENT: &str = "ctms:desktop-fullscreen-changed";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DesktopMenuShortcuts {
back: String,
forward: String,
refresh: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum DesktopThemePreference {
System,
Light,
Dark,
}
fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
let package = handle.package_info();
let config = handle.config();
let about = AboutMetadata {
name: Some(package.name.clone()),
version: Some(package.version.to_string()),
// macOS falls back to CFBundleVersion when this value is omitted, which
// renders duplicated text such as `Version 0.1.0 (0.1.0)`.
#[cfg(target_os = "macos")]
short_version: Some(String::new()),
copyright: config.bundle.copyright.clone(),
authors: config
.bundle
.publisher
.clone()
.map(|publisher| vec![publisher]),
// Supplying the icon explicitly keeps the native About panel from
// falling back to the generic macOS application/folder icon.
icon: handle.default_window_icon().cloned(),
..Default::default()
};
Menu::with_items(
handle,
&[
#[cfg(target_os = "macos")]
&Submenu::with_items(
handle,
package.name.clone(),
true,
&[
&PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about.clone()))?,
&PredefinedMenuItem::separator(handle)?,
&MenuItem::with_id(
handle,
"ctms.desktop.preferences",
"设置…",
true,
Some("CmdOrCtrl+,"),
)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::services(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::hide(handle, None)?,
&PredefinedMenuItem::hide_others(handle, None)?,
&PredefinedMenuItem::show_all(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::quit(handle, None)?,
],
)?,
&Submenu::with_items(
handle,
"文件",
@@ -22,15 +98,17 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
true,
Some("CmdOrCtrl+K"),
)?,
#[cfg(not(target_os = "macos"))]
&MenuItem::with_id(
handle,
"ctms.desktop.serverSettings",
"服务器设置",
"ctms.desktop.preferences",
"设置",
true,
None::<&str>,
Some("CmdOrCtrl+,"),
)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::close_window(handle, None)?,
#[cfg(not(target_os = "macos"))]
&PredefinedMenuItem::quit(handle, None)?,
],
)?,
@@ -48,44 +126,49 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
&PredefinedMenuItem::select_all(handle, None)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
"视图",
VIEW_MENU_ID,
"显示",
true,
&[
&MenuItem::with_id(
handle,
"ctms.desktop.refresh",
REFRESH_COMMAND_ID,
"刷新当前视图",
true,
Some("CmdOrCtrl+R"),
)?,
&MenuItem::with_id(
handle,
TOGGLE_SIDEBAR_COMMAND_ID,
"显示/隐藏导航栏",
true,
None::<&str>,
)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::fullscreen(handle, None)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
NAVIGATION_MENU_ID,
"导航",
true,
&[
&MenuItem::with_id(handle, BACK_COMMAND_ID, "后退", true, Some("CmdOrCtrl+["))?,
&MenuItem::with_id(
handle,
"ctms.desktop.back",
"返回",
true,
Some("CmdOrCtrl+["),
)?,
&MenuItem::with_id(
handle,
"ctms.desktop.forward",
FORWARD_COMMAND_ID,
"前进",
true,
Some("CmdOrCtrl+]"),
)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
WINDOW_SUBMENU_ID,
"窗口",
true,
&[
@@ -94,17 +177,20 @@ fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<
&PredefinedMenuItem::close_window(handle, None)?,
],
)?,
&Submenu::with_items(
&Submenu::with_id_and_items(
handle,
HELP_SUBMENU_ID,
"帮助",
true,
&[
#[cfg(not(target_os = "macos"))]
&PredefinedMenuItem::about(handle, Some("关于 CTMS"), Some(about))?,
&MenuItem::with_id(
handle,
"ctms.desktop.preferences",
"桌面偏好",
"ctms.desktop.help",
"CTMS 命令与导航",
true,
Some("CmdOrCtrl+,"),
None::<&str>,
)?,
],
)?,
@@ -118,8 +204,167 @@ fn emit_menu_command<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
}
}
fn restore_main_window<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
return Ok(());
}
let config = app
.config()
.app
.windows
.iter()
.find(|config| config.label == "main")
.cloned()
.ok_or_else(|| "未找到主窗口配置".to_string())?;
let window = WebviewWindowBuilder::from_config(app, &config)
.map_err(|error| format!("读取主窗口配置失败:{error}"))?
.build()
.map_err(|error| format!("重建主窗口失败:{error}"))?;
let _ = window.show();
let _ = window.set_focus();
Ok(())
}
fn is_allowed_shortcut_key(value: &str) -> bool {
matches!(
value,
"[" | "]"
| ","
| "."
| "/"
| ";"
| "="
| "-"
| "ArrowLeft"
| "ArrowRight"
| "ArrowUp"
| "ArrowDown"
| "Home"
| "End"
| "PageUp"
| "PageDown"
) || (value.len() == 1
&& value
.as_bytes()
.first()
.is_some_and(|value| value.is_ascii_uppercase() || value.is_ascii_digit()))
}
fn normalize_menu_shortcut(value: &str) -> Result<String, String> {
const RESERVED: &[&str] = &[
"Mod+K",
"Mod+,",
"Mod+W",
"Mod+Q",
"Mod+C",
"Mod+V",
"Mod+X",
"Mod+A",
"Mod+Z",
"Mod+Shift+Z",
];
if RESERVED.contains(&value) {
return Err("快捷键与系统或应用保留快捷键冲突".to_string());
}
let parts = value.split('+').collect::<Vec<_>>();
if parts.len() < 2 || parts[0] != "Mod" {
return Err("快捷键必须以 Mod 开头".to_string());
}
let key = parts.last().copied().unwrap_or_default();
if !is_allowed_shortcut_key(key) {
return Err("快捷键按键不受支持".to_string());
}
let mut modifiers = HashSet::new();
for modifier in &parts[1..parts.len() - 1] {
if !matches!(*modifier, "Shift" | "Alt") || !modifiers.insert(*modifier) {
return Err("快捷键修饰键不受支持或重复".to_string());
}
}
Ok(format!("CmdOrCtrl+{}", parts[1..].join("+")))
}
fn set_menu_accelerator<R: Runtime>(
app: &tauri::AppHandle<R>,
submenu_id: &str,
item_id: &str,
accelerator: &str,
) -> Result<(), String> {
let menu = app.menu().ok_or_else(|| "桌面菜单尚未初始化".to_string())?;
let submenu = menu
.get(submenu_id)
.and_then(|item| item.as_submenu().cloned())
.ok_or_else(|| format!("未找到桌面菜单:{submenu_id}"))?;
let item = submenu
.get(item_id)
.and_then(|item| item.as_menuitem().cloned())
.ok_or_else(|| format!("未找到桌面菜单项:{item_id}"))?;
item.set_accelerator(Some(accelerator))
.map_err(|error| format!("更新桌面菜单快捷键失败:{error}"))
}
#[tauri::command]
fn desktop_menu_set_shortcuts<R: Runtime>(
app: tauri::AppHandle<R>,
shortcuts: DesktopMenuShortcuts,
) -> Result<(), String> {
let back = normalize_menu_shortcut(&shortcuts.back)?;
let forward = normalize_menu_shortcut(&shortcuts.forward)?;
let refresh = normalize_menu_shortcut(&shortcuts.refresh)?;
if HashSet::from([back.as_str(), forward.as_str(), refresh.as_str()]).len() != 3 {
return Err("桌面快捷键不能重复".to_string());
}
set_menu_accelerator(&app, NAVIGATION_MENU_ID, BACK_COMMAND_ID, &back)?;
set_menu_accelerator(&app, NAVIGATION_MENU_ID, FORWARD_COMMAND_ID, &forward)?;
set_menu_accelerator(&app, VIEW_MENU_ID, REFRESH_COMMAND_ID, &refresh)?;
Ok(())
}
#[tauri::command]
fn desktop_window_set_theme<R: Runtime>(
window: WebviewWindow<R>,
theme: DesktopThemePreference,
) -> Result<(), String> {
let theme = match theme {
DesktopThemePreference::System => None,
DesktopThemePreference::Light => Some(Theme::Light),
DesktopThemePreference::Dark => Some(Theme::Dark),
};
window
.set_theme(theme)
.map_err(|error| format!("更新桌面窗口主题失败:{error}"))
}
#[tauri::command]
fn desktop_window_get_fullscreen<R: Runtime>(window: WebviewWindow<R>) -> Result<bool, String> {
window
.is_fullscreen()
.map_err(|error| format!("读取桌面窗口全屏状态失败:{error}"))
}
#[tauri::command]
fn desktop_window_set_fullscreen<R: Runtime>(
window: WebviewWindow<R>,
fullscreen: bool,
) -> Result<bool, String> {
window
.set_fullscreen(fullscreen)
.map_err(|error| format!("切换桌面窗口全屏状态失败:{error}"))?;
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
Ok(fullscreen)
}
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
.menu(desktop_menu)
.on_menu_event(|app, event| {
let command = event.id().as_ref();
@@ -128,11 +373,7 @@ pub fn run() {
}
})
.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();
}
let _ = restore_main_window(app);
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
@@ -144,9 +385,64 @@ pub fn run() {
credentials::credential_get,
credentials::credential_set,
credentials::credential_delete,
credentials::login_credential_get,
credentials::login_credential_set,
credentials::login_credential_delete,
updates::desktop_update_check,
updates::desktop_update_install,
desktop_menu_set_shortcuts,
desktop_window_set_theme,
desktop_window_get_fullscreen,
desktop_window_set_fullscreen,
])
.run(tauri::generate_context!())
.expect("error while running CTMS desktop application");
.build(tauri::generate_context!())
.expect("error while building CTMS desktop application");
app.run(|app, event| {
#[cfg(target_os = "macos")]
if let RunEvent::Reopen { .. } = &event {
if let Err(error) = restore_main_window(app) {
eprintln!("{error}");
}
}
if let RunEvent::WindowEvent {
label,
event: WindowEvent::Resized(_),
..
} = &event
{
if label == "main" {
if let Some(window) = app.get_webview_window(label) {
if let Ok(fullscreen) = window.is_fullscreen() {
let _ = window.emit(DESKTOP_FULLSCREEN_CHANGED_EVENT, fullscreen);
}
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::normalize_menu_shortcut;
#[test]
fn converts_runtime_shortcuts_to_native_accelerators() {
assert_eq!(
normalize_menu_shortcut("Mod+Shift+[").unwrap(),
"CmdOrCtrl+Shift+["
);
assert_eq!(
normalize_menu_shortcut("Mod+Alt+ArrowLeft").unwrap(),
"CmdOrCtrl+Alt+ArrowLeft"
);
}
#[test]
fn rejects_reserved_or_malformed_shortcuts() {
assert!(normalize_menu_shortcut("Mod+Q").is_err());
assert!(normalize_menu_shortcut("Ctrl+R").is_err());
assert!(normalize_menu_shortcut("Mod+Shift+Shift+R").is_err());
assert!(normalize_menu_shortcut("Mod+F12").is_err());
}
}
+16 -3
View File
@@ -17,17 +17,30 @@
"width": 1440,
"height": 900,
"minWidth": 1180,
"minHeight": 760
"minHeight": 760,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"backgroundColor": "#f9fafb",
"backgroundThrottling": "disabled",
"trafficLightPosition": { "x": 16, "y": 18 }
}
],
"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'"
"csp": "default-src 'self' customprotocol: asset:; script-src 'self'; 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: https: http://localhost:* http://127.0.0.1:*; object-src 'none'; base-uri 'self'; form-action 'self'"
}
},
"bundle": {
"active": true,
"targets": ["app", "dmg"],
"createUpdaterArtifacts": true
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
},
"plugins": {
"updater": {
+17 -7
View File
@@ -1,20 +1,29 @@
<template>
<div class="app-shell">
<router-view />
<SessionTimeoutPrompt />
</div>
<el-config-provider :locale="zhCn">
<div class="app-shell">
<router-view />
<SessionTimeoutPrompt />
</div>
</el-config-provider>
<!-- TODO: 预留聚合接口页面/study/{id}/overview /subjects/{id}/detail /sites/{id}/summary -->
</template>
<script setup lang="ts">
import zhCn from "element-plus/es/locale/lang/zh-cn";
import { initSessionManager } from "./session/sessionManager";
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
import { initializeDesktopMenuShortcutSync, initializeDesktopThemePreference, isTauriRuntime } from "./runtime";
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
if (isTauriRuntime()) {
applyDesktopThemePreference();
const isDesktopRuntime = isTauriRuntime();
if (isDesktopRuntime) {
initializeDesktopThemePreference();
initializeDesktopMenuShortcutSync();
document.body.classList.add("is-desktop-runtime");
} else {
document.body.classList.remove("is-desktop-runtime");
}
initSessionManager();
initDesktopNotificationManager();
@@ -25,5 +34,6 @@ initDesktopUpdateManager();
.app-shell {
min-height: 100vh;
min-height: 100dvh;
background: var(--ctms-bg-base);
}
</style>
+56
View File
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiDelete = vi.fn();
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({
apiDelete,
apiGet,
apiPost,
}));
describe("attachments api", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("downloads attachment blobs without putting credentials in the URL", async () => {
const { downloadAttachment } = await import("./attachments");
downloadAttachment("attachment-1");
expect(apiGet).toHaveBeenCalledWith("/api/v1/attachments/attachment-1/download", {
responseType: "blob",
});
expect(apiGet.mock.calls[0][0]).not.toContain("token");
expect(apiGet.mock.calls[0][0]).not.toContain("access_token");
});
it("uploads attachments as multipart form data", async () => {
const { uploadAttachment } = await import("./attachments");
const file = new File(["content"], "report.pdf", { type: "application/pdf" });
const onUploadProgress = vi.fn();
uploadAttachment("study-1", "startup_initiation", "entity-1", file, { onUploadProgress });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/startup_initiation/entity-1/attachments/",
expect.any(FormData),
{
headers: { "Content-Type": "multipart/form-data" },
onUploadProgress,
},
);
const formData = apiPost.mock.calls[0][1] as FormData;
expect(formData.get("file")).toBe(file);
});
it("deletes attachments through the scoped attachment endpoint", async () => {
const { deleteAttachment } = await import("./attachments");
deleteAttachment("attachment-1");
expect(apiDelete).toHaveBeenCalledWith("/api/v1/attachments/attachment-1");
});
});
+2 -2
View File
@@ -1,8 +1,8 @@
import { apiGet, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api";
import type { ApiListResponse, AuditLogItem } from "../types/api";
export const fetchAuditLogs = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
apiGet<ApiListResponse<AuditLogItem>>(`/api/v1/studies/${studyId}/audit-logs/`, { params });
export const createAuditEvent = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload);
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPatch = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({
default: {},
apiGet,
apiPatch,
apiPost,
}));
describe("auth api", () => {
it("does not deduplicate login key challenges", async () => {
const { getLoginKey } = await import("./auth");
getLoginKey();
expect(apiGet).toHaveBeenCalledWith("/api/v1/auth/login-key", { disableRequestDedupe: true });
});
});
+6 -5
View File
@@ -1,5 +1,5 @@
import type { AxiosResponse } from "axios";
import api, { apiGet, apiPatch, apiPost } from "./axios";
import api, { apiGet, apiPatch, apiPost, type ApiRequestConfig } from "./axios";
import type {
UserMeResponse,
LoginRequest,
@@ -22,12 +22,13 @@ export const devLogin = (payload: DevLoginRequest): Promise<AxiosResponse<LoginR
apiPost<LoginResponse>("/api/v1/auth/dev-login", payload);
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
apiGet<LoginKeyResponse>("/api/v1/auth/login-key");
apiGet<LoginKeyResponse>("/api/v1/auth/login-key", { disableRequestDedupe: true });
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export const fetchMe = (config?: ApiRequestConfig): Promise<AxiosResponse<UserMeResponse>> =>
apiGet<UserMeResponse>("/api/v1/auth/me", config);
export const fetchEmailDomains = (): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains");
export const fetchEmailDomains = (config?: ApiRequestConfig): Promise<AxiosResponse<EmailDomainsResponse>> =>
apiGet<EmailDomainsResponse>("/api/v1/auth/email-domains", config);
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register", payload);
+18 -4
View File
@@ -33,10 +33,10 @@ export type LoginKeyResponse = {
expires_at: string;
};
export type EncryptedPasswordRequest = {
key_id: string;
challenge: string;
ciphertext: string;
export type SessionHeartbeatResponse = {
status: "online";
last_seen_at: string;
client_ip: string | null;
};
export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse>> =>
@@ -53,4 +53,18 @@ export const extendToken = (token: string): Promise<AxiosResponse<ExtendResponse
export const getLoginKey = (): Promise<AxiosResponse<LoginKeyResponse>> =>
authClient.get<LoginKeyResponse>("/api/v1/auth/login-key");
export const heartbeatSession = (token: string): Promise<AxiosResponse<SessionHeartbeatResponse>> =>
authClient.post<SessionHeartbeatResponse>(
"/api/v1/auth/session/heartbeat",
{},
{ headers: { Authorization: `Bearer ${token}` }, timeout: 5000 },
);
export const logoutSession = (token: string): Promise<AxiosResponse<void>> =>
authClient.post<void>(
"/api/v1/auth/session/logout",
{},
{ headers: { Authorization: `Bearer ${token}` } },
);
export default authClient;
+177 -19
View File
@@ -9,35 +9,21 @@ vi.mock("element-plus", () => ({
},
}));
vi.mock("../router", () => ({
default: {
push: vi.fn(),
},
}));
vi.mock("../utils/auth", () => ({
getToken: vi.fn(() => undefined),
}));
vi.mock("../store/study", () => ({
useStudyStore: vi.fn(() => ({
clearCurrentStudy: vi.fn(),
})),
}));
vi.mock("../session/sessionManager", () => ({
extendAccessToken: vi.fn(),
forceLogout: vi.fn(),
LOGOUT_REASON_AUTH_EXPIRED: "auth-expired",
}));
describe("api axios retry", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
errorMessage.mockClear();
});
afterEach(() => {
afterEach(async () => {
const { clearApiResponseCache, setApiResponseCacheScope } = await import("./axios");
setApiResponseCacheScope(null);
clearApiResponseCache();
vi.useRealTimers();
});
@@ -67,4 +53,176 @@ describe("api axios retry", () => {
await expect(request).resolves.toMatchObject({ message: "Network Error" });
expect(errorMessage).toHaveBeenCalledTimes(1);
});
it("deduplicates concurrent apiGet calls for the same request", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const [first, second] = await Promise.all([
apiGet("/api/v1/studies/", { adapter }),
apiGet("/api/v1/studies/", { adapter }),
]);
expect(adapter).toHaveBeenCalledTimes(1);
expect(first.data).toEqual({ ok: true });
expect(second.data).toEqual({ ok: true });
});
it("does not deduplicate requests when disabled by the caller", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await Promise.all([
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
apiGet("/api/v1/auth/login-key", { adapter, disableRequestDedupe: true }),
]);
expect(adapter).toHaveBeenCalledTimes(2);
});
it("does not attach the CTMS login token to public share requests", async () => {
const auth = await import("../utils/auth");
vi.mocked(auth.getToken).mockReturnValue("private-login-token");
const api = (await import("./axios")).default;
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { ok: true },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await api.get("/api/v1/collaboration/shares/metadata", {
adapter,
publicRequest: true,
headers: { "X-CTMS-Share-Token": "share-token" },
} as any);
const config = adapter.mock.calls[0]?.[0] as InternalAxiosRequestConfig;
expect(config.headers.Authorization).toBeUndefined();
expect(config.headers["X-CTMS-Share-Token"]).toBe("share-token");
vi.mocked(auth.getToken).mockReturnValue(null);
});
it("keeps request headers in the dedupe key when they can affect the response", async () => {
const { apiGet } = await import("./axios");
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { accept: (config.headers as any)?.Accept },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const [json, csv] = await Promise.all([
apiGet("/api/v1/reports", { adapter, headers: { Accept: "application/json" } }),
apiGet("/api/v1/reports", { adapter, headers: { Accept: "text/csv" } }),
]);
expect(adapter).toHaveBeenCalledTimes(2);
expect(json.data).toEqual({ accept: "application/json" });
expect(csv.data).toEqual({ accept: "text/csv" });
});
it("serves configured apiGet calls from memory cache until ttl expires", async () => {
const { apiGet } = await import("./axios");
let sequence = 0;
const adapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { sequence: (sequence += 1) },
status: 200,
statusText: "OK",
headers: { etag: "v1" },
config: config as InternalAxiosRequestConfig,
}));
const first = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
const second = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(first.data).toEqual({ sequence: 1 });
expect(second.data).toEqual({ sequence: 1 });
expect(adapter).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
const third = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(third.data).toEqual({ sequence: 2 });
expect(adapter).toHaveBeenCalledTimes(2);
});
it("clears cached GET responses after successful mutations", async () => {
const { apiGet, apiPost } = await import("./axios");
let sequence = 0;
const getAdapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { sequence: (sequence += 1) },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
const postAdapter = vi.fn<AxiosAdapter>(async (config) => ({
data: { id: "study-1" },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
}));
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
await apiGet("/api/v1/studies/", { adapter: getAdapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(getAdapter).toHaveBeenCalledTimes(1);
await apiPost("/api/v1/studies/", { name: "Study" }, { adapter: postAdapter });
const afterMutation = await apiGet("/api/v1/studies/", {
adapter: getAdapter,
cache: { ttlMs: 1000, tags: ["studies"] },
});
expect(afterMutation.data).toEqual({ sequence: 2 });
expect(getAdapter).toHaveBeenCalledTimes(2);
expect(postAdapter).toHaveBeenCalledTimes(1);
});
it("does not write an in-flight GET response into cache after the cache generation changes", async () => {
const { apiGet, clearApiResponseCache } = await import("./axios");
let sequence = 0;
let resolveFirst: (() => void) | undefined;
const adapter = vi.fn<AxiosAdapter>((config) => {
sequence += 1;
const response = {
data: { sequence },
status: 200,
statusText: "OK",
headers: {},
config: config as InternalAxiosRequestConfig,
};
if (sequence === 1) {
return new Promise((resolve) => {
resolveFirst = () => resolve(response);
});
}
return Promise.resolve(response);
});
const firstRequest = apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
await vi.dynamicImportSettled();
expect(adapter).toHaveBeenCalledTimes(1);
clearApiResponseCache();
resolveFirst?.();
await expect(firstRequest).resolves.toMatchObject({ data: { sequence: 1 } });
const afterClear = await apiGet("/api/v1/studies/", { adapter, cache: { ttlMs: 1000, tags: ["studies"] } });
expect(afterClear.data).toEqual({ sequence: 2 });
expect(adapter).toHaveBeenCalledTimes(2);
});
});
+220 -27
View File
@@ -4,6 +4,11 @@ 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";
import {
clearInvalidStudyAndNavigate,
extendAccessTokenAfterUnauthorized,
forceAuthExpiredLogout,
} from "./runtimeHooks";
const instance: AxiosInstance = axios.create({
baseURL: clientRuntime.apiBaseUrl(),
@@ -14,45 +19,201 @@ export const refreshApiBaseUrl = (): void => {
instance.defaults.baseURL = clientRuntime.apiBaseUrl();
};
if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl);
}
const NETWORK_RETRY_LIMIT = 10;
const NETWORK_RETRY_DELAY_MS = 30000;
const DEFAULT_GET_CACHE_TTL_MS = 30_000;
const API_RESPONSE_CACHE_SCHEMA_VERSION = 1;
export type ApiRequestConfig = AxiosRequestConfig & {
publicRequest?: boolean;
suppressErrorMessage?: boolean;
_retry?: boolean;
_networkRetryCount?: number;
disableNetworkRetry?: boolean;
disableRequestDedupe?: boolean;
cache?: boolean | ApiCacheConfig;
invalidateCache?: boolean | { tags?: string[] };
};
export interface ApiCacheConfig {
ttlMs?: number;
key?: string;
tags?: string[];
}
interface CachedAxiosResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Record<string, string>;
}
const pendingGetRequests = new Map<string, Promise<AxiosResponse<any>>>();
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
const clearInvalidStudyAndNavigate = async () => {
try {
const [{ useStudyStore }, { default: router }] = await Promise.all([
import("../store/study"),
import("../router"),
]);
useStudyStore().clearCurrentStudy();
router.push("/admin/projects");
} catch {
/* ignore */
const sensitiveHeaderPattern = /\b(?:authorization|bearer|cookie|token|password|credential|secret)\b/i;
const stableSerialize = (value: unknown): string => {
if (value === null || value === undefined) return "";
if (value instanceof URLSearchParams) {
return stableSerialize(Object.fromEntries([...value.entries()].sort(([left], [right]) => left.localeCompare(right))));
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
}
if (typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
};
const forceAuthExpiredLogout = async () => {
const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager");
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
const safeClone = <T>(value: T): T => {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
};
const rawHeaderEntries = (headers: AxiosRequestConfig["headers"]): Array<[string, unknown]> => {
if (!headers) return [];
if (typeof (headers as any).toJSON === "function") {
return Object.entries((headers as any).toJSON());
}
if (typeof (headers as any).forEach === "function") {
const entries: Array<[string, unknown]> = [];
(headers as any).forEach((value: unknown, key: string) => entries.push([key, value]));
return entries;
}
return Object.entries(headers as Record<string, unknown>);
};
const hasSensitiveRequestIdentityConfig = (config?: ApiRequestConfig) =>
Boolean(config?.auth) ||
rawHeaderEntries(config?.headers).some(
([key, value]) => sensitiveHeaderPattern.test(key) || sensitiveHeaderPattern.test(String(value)),
);
const shouldSkipGetCache = (config?: ApiRequestConfig) =>
config?.responseType === "blob" ||
config?.responseType === "arraybuffer" ||
config?.cache === false ||
hasSensitiveRequestIdentityConfig(config);
const normalizeCacheConfig = (config?: ApiRequestConfig): ApiCacheConfig | null => {
if (!config?.cache || shouldSkipGetCache(config)) return null;
if (config.cache === true) return { ttlMs: DEFAULT_GET_CACHE_TTL_MS };
return {
ttlMs: config.cache.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS,
key: config.cache.key,
tags: config.cache.tags,
};
};
const createGetCacheKey = (url: string, config?: ApiRequestConfig, explicitKey?: string) => {
if (explicitKey) return explicitKey;
return stableSerialize({
baseURL: config?.baseURL || instance.defaults.baseURL || "",
headers: Object.fromEntries(
rawHeaderEntries(config?.headers)
.filter(([key, value]) => !sensitiveHeaderPattern.test(key) && !sensitiveHeaderPattern.test(String(value)))
.map(([key, value]) => [key.toLowerCase(), String(value)])
.sort(([left], [right]) => left.localeCompare(right)),
),
method: "get",
params: config?.params || null,
paramsSerializer: config?.paramsSerializer ? String(config.paramsSerializer) : null,
responseType: config?.responseType || "json",
schemaVersion: API_RESPONSE_CACHE_SCHEMA_VERSION,
url,
withCredentials: config?.withCredentials || false,
});
};
const createPendingGetKey = (url: string, config?: ApiRequestConfig) =>
createGetCacheKey(url, config, typeof config?.cache === "object" ? config.cache.key : undefined);
const cacheableResponseHeaders = (headers: AxiosResponse["headers"]): Record<string, string> => {
const rawHeaders =
headers && typeof (headers as any).toJSON === "function" ? (headers as any).toJSON() : (headers as Record<string, unknown>);
const allowed = ["cache-control", "etag", "last-modified"];
return Object.fromEntries(
Object.entries(rawHeaders || {})
.filter(([key]) => allowed.includes(key.toLowerCase()))
.map(([key, value]) => [key, String(value)]),
);
};
const toCachedResponse = <T>(response: AxiosResponse<T>): CachedAxiosResponse<T> => ({
data: safeClone(response.data),
status: response.status,
statusText: response.statusText,
headers: cacheableResponseHeaders(response.headers),
});
const fromCachedResponse = <T>(cached: CachedAxiosResponse<T>, config?: ApiRequestConfig): AxiosResponse<T> => ({
data: safeClone(cached.data),
status: cached.status,
statusText: cached.statusText,
headers: cached.headers,
config: (config || {}) as InternalAxiosRequestConfig,
});
export const clearApiResponseCache = (): void => {
pendingGetRequests.clear();
clientRuntime.dataCache.clearDesktopDataCache();
};
export const setApiResponseCacheScope = (scope?: string | null): void => {
pendingGetRequests.clear();
clientRuntime.dataCache.setDesktopDataCacheScope(scope);
};
const requestWithDedupe = <T>(url: string, config?: ApiRequestConfig): Promise<AxiosResponse<T>> => {
if (config?.disableRequestDedupe || shouldSkipGetCache(config)) {
return instance.get<T>(url, config);
}
const pendingKey = createPendingGetKey(url, config);
const existing = pendingGetRequests.get(pendingKey) as Promise<AxiosResponse<T>> | undefined;
if (existing) return existing;
const request = instance.get<T>(url, config).finally(() => {
pendingGetRequests.delete(pendingKey);
});
pendingGetRequests.set(pendingKey, request);
return request;
};
const invalidateCacheAfterMutation = async <T>(
request: Promise<AxiosResponse<T>>,
config?: ApiRequestConfig,
): Promise<AxiosResponse<T>> => {
const response = await request;
const invalidation = config?.invalidateCache;
if (invalidation !== false) {
if (typeof invalidation === "object" && invalidation.tags?.length) {
clientRuntime.dataCache.invalidateDesktopDataCacheByTags(invalidation.tags);
} else {
clearApiResponseCache();
}
}
return response;
};
if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, () => {
refreshApiBaseUrl();
setApiResponseCacheScope(null);
});
}
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
config.headers = config.headers || {};
Object.assign(config.headers, getAppMetadataHeaders());
const token = getToken();
if (token) {
if (token && !config.publicRequest) {
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return config;
@@ -64,12 +225,13 @@ instance.interceptors.response.use(
const status = error.response?.status;
const data = error.response?.data;
const reqUrl = error.config?.url || "";
const isPublicRequest = Boolean((error.config as ApiRequestConfig | undefined)?.publicRequest);
const isAuthEndpoint =
reqUrl.includes("/api/v1/auth/login") ||
reqUrl.includes("/api/v1/auth/register") ||
reqUrl.includes("/api/v1/auth/password-reset") ||
reqUrl.includes("/api/v1/auth/extend");
if (isAuthEndpoint) {
if (isAuthEndpoint || isPublicRequest) {
// 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error);
}
@@ -88,9 +250,9 @@ instance.interceptors.response.use(
}
if (status === 401) {
const config = error.config as ApiRequestConfig;
let clearedCacheForAuthFailure = false;
if (config && !config._retry) {
const { extendAccessToken } = await import("../session/sessionManager");
const result = await extendAccessToken("response-401");
const result = await extendAccessTokenAfterUnauthorized();
if (result.token) {
config._retry = true;
config.headers = config.headers || {};
@@ -98,12 +260,20 @@ instance.interceptors.response.use(
return instance(config);
}
if (result.authFailed) {
await forceAuthExpiredLogout();
clearApiResponseCache();
clearedCacheForAuthFailure = true;
}
}
if (!clearedCacheForAuthFailure) {
clearApiResponseCache();
}
} else if (status === 403 && (data as any)?.detail === "账号已停用") {
clearApiResponseCache();
await forceAuthExpiredLogout();
} else {
if (status === 403) {
clearApiResponseCache();
}
const suppressErrorMessage = (error.config as ApiRequestConfig | undefined)?.suppressErrorMessage;
if (!suppressErrorMessage) {
if (data?.message || (data as any)?.detail) {
@@ -120,14 +290,37 @@ instance.interceptors.response.use(
}
);
export const apiGet = <T = any>(url: string, config?: ApiRequestConfig) => instance.get<T>(url, config);
export const apiGet = async <T = any>(url: string, config?: ApiRequestConfig) => {
const cacheConfig = normalizeCacheConfig(config);
const cacheKey = cacheConfig ? createGetCacheKey(url, config, cacheConfig.key) : null;
const cacheGeneration = cacheConfig ? clientRuntime.dataCache.getDesktopDataCacheGeneration() : null;
if (cacheConfig && cacheKey) {
const cached = clientRuntime.dataCache.readDesktopDataCache<CachedAxiosResponse<T>>(cacheKey);
if (cached) {
return fromCachedResponse(cached, config);
}
}
const response = await requestWithDedupe<T>(url, config);
if (
cacheConfig &&
cacheKey &&
cacheGeneration !== null &&
clientRuntime.dataCache.isDesktopDataCacheGenerationCurrent(cacheGeneration)
) {
clientRuntime.dataCache.writeDesktopDataCache(cacheKey, toCachedResponse(response), {
ttlMs: cacheConfig.ttlMs ?? DEFAULT_GET_CACHE_TTL_MS,
tags: cacheConfig.tags,
});
}
return response;
};
export const apiPost = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.post<T>(url, data, config);
invalidateCacheAfterMutation(instance.post<T>(url, data, config), config);
export const apiPatch = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.patch<T>(url, data, config);
invalidateCacheAfterMutation(instance.patch<T>(url, data, config), config);
export const apiPut = <T = any>(url: string, data?: unknown, config?: ApiRequestConfig) =>
instance.put<T>(url, data, config);
invalidateCacheAfterMutation(instance.put<T>(url, data, config), config);
export const apiDelete = <T = any>(url: string, config?: ApiRequestConfig) =>
instance.delete<T>(url, config);
invalidateCacheAfterMutation(instance.delete<T>(url, config), config);
export default instance;
+165
View File
@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPut = vi.fn();
const apiPatch = vi.fn();
const apiDelete = vi.fn();
vi.mock("./axios", () => ({ apiGet, apiPost, apiPut, apiPatch, apiDelete }));
describe("collaboration API", () => {
beforeEach(() => vi.clearAllMocks());
it("keeps editor configuration outside request dedupe and desktop cache", async () => {
const { fetchCollaborationEditorConfig } = await import("./collaboration");
fetchCollaborationEditorConfig("study-1", "file-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/editor-config",
{
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
},
);
});
it("uses an independent collaboration namespace", async () => {
const { createCollaborationFile, fetchCollaborationFiles } = await import("./collaboration");
createCollaborationFile("study-1", { title: "方案", file_type: "word" });
fetchCollaborationFiles("study-1", { keyword: "方案" });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files",
{ title: "方案", file_type: "word" },
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files",
{ params: { keyword: "方案" } },
);
});
it("records a completed local download separately from workspace Save As", async () => {
const { recordCollaborationDownload } = await import("./collaboration");
recordCollaborationDownload("study-1", "file-1", "xlsx");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/downloads",
{ file_type: "xlsx" },
);
});
it("copies and downloads collaboration files through authenticated endpoints", async () => {
const { copyCollaborationFile, downloadCollaborationFile } = await import("./collaboration");
copyCollaborationFile("study-1", "file-1");
downloadCollaborationFile("study-1", "file-1");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/copy",
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/download",
{
responseType: "blob",
cache: false,
suppressErrorMessage: true,
},
);
});
it("always reloads revision history without cache or pending-request reuse", async () => {
const { fetchCollaborationRevisions } = await import("./collaboration");
fetchCollaborationRevisions("study-1", "file-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions",
{ cache: false, disableRequestDedupe: true },
);
});
it("supports naming, copying, and deleting a selected historical revision", async () => {
const { copyCollaborationRevision, deleteCollaborationRevision, updateCollaborationRevision } = await import("./collaboration");
updateCollaborationRevision("study-1", "file-1", "revision-2", { change_summary: "送审版" });
copyCollaborationRevision("study-1", "file-1", "revision-2", { title: "方案-R2.docx", folder_id: "folder-2" });
deleteCollaborationRevision("study-1", "file-1", "revision-2");
expect(apiPatch).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2",
{ change_summary: "送审版" },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2/copy",
{ title: "方案-R2.docx", folder_id: "folder-2" },
);
expect(apiDelete).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/revisions/revision-2",
);
});
it("keeps share tokens in a redacted request header and public requests outside auth recovery", async () => {
const {
fetchCollaborationShareLink,
fetchPublicCollaborationShareMetadata,
updateCollaborationShareLink,
verifyPublicCollaborationSharePassword,
} = await import("./collaboration");
fetchCollaborationShareLink("study-1", "file-1");
updateCollaborationShareLink("study-1", "file-1", {
enabled: true,
access_mode: "EDIT",
expiry_policy: "SEVEN_DAYS",
password_mode: "SET",
password: "1234",
});
fetchPublicCollaborationShareMetadata("share-secret");
verifyPublicCollaborationSharePassword("share-secret", "1234");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/share-link",
{ cache: false, disableRequestDedupe: true },
);
expect(apiPut).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/share-link",
expect.objectContaining({ enabled: true, expiry_policy: "SEVEN_DAYS", password: "1234" }),
);
const publicConfig = expect.objectContaining({
headers: { "X-CTMS-Share-Token": "share-secret" },
publicRequest: true,
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
invalidateCache: false,
});
expect(apiGet).toHaveBeenCalledWith("/api/v1/collaboration/shares/metadata", publicConfig);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/collaboration/shares/access",
{ password: "1234" },
publicConfig,
);
});
it("supports edit-request approval and contact ownership transfer", async () => {
const {
createCollaborationEditRequest,
fetchCollaborationEditRequests,
resolveCollaborationEditRequest,
transferCollaborationOwnership,
} = await import("./collaboration");
createCollaborationEditRequest("study-1", "file-1");
fetchCollaborationEditRequests("study-1", "file-1");
resolveCollaborationEditRequest("study-1", "file-1", "request-1", "APPROVED");
transferCollaborationOwnership("study-1", "file-1", "user-2");
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests",
);
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests",
{ cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/edit-requests/request-1/resolve",
{ status: "APPROVED" },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/collaboration/files/file-1/transfer-ownership",
{ new_owner_id: "user-2" },
);
});
});
+195
View File
@@ -0,0 +1,195 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "./axios";
import type {
CollaborationCandidate,
CollaborationEditorConfig,
CollaborationEditRequest,
CollaborationFile,
CollaborationFileType,
CollaborationFolder,
CollaborationMember,
CollaborationMemberRole,
CollaborationPublicShareMetadata,
CollaborationRevision,
CollaborationShareAccessGrant,
CollaborationShareAccessMode,
CollaborationShareExpiryPolicy,
CollaborationShareLink,
} from "../types/collaboration";
const baseUrl = (studyId: string) => `/api/v1/studies/${studyId}/collaboration`;
export const fetchCollaborationFolders = (studyId: string) =>
apiGet<CollaborationFolder[]>(`${baseUrl(studyId)}/folders`);
export const createCollaborationFolder = (studyId: string, payload: { name: string; parent_id?: string | null }) =>
apiPost<CollaborationFolder>(`${baseUrl(studyId)}/folders`, payload);
export const updateCollaborationFolder = (studyId: string, folderId: string, payload: Record<string, unknown>) =>
apiPatch<CollaborationFolder>(`${baseUrl(studyId)}/folders/${folderId}`, payload);
export const deleteCollaborationFolder = (studyId: string, folderId: string) =>
apiDelete(`${baseUrl(studyId)}/folders/${folderId}`);
export const fetchCollaborationFiles = (
studyId: string,
params?: { folder_id?: string; keyword?: string; deleted?: boolean },
) => apiGet<CollaborationFile[]>(`${baseUrl(studyId)}/files`, { params });
export const fetchCollaborationFile = (studyId: string, fileId: string) =>
apiGet<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}`, {
cache: false,
disableRequestDedupe: true,
});
export const createCollaborationFile = (
studyId: string,
payload: { title: string; file_type: CollaborationFileType; folder_id?: string | null },
) => apiPost<CollaborationFile>(`${baseUrl(studyId)}/files`, payload);
export const importCollaborationFile = (studyId: string, payload: FormData) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/import`, payload, {
headers: { "Content-Type": "multipart/form-data" },
});
export const copyCollaborationFile = (studyId: string, fileId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/copy`);
export const downloadCollaborationFile = (studyId: string, fileId: string) =>
apiGet<Blob>(`${baseUrl(studyId)}/files/${fileId}/download`, {
responseType: "blob",
cache: false,
suppressErrorMessage: true,
});
export const updateCollaborationFile = (studyId: string, fileId: string, payload: Record<string, unknown>) =>
apiPatch<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}`, payload);
export const trashCollaborationFile = (studyId: string, fileId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}`);
export const restoreCollaborationFile = (studyId: string, fileId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/restore`);
export const fetchCollaborationMembers = (studyId: string, fileId: string) =>
apiGet<CollaborationMember[]>(`${baseUrl(studyId)}/files/${fileId}/members`);
export const fetchCollaborationCandidates = (studyId: string) =>
apiGet<CollaborationCandidate[]>(`${baseUrl(studyId)}/member-candidates`);
export const upsertCollaborationMember = (
studyId: string,
fileId: string,
payload: { user_id: string; role: CollaborationMemberRole },
) => apiPut<CollaborationMember>(`${baseUrl(studyId)}/files/${fileId}/members`, payload);
export const removeCollaborationMember = (studyId: string, fileId: string, userId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}/members/${userId}`);
export const createCollaborationEditRequest = (studyId: string, fileId: string) =>
apiPost<CollaborationEditRequest>(`${baseUrl(studyId)}/files/${fileId}/edit-requests`);
export const fetchCollaborationEditRequests = (studyId: string, fileId: string) =>
apiGet<CollaborationEditRequest[]>(`${baseUrl(studyId)}/files/${fileId}/edit-requests`, {
cache: false,
disableRequestDedupe: true,
});
export const resolveCollaborationEditRequest = (
studyId: string,
fileId: string,
requestId: string,
status: "APPROVED" | "REJECTED",
) => apiPost<CollaborationEditRequest>(
`${baseUrl(studyId)}/files/${fileId}/edit-requests/${requestId}/resolve`,
{ status },
);
export const transferCollaborationOwnership = (studyId: string, fileId: string, newOwnerId: string) =>
apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/transfer-ownership`, {
new_owner_id: newOwnerId,
});
export const fetchCollaborationShareLink = (studyId: string, fileId: string) =>
apiGet<CollaborationShareLink>(`${baseUrl(studyId)}/files/${fileId}/share-link`, {
cache: false,
disableRequestDedupe: true,
});
export const updateCollaborationShareLink = (
studyId: string,
fileId: string,
payload: {
enabled: boolean;
access_mode: CollaborationShareAccessMode;
expiry_policy: CollaborationShareExpiryPolicy;
password_mode: "KEEP" | "SET" | "CLEAR";
password?: string;
},
) => apiPut<CollaborationShareLink>(`${baseUrl(studyId)}/files/${fileId}/share-link`, payload);
export const fetchCollaborationRevisions = (studyId: string, fileId: string) =>
apiGet<CollaborationRevision[]>(`${baseUrl(studyId)}/files/${fileId}/revisions`, {
cache: false,
disableRequestDedupe: true,
});
export const restoreCollaborationRevision = (studyId: string, fileId: string, revisionId: string) =>
apiPost<CollaborationRevision>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/restore`, {});
export const updateCollaborationRevision = (
studyId: string,
fileId: string,
revisionId: string,
payload: { change_summary: string },
) => apiPatch<CollaborationRevision>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`, payload);
export const deleteCollaborationRevision = (studyId: string, fileId: string, revisionId: string) =>
apiDelete(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}`);
export const copyCollaborationRevision = (
studyId: string,
fileId: string,
revisionId: string,
payload: { title: string; folder_id?: string | null },
) => apiPost<CollaborationFile>(`${baseUrl(studyId)}/files/${fileId}/revisions/${revisionId}/copy`, payload);
export const fetchCollaborationEditorConfig = (studyId: string, fileId: string) =>
apiGet<CollaborationEditorConfig>(`${baseUrl(studyId)}/files/${fileId}/editor-config`, {
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
});
export const recordCollaborationDownload = (studyId: string, fileId: string, fileType: string) =>
apiPost(`${baseUrl(studyId)}/files/${fileId}/downloads`, { file_type: fileType });
const publicShareHeaders = (shareToken: string) => ({ "X-CTMS-Share-Token": shareToken });
const publicShareRequestConfig = (shareToken: string) => ({
headers: publicShareHeaders(shareToken),
publicRequest: true,
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
invalidateCache: false,
});
export const fetchPublicCollaborationShareMetadata = (shareToken: string) =>
apiGet<CollaborationPublicShareMetadata>("/api/v1/collaboration/shares/metadata", publicShareRequestConfig(shareToken));
export const verifyPublicCollaborationSharePassword = (shareToken: string, password: string) =>
apiPost<CollaborationShareAccessGrant>(
"/api/v1/collaboration/shares/access",
{ password },
publicShareRequestConfig(shareToken),
);
export const fetchPublicCollaborationEditorConfig = (
shareToken: string,
payload: { access_token?: string; client_id: string; display_name?: string },
) => apiPost<CollaborationEditorConfig>(
"/api/v1/collaboration/shares/editor-config",
payload,
publicShareRequestConfig(shareToken),
);
-28
View File
@@ -1,28 +0,0 @@
import { apiGet } from "./axios";
import type { ApiListResponse } from "../types/api";
import { VisitLostItem } from "../types/visits";
export interface CenterSummaryItem {
id: string;
name: string;
stage: string;
stage_status?: string;
actual_enrolled: number;
planned_enrolled: number;
}
export const fetchProgress = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/dashboard/progress`);
export const fetchFinanceSummary = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/finance/summary`);
export const fetchOverdueAesCount = (studyId: string) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params: { overdue: true, limit: 1 } });
export const fetchLostVisits = (studyId: string, params?: Record<string, any>) =>
apiGet<VisitLostItem[]>(`/api/v1/studies/${studyId}/dashboard/lost-visits`, { params });
export const fetchCenterSummary = (studyId: string) =>
apiGet<CenterSummaryItem[]>(`/api/v1/studies/${studyId}/dashboard/center-summary`);
+2 -8
View File
@@ -1,5 +1,5 @@
import { apiGet, apiPost, apiPut } from "./axios";
import type { NotificationItem } from "../types/notifications";
import type { GeneralNotificationItem } from "../types/notifications";
export interface DesktopNotificationSubscription {
enabled: boolean;
@@ -9,7 +9,7 @@ export interface DesktopNotificationSubscription {
export interface DesktopNotificationClaim {
claim_token?: string | null;
lease_expires_at?: string | null;
items: NotificationItem[];
items: GeneralNotificationItem[];
}
export const getDesktopNotificationSubscription = () =>
@@ -37,9 +37,3 @@ export const acknowledgeDesktopNotifications = (claimToken: string, deliveredIds
suppressErrorMessage: true,
disableNetworkRetry: true,
});
export const markDesktopNotificationRead = (distributionId: string) =>
apiPost<void>(`/api/v1/desktop-notifications/${distributionId}/read`, undefined, {
suppressErrorMessage: true,
disableNetworkRetry: true,
});
+1 -1
View File
@@ -1,7 +1,6 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse } from "../types/api";
import type {
Acknowledgement,
Distribution,
DocumentDetail,
DocumentSummary,
@@ -32,6 +31,7 @@ export const uploadDocumentVersion = (documentId: string, payload: FormData) =>
export const downloadDocumentVersion = (versionId: string) =>
apiGet<Blob>(`/api/v1/versions/${versionId}/download`, {
responseType: "blob",
suppressErrorMessage: true,
});
export const deleteDocumentVersion = (versionId: string) =>
+1 -5
View File
@@ -2,12 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPatch = vi.fn();
vi.mock("./axios", () => ({
apiGet,
apiPost,
apiPatch,
}));
describe("etmf api", () => {
@@ -16,12 +14,11 @@ describe("etmf api", () => {
});
it("uses canonical eTMF URLs", async () => {
const { fetchEtmfTree, fetchEtmfNodeDocuments, createEtmfNode, updateEtmfNode, createEtmfDocument } = await import("./etmf");
const { fetchEtmfTree, fetchEtmfNodeDocuments, createEtmfNode, createEtmfDocument } = await import("./etmf");
fetchEtmfTree("study-1");
fetchEtmfNodeDocuments("node-1", { site_id: "site-1" });
createEtmfNode({ study_id: "study-1", code: "01", name: "项目管理" });
updateEtmfNode("node-1", { name: "项目管理文件" });
createEtmfDocument("node-1", {
trial_id: "study-1",
doc_no: "DOC-001",
@@ -33,7 +30,6 @@ describe("etmf api", () => {
expect(apiGet).toHaveBeenCalledWith("/api/v1/etmf/tree", { params: { study_id: "study-1" } });
expect(apiGet).toHaveBeenCalledWith("/api/v1/etmf/nodes/node-1/documents", { params: { site_id: "site-1" } });
expect(apiPost).toHaveBeenCalledWith("/api/v1/etmf/nodes", { study_id: "study-1", code: "01", name: "项目管理" });
expect(apiPatch).toHaveBeenCalledWith("/api/v1/etmf/nodes/node-1", { name: "项目管理文件" });
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/etmf/nodes/node-1/documents",
expect.objectContaining({ doc_no: "DOC-001" })
+2 -5
View File
@@ -1,6 +1,6 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import { apiGet, apiPost } from "./axios";
import type { DocumentSummary } from "../types/documents";
import type { EtmfDocumentCreatePayload, EtmfNodeCreatePayload, EtmfNodeUpdatePayload, EtmfTreeNode } from "../types/etmf";
import type { EtmfDocumentCreatePayload, EtmfNodeCreatePayload, EtmfTreeNode } from "../types/etmf";
export const fetchEtmfTree = (studyId: string) =>
apiGet<EtmfTreeNode[]>("/api/v1/etmf/tree", { params: { study_id: studyId } });
@@ -11,8 +11,5 @@ export const fetchEtmfNodeDocuments = (nodeId: string, params: Record<string, an
export const createEtmfNode = (payload: EtmfNodeCreatePayload) =>
apiPost<EtmfTreeNode>("/api/v1/etmf/nodes", payload);
export const updateEtmfNode = (nodeId: string, payload: EtmfNodeUpdatePayload) =>
apiPatch<EtmfTreeNode>(`/api/v1/etmf/nodes/${nodeId}`, payload);
export const createEtmfDocument = (nodeId: string, payload: EtmfDocumentCreatePayload) =>
apiPost<DocumentSummary>(`/api/v1/etmf/nodes/${nodeId}/documents`, payload);
+9 -2
View File
@@ -2,10 +2,17 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { StudyMember, UserInfo } from "../types/api";
export const listMembers = (studyId: string, params?: Record<string, any>) =>
apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, { params });
apiGet<StudyMember[] | { items: StudyMember[] }>(`/api/v1/studies/${studyId}/members/`, {
params,
cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] },
});
export const listMemberCandidates = (studyId: string, params?: Record<string, any>) =>
apiGet<UserInfo[]>(`/api/v1/studies/${studyId}/members/candidates`, { params, suppressErrorMessage: true });
apiGet<UserInfo[]>(`/api/v1/studies/${studyId}/members/candidates`, {
params,
suppressErrorMessage: true,
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:members`] },
});
export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) =>
apiPost<StudyMember>(`/api/v1/studies/${studyId}/members/`, payload);
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
const apiPost = vi.fn();
vi.mock("./axios", () => ({ apiGet, apiPost }));
describe("general notification API", () => {
beforeEach(() => vi.clearAllMocks());
it("loads an uncached recipient feed and updates read state", async () => {
const {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
} = await import("./notifications");
listGeneralNotifications("study-1", { limit: 8, unread_only: true });
markGeneralNotificationRead("study-1", "notification-1");
markAllGeneralNotificationsRead("study-1");
expect(apiGet).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/feed",
{ params: { limit: 8, unread_only: true }, cache: false, disableRequestDedupe: true },
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/notification-1/read",
);
expect(apiPost).toHaveBeenCalledWith(
"/api/v1/studies/study-1/notifications/read-all",
);
});
});
+26 -4
View File
@@ -1,5 +1,27 @@
import { apiGet } from "./axios";
import type { NotificationItem } from "../types/notifications";
import { apiGet, apiPost } from "./axios";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
export const listNotifications = (studyId: string, params?: Record<string, any>) =>
apiGet<NotificationItem[]>(`/api/v1/studies/${studyId}/notifications`, { params });
export interface GeneralNotificationQuery {
skip?: number;
limit?: number;
category?: string;
unread_only?: boolean;
requires_action?: boolean;
}
export const listGeneralNotifications = (
studyId: string,
query: GeneralNotificationQuery = {},
) => apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit: 10, ...query },
cache: false,
disableRequestDedupe: true,
});
export const markGeneralNotificationRead = (studyId: string, notificationId: string) =>
apiPost<GeneralNotificationItem>(
`/api/v1/studies/${studyId}/notifications/${notificationId}/read`,
);
export const markAllGeneralNotificationsRead = (studyId: string) =>
apiPost<void>(`/api/v1/studies/${studyId}/notifications/read-all`);
+23
View File
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiGet = vi.fn();
vi.mock("./axios", () => ({ apiGet }));
describe("ONLYOFFICE config api", () => {
beforeEach(() => vi.clearAllMocks());
it.each([
["attachment", "fetchAttachmentOnlyOfficeConfig", "/api/v1/onlyoffice/attachments/resource-1/config"],
["version", "fetchVersionOnlyOfficeConfig", "/api/v1/onlyoffice/versions/resource-1/config"],
] as const)("loads %s config without caching or request dedupe", async (_type, exportName, path) => {
const api = await import("./onlyoffice");
api[exportName]("resource-1");
expect(apiGet).toHaveBeenCalledWith(path, {
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
});
});
});
+24
View File
@@ -0,0 +1,24 @@
import { apiGet } from "./axios";
import type { OnlyOfficePreviewConfig } from "../types/onlyoffice";
const noStoreConfig = {
cache: false,
disableRequestDedupe: true,
disableNetworkRetry: true,
suppressErrorMessage: true,
} as const;
export const fetchAttachmentOnlyOfficeConfig = (attachmentId: string) =>
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/attachments/${attachmentId}/config`, noStoreConfig);
export const fetchVersionOnlyOfficeConfig = (versionId: string) =>
apiGet<OnlyOfficePreviewConfig>(`/api/v1/onlyoffice/versions/${versionId}/config`, noStoreConfig);
export const fetchCollaborationRevisionOnlyOfficeConfig = (
studyId: string,
fileId: string,
revisionId: string,
) => apiGet<OnlyOfficePreviewConfig>(
`/api/v1/studies/${studyId}/collaboration/files/${fileId}/revisions/${revisionId}/preview-config`,
noStoreConfig,
);
+4 -1
View File
@@ -1,3 +1,6 @@
import { apiGet } from "./axios";
export const fetchProjectOverview = (studyId: string) => apiGet(`/api/v1/studies/${studyId}/overview`);
export const fetchProjectOverview = (studyId: string) =>
apiGet(`/api/v1/studies/${studyId}/overview`, {
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:overview`] },
});
+42 -41
View File
@@ -4,7 +4,6 @@ import type {
ApiEndpointPermissionsUpdate,
ApiOperation,
PermissionMetricsResponse,
CacheStatsResponse,
AlertsResponse,
HealthResponse,
AccessLogsResponse,
@@ -17,10 +16,16 @@ import type {
// 接口级权限
export const fetchApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, config);
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions`, {
...(config || {}),
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
});
export const fetchMyApiEndpointPermissions = (studyId: string, config?: ApiRequestConfig) =>
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions/me`, config);
apiGet<ApiEndpointPermissionsResponse>(`/api/v1/studies/${studyId}/api-permissions/me`, {
...(config || {}),
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
});
export const updateApiEndpointPermissions = (
studyId: string,
@@ -32,14 +37,15 @@ export const updateApiEndpointPermissions = (
});
export const fetchApiOperations = () =>
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`);
apiGet<{ operations: ApiOperation[] }>(`/api/v1/api-permissions/operations`, {
cache: { ttlMs: 24 * 60 * 60_000, tags: ["permission-operations"] },
});
// 系统监测API
export const fetchPermissionMetrics = () =>
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`);
export const fetchCacheStats = () =>
apiGet<CacheStatsResponse>(`/api/v1/permission-monitoring/cache-stats`);
apiGet<PermissionMetricsResponse>(`/api/v1/permission-monitoring/metrics`, {
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
});
export const fetchPermissionAlerts = (level?: string, limit?: number) =>
apiGet<AlertsResponse>(`/api/v1/permission-monitoring/alerts`, {
@@ -47,13 +53,9 @@ export const fetchPermissionAlerts = (level?: string, limit?: number) =>
});
export const fetchPermissionHealth = () =>
apiGet<HealthResponse>(`/api/v1/permission-monitoring/health`);
export const resetPermissionMetrics = () =>
apiPost<void>(`/api/v1/permission-monitoring/reset-metrics`);
export const clearPermissionAlerts = () =>
apiPost<void>(`/api/v1/permission-monitoring/clear-alerts`);
apiGet<HealthResponse>(`/api/v1/permission-monitoring/health`, {
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
});
// 系统监测 - 新增API
export const fetchAccessLogs = (params: {
@@ -64,6 +66,10 @@ export const fetchAccessLogs = (params: {
allowed?: boolean;
start_time?: string;
end_time?: string;
client_ip?: string;
client_type?: string;
event_type?: "all" | "permission";
keyword?: string;
page?: number;
page_size?: number;
}) => apiGet<AccessLogsResponse>(`/api/v1/permission-monitoring/access-logs`, { params });
@@ -73,6 +79,13 @@ export const fetchSecurityAccessLogs = (params?: {
auth_status?: string;
client_type?: string;
client_version?: string;
client_ip?: string;
category?: string;
severity?: string;
keyword?: string;
events_only?: boolean;
start_time?: string;
end_time?: string;
page?: number;
page_size?: number;
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
@@ -83,11 +96,13 @@ export const fetchPermissionTrends = (period: "24h" | "7d" | "30d" = "24h") =>
export const fetchTopDenied = (params?: { days?: number; limit?: number }) =>
apiGet<TopDeniedResponse>(`/api/v1/permission-monitoring/top-denied`, { params });
export const fetchIpLocations = (params?: { days?: number; limit?: number }) =>
export const fetchIpLocations = (params?: { days?: number; all_time?: boolean; limit?: number }) =>
apiGet<IpLocationsResponse>(`/api/v1/permission-monitoring/ip-locations`, { params });
export const fetchStatsSummary = () =>
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`);
apiGet<StatsSummaryResponse>(`/api/v1/permission-monitoring/stats-summary`, {
cache: { ttlMs: 30_000, tags: ["permission-monitoring"] },
});
// 权限模板API
export interface PermissionTemplate {
@@ -116,23 +131,11 @@ export interface PermissionTemplateCreate {
recommended_roles?: string;
}
export interface ApplyTemplateRequest {
template_id: string;
roles?: string[];
override?: boolean;
}
export interface ApplyTemplateResponse {
study_id: string;
applied_roles: string[];
permissions: Record<string, Record<string, { allowed: boolean }>>;
}
export const fetchPermissionTemplates = (params?: { template_type?: string; category?: string }) =>
apiGet<PermissionTemplate[]>(`/api/v1/permission-templates`, { params });
export const fetchPermissionTemplate = (templateId: string) =>
apiGet<PermissionTemplate>(`/api/v1/permission-templates/${templateId}`);
apiGet<PermissionTemplate[]>(`/api/v1/permission-templates`, {
params,
cache: { ttlMs: 5 * 60_000, tags: ["permission-templates"] },
});
export const createPermissionTemplate = (payload: PermissionTemplateCreate) =>
apiPost<PermissionTemplate>(`/api/v1/permission-templates`, payload);
@@ -143,12 +146,6 @@ export const updatePermissionTemplate = (templateId: string, payload: Partial<Pe
export const deletePermissionTemplate = (templateId: string) =>
apiDelete<void>(`/api/v1/permission-templates/${templateId}`);
export const applyPermissionTemplate = (studyId: string, payload: ApplyTemplateRequest) =>
apiPost<ApplyTemplateResponse>(
`/api/v1/studies/${studyId}/permission-templates/${payload.template_id}/apply`,
payload,
);
// 系统级权限
export interface SystemPermissionItem {
permission_key: string;
@@ -165,11 +162,15 @@ export interface SystemPermissionsResponse {
}
export const fetchSystemPermissions = () =>
apiGet<SystemPermissionsResponse>(`/api/v1/system-permissions`);
apiGet<SystemPermissionsResponse>(`/api/v1/system-permissions`, {
cache: { ttlMs: 24 * 60 * 60_000, tags: ["system-permissions"] },
});
// 项目角色生效管理
export const fetchActiveRoles = (studyId: string) =>
apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`);
apiGet<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, {
cache: { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:permissions`] },
});
export const updateActiveRoles = (studyId: string, activeRoles: string[]) =>
apiPut<{ active_roles: string[] }>(`/api/v1/studies/${studyId}/active-roles`, { active_roles: activeRoles });
+28
View File
@@ -0,0 +1,28 @@
export type AccessTokenExtensionResult = {
token: string | null;
authFailed: boolean;
};
export interface ApiRuntimeHooks {
clearInvalidStudyAndNavigate: () => void | Promise<unknown>;
extendAccessToken: (reason: "response-401") => Promise<AccessTokenExtensionResult>;
forceAuthExpiredLogout: () => void | Promise<unknown>;
}
let runtimeHooks: ApiRuntimeHooks | null = null;
export const configureApiRuntimeHooks = (hooks: ApiRuntimeHooks): void => {
runtimeHooks = hooks;
};
export const clearInvalidStudyAndNavigate = async (): Promise<void> => {
await runtimeHooks?.clearInvalidStudyAndNavigate();
};
export const extendAccessTokenAfterUnauthorized = (): Promise<AccessTokenExtensionResult> =>
runtimeHooks?.extendAccessToken("response-401")
?? Promise.resolve({ token: null, authFailed: false });
export const forceAuthExpiredLogout = async (): Promise<void> => {
await runtimeHooks?.forceAuthExpiredLogout();
};
+1
View File
@@ -5,6 +5,7 @@ export const fetchSites = (studyId: string, params?: Record<string, any>, config
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, {
...(config || {}),
params: { include_inactive: true, ...(params || {}), ...(config?.params || {}) },
cache: config?.cache ?? { ttlMs: 5 * 60_000, tags: [`study:${studyId}`, `study:${studyId}:sites`] },
});
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
+12 -4
View File
@@ -12,7 +12,8 @@ import type {
} from "../types/setupConfig";
// Backend expects trailing slash to avoid 307 redirect
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
export const fetchStudies = () =>
apiGet<ApiListResponse<Study>>("/api/v1/studies/", { cache: { ttlMs: 5 * 60_000, tags: ["studies"] } });
export const createStudy = (payload: Partial<Study> & { name: string }) =>
apiPost<Study>("/api/v1/studies/", payload);
@@ -20,7 +21,8 @@ export const createStudy = (payload: Partial<Study> & { name: string }) =>
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
export const fetchStudyDetail = (studyId: string) =>
apiGet<Study>(`/api/v1/studies/${studyId}`, { cache: { ttlMs: 5 * 60_000, tags: ["studies", `study:${studyId}`] } });
export const deleteStudy = (studyId: string) =>
apiDelete(`/api/v1/studies/${studyId}`);
@@ -29,7 +31,10 @@ export const lockStudy = (studyId: string) =>
apiPatch<Study>(`/api/v1/studies/${studyId}/lock`, {});
export const fetchStudySetupConfig = (studyId: string) =>
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, { suppressErrorMessage: true });
apiGet<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, {
suppressErrorMessage: true,
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] },
});
export const saveStudySetupConfig = (studyId: string, payload: StudySetupConfigUpsertPayload) =>
apiPut<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config`, payload, { suppressErrorMessage: true });
@@ -38,7 +43,10 @@ export const publishStudySetupConfig = (studyId: string, payload: StudySetupConf
apiPost<StudySetupConfigResponse>(`/api/v1/studies/${studyId}/setup-config/publish`, payload, { suppressErrorMessage: true });
export const fetchStudySetupConfigVersions = (studyId: string) =>
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, { suppressErrorMessage: true });
apiGet<StudySetupConfigVersionItem[]>(`/api/v1/studies/${studyId}/setup-config/versions`, {
suppressErrorMessage: true,
cache: { ttlMs: 60_000, tags: [`study:${studyId}`, `study:${studyId}:setup-config`] },
});
export const deleteStudySetupConfigVersion = (studyId: string, targetVersion: number) =>
apiDelete(`/api/v1/studies/${studyId}/setup-config/versions/${targetVersion}`, { suppressErrorMessage: true });
-3
View File
@@ -3,9 +3,6 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listSubjectHistories = (studyId: string, subjectId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, { params });
export const getSubjectHistory = (studyId: string, subjectId: string, historyId: string) =>
apiGet(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories/${historyId}`);
export const createSubjectHistory = (studyId: string, subjectId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/subjects/${subjectId}/histories`, payload);
+6 -1
View File
@@ -1,5 +1,5 @@
import { apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse, UserInfo } from "../types/api";
import type { ApiListResponse, UserInfo, UserLoginActivity } from "../types/api";
import { apiDelete } from "./axios";
export const fetchUsers = (params?: Record<string, any>) =>
@@ -21,3 +21,8 @@ export const updateUser = (
export const deleteUser = (userId: string) =>
apiDelete<void>(`/api/v1/users/${userId}`, { suppressErrorMessage: true });
export const fetchUserLoginActivities = (userId: string, limit = 100) =>
apiGet<UserLoginActivity[]>(`/api/v1/users/${userId}/login-activities`, {
params: { limit },
});
+43 -1
View File
@@ -1,7 +1,49 @@
import { describe, expect, it } from "vitest";
import { normalizeAuditEvent } from ".";
import { normalizeAuditEvent, resolveAuditClientSourceLabel } from ".";
describe("normalizeAuditEvent", () => {
it("normalizes request source and IP context for access audit troubleshooting", () => {
const event = normalizeAuditEvent(
{
action: "UPDATE_SUBJECT",
detail: JSON.stringify({ targetName: "S001" }),
operator_id: "operator-1",
operator_name: "张三",
operator_email: "pm@example.com",
operator_role: "PM",
entity_type: "subject",
entity_id: "307e9a37-530c-4894-8d19-f22222222222",
client_ip: "203.0.113.10",
ip_country: "中国",
ip_province: "重庆",
ip_city: "渝中",
ip_isp: "电信",
client_type: "desktop",
client_version: "1.2.3",
client_platform: "macos",
build_channel: "release",
build_commit: "abcdef1",
user_agent: "CTMS Desktop",
created_at: "2026-07-09T10:00:00Z",
},
{}
);
expect(event.actorName).toBe("张三");
expect(event.actorAccount).toBe("pm@example.com");
expect(event.clientIp).toBe("203.0.113.10");
expect(event.ipLocation).toBe("重庆 / 渝中 / 电信");
expect(event.clientSourceLabel).toBe("桌面端 / macos");
expect(event.clientType).toBe("desktop");
expect(event.clientVersion).toBe("1.2.3");
expect(event.buildCommit).toBe("abcdef1");
});
it("labels unmarked automation clients as script or command-line sources", () => {
expect(resolveAuditClientSourceLabel({ user_agent: "curl/8.1.2" })).toBe("脚本/命令行");
expect(resolveAuditClientSourceLabel({ user_agent: "" })).toBe("未知来源");
});
it("removes UUID tokens from legacy audit detail text", () => {
const event = normalizeAuditEvent(
{
@@ -19,5 +19,9 @@ export const auditExportColumns: AuditExportColumn[] = [
{ key: "resultLabel", label: TEXT.audit.exportColumns.resultLabel },
{ key: "reason", label: TEXT.audit.exportColumns.reason },
{ key: "ip", label: TEXT.audit.exportColumns.ip },
{ key: "ipLocation", label: TEXT.audit.exportColumns.ipLocation },
{ key: "clientSource", label: TEXT.audit.exportColumns.clientSource },
{ key: "clientType", label: TEXT.audit.exportColumns.clientType },
{ key: "userAgent", label: TEXT.audit.exportColumns.userAgent },
{ key: "remark", label: TEXT.audit.exportColumns.remark },
];
@@ -43,7 +43,11 @@ export const formatAuditRow = (event: AuditEvent): AuditExportRow => {
afterStatus,
resultLabel: event.resultLabel || "",
reason: event.reason || "",
ip: "",
ip: event.clientIp || "",
ipLocation: event.ipLocation || "",
clientSource: event.clientSourceLabel || "",
clientType: [event.clientType, event.clientVersion, event.clientPlatform].filter(Boolean).join("/") || "",
userAgent: event.userAgent || "",
remark: event.diffText?.join("") || "",
};
};
@@ -1,7 +1,7 @@
import { auditExportColumns } from "./auditExportColumns";
import { formatAuditRows } from "./auditExportFormatter";
import type { AuditEvent } from "..";
import { saveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const BOM = "\ufeff";
@@ -24,9 +24,17 @@ export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportO
const rows = formatAuditRows(events);
const csv = buildCsv(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
await saveFile({
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8",
data: blob,
});
await saveFileWithFeedback(
{
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8",
data: blob,
},
{
kind: "export",
title: "导出审计记录",
pendingDetail: "等待选择保存位置",
completedDetail: "导出文件已保存",
},
);
};
+80 -8
View File
@@ -7,6 +7,7 @@ export interface AuditEvent {
eventLabel: string;
actorId: string;
actorName: string;
actorAccount?: string;
actorRole: string;
actorRoleLabel: string;
targetType: string;
@@ -21,6 +22,19 @@ export interface AuditEvent {
result: "SUCCESS" | "FAIL";
resultLabel: string;
timestamp: string;
clientIp?: string;
ipLocation?: string;
ipCountry?: string;
ipProvince?: string;
ipCity?: string;
ipIsp?: string;
clientType?: string;
clientVersion?: string;
clientPlatform?: string;
buildChannel?: string;
buildCommit?: string;
userAgent?: string;
clientSourceLabel?: string;
}
const entityTypeLabelMap: Record<string, string> = {
@@ -619,6 +633,57 @@ const buildDiffText = (
return lines;
};
const fallbackIpLocation = (ip: string | null | undefined): string => {
if (!ip) return "";
if (ip === "127.0.0.1" || ip === "::1") return "本机访问";
if (/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/.test(ip)) return "内网地址";
return "";
};
export const formatAuditIpLocation = (raw: {
ip_country?: string | null;
ip_province?: string | null;
ip_city?: string | null;
ip_isp?: string | null;
ip_location?: string | null;
client_ip?: string | null;
clientIp?: string | null;
}): string => {
const parts = [
raw.ip_country && raw.ip_country !== "中国" ? raw.ip_country : "",
raw.ip_province,
raw.ip_city,
raw.ip_isp,
].filter(Boolean);
if (parts.length) return parts.join(" / ");
return raw.ip_location || fallbackIpLocation(raw.client_ip || raw.clientIp) || "";
};
const isAutomationUserAgent = (userAgent = ""): boolean =>
/curl|wget|python-requests|httpie|go-http-client|node-fetch|axios|postman|insomnia|okhttp|java|powershell|libwww/i.test(userAgent);
const isBrowserUserAgent = (userAgent = ""): boolean =>
/mozilla|chrome|safari|firefox|edg\//i.test(userAgent);
export const resolveAuditClientSourceLabel = (raw: {
client_type?: string | null;
client_platform?: string | null;
user_agent?: string | null;
clientType?: string | null;
clientPlatform?: string | null;
userAgent?: string | null;
}): string => {
const clientType = String(raw.client_type || raw.clientType || "").trim().toLowerCase();
const platform = String(raw.client_platform || raw.clientPlatform || "").trim();
const userAgent = String(raw.user_agent || raw.userAgent || "").trim();
if (clientType === "web") return "网页端";
if (clientType === "desktop") return platform ? `桌面端 / ${platform}` : "桌面端";
if (clientType) return `未知客户端 / ${clientType}`;
if (isAutomationUserAgent(userAgent)) return "脚本/命令行";
if (isBrowserUserAgent(userAgent)) return "浏览器(未标识)";
return "未知来源";
};
export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>): AuditEvent => {
const dict = auditDict[raw.action] || buildReadableAuditDict(raw.action, raw.entity_type);
let detailObj: any = {};
@@ -668,7 +733,8 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
eventType: raw.action,
eventLabel: dict.label,
actorId: raw.operator_id,
actorName: userMap[raw.operator_id] || raw.operator_id,
actorName: raw.operator_name || userMap[raw.operator_id] || raw.operator_id,
actorAccount: raw.operator_email || raw.operator_id,
actorRole: raw.operator_role,
actorRoleLabel: getDictLabel(roleDict, raw.operator_role),
targetType: raw.entity_type || "",
@@ -683,12 +749,18 @@ export const normalizeAuditEvent = (raw: any, userMap: Record<string, string>):
result: detailObj.result || "SUCCESS",
resultLabel: detailObj.result === "FAIL" ? TEXT.audit.resultFail : TEXT.audit.resultSuccess,
timestamp: raw.created_at,
clientIp: raw.client_ip || "",
ipLocation: formatAuditIpLocation(raw),
ipCountry: raw.ip_country || "",
ipProvince: raw.ip_province || "",
ipCity: raw.ip_city || "",
ipIsp: raw.ip_isp || "",
clientType: raw.client_type || "",
clientVersion: raw.client_version || "",
clientPlatform: raw.client_platform || "",
buildChannel: raw.build_channel || "",
buildCommit: raw.build_commit || "",
userAgent: raw.user_agent || "",
clientSourceLabel: resolveAuditClientSourceLabel(raw),
};
};
// Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage.
export const logAudit = async (eventType: string, payload: Record<string, any>) => {
void eventType;
void payload;
return Promise.resolve();
};
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = (path: string) => readFileSync(resolve(__dirname, path), "utf8");
describe("account connection status", () => {
it("keeps the compact indicator and detailed account diagnostics in both shells", () => {
const component = readSource("./AccountConnectionStatus.vue");
const webLayout = readSource("./WebLayout.vue");
const desktopLayout = readSource("./DesktopLayout.vue");
expect(component).toContain('mode === \'indicator\'');
expect(component).toContain("当前账号与连接状态");
expect(component).toContain("API 延迟");
expect(component).toContain("会话状态");
expect(component).toContain("useStudyStore");
expect(component).toContain("getProjectRole(study.currentStudy, study.currentStudyRole)");
expect(component).toContain('isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"');
expect(component).toContain("projectRole.value ? displayRoleLabel(projectRole.value) : \"未选择项目\"");
expect(component).toContain("`${study.currentStudy.name} · ${roleLabel.value}`");
expect(component).not.toContain('auth.user?.is_admin ? "系统管理员" : "普通账号"');
expect(component).toContain("mode === 'network'");
expect(component).toContain("当前 IP");
expect(component).toContain("clientIpLabel");
expect(component).toContain("连接安全检查");
expect(component).toContain("runSecurityCheck");
expect(component).toContain("evaluateConnectionSecurity");
expect(component).toContain("const securityChecked = ref(true)");
expect(component).toContain("const securityCheckExpanded = ref(true)");
expect(component).toContain("const nowTs = ref(Date.now())");
expect(component).toContain("const tokenExpiryAt = computed");
expect(component).toContain("const credentialDeadlineAt = computed");
expect(component).toContain("return session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000");
expect(component).toContain("sessionDeadlineAt: credentialDeadlineAt.value");
expect(component).toContain("Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes)");
expect(component).toContain("sessionClockTimer !== undefined");
expect(component).toContain("window.clearInterval(sessionClockTimer)");
expect(component).not.toContain("连接状态仅表示会话与后端通信");
expect(component).not.toContain("connection-details-note");
expect(component).not.toContain("ip_address");
expect(component).not.toContain("access_token");
expect(webLayout).toContain('<AccountConnectionStatus mode="indicator" />');
expect(webLayout).toContain('<AccountConnectionStatus mode="details" />');
expect(desktopLayout).toContain('<AccountConnectionStatus mode="indicator" />');
expect(desktopLayout).toContain('<AccountConnectionStatus mode="details" />');
});
});
@@ -0,0 +1,533 @@
<template>
<span
v-if="mode === 'indicator'"
class="account-connection-indicator"
:class="`is-${status}`"
:title="`账号会话:${statusLabel}`"
>
<i class="account-connection-dot"></i>
<span>{{ statusLabel }}</span>
</span>
<section v-else-if="mode === 'network'" class="network-overview" aria-label="当前网络状态">
<div class="network-overview-head">
<span>网络状态</span>
<span class="connection-state-badge" :class="`is-${status}`">
<i class="account-connection-dot"></i>
{{ statusLabel }}
</span>
</div>
<dl class="network-overview-grid">
<div class="network-ip-row">
<dt>当前 IP</dt>
<dd :title="clientIpLabel">{{ clientIpLabel }}</dd>
</div>
<div>
<dt>API 延迟</dt>
<dd>{{ latencyLabel }}</dd>
</div>
<div>
<dt>最近通信</dt>
<dd>{{ lastCommunicationLabel }}</dd>
</div>
</dl>
<button class="security-check-trigger" type="button" :disabled="securityChecking" @click="runSecurityCheck">
<span>连接安全检查</span>
<strong :class="`is-${securityResult.level}`">
{{ securityChecking ? "检查中" : securityChecked ? securityResult.label : "立即检查" }}
</strong>
</button>
<ul v-if="securityCheckExpanded" class="security-check-list" aria-live="polite">
<li v-for="item in securityResult.checks" :key="item.key">
<i :class="`is-${item.level}`"></i>
<span>{{ item.label }}</span>
<small>{{ item.detail }}</small>
</li>
</ul>
</section>
<div v-else class="account-connection-details" aria-label="当前账号与连接状态" @click.stop>
<div class="connection-details-head">
<span class="connection-details-title">当前账号</span>
<span class="connection-state-badge" :class="`is-${status}`">
<i class="account-connection-dot"></i>
{{ statusLabel }}
</span>
</div>
<strong class="connection-account-name">{{ userDisplayName }}</strong>
<span class="connection-account-email">{{ auth.user?.email || "未提供邮箱" }}</span>
<dl class="connection-details-grid">
<div>
<dt>{{ roleFieldLabel }}</dt>
<dd :title="roleContextTitle">{{ roleLabel }}</dd>
</div>
<div>
<dt>客户端</dt>
<dd>{{ clientLabel }}</dd>
</div>
<div>
<dt>运行环境</dt>
<dd>{{ environmentLabel }}</dd>
</div>
<div>
<dt>会话状态</dt>
<dd>{{ sessionRemainingLabel }}</dd>
</div>
<div>
<dt>API 延迟</dt>
<dd>{{ latencyLabel }}</dd>
</div>
<div>
<dt>最近通信</dt>
<dd>{{ lastCommunicationLabel }}</dd>
</div>
</dl>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { useStudyStore } from "../store/study";
import { clientRuntime, getAppMetadata, isTauriRuntime } from "../runtime";
import { IDLE_TIMEOUT_MINUTES } from "../session/sessionManager";
import { parseJwtExp } from "../session/jwt";
import { useConnectionStatus } from "../composables/useConnectionStatus";
import { useRoleTemplateMeta } from "../composables/useRoleTemplateMeta";
import { evaluateConnectionSecurity } from "../utils/connectionSecurity";
import { getProjectRole, isSystemAdmin } from "../utils/roles";
withDefaults(defineProps<{ mode?: "indicator" | "details" | "network" }>(), {
mode: "indicator",
});
const auth = useAuthStore();
const session = useSessionStore();
const study = useStudyStore();
const isDesktop = isTauriRuntime();
const appMetadata = getAppMetadata();
const { roleLabel: displayRoleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const {
status,
statusLabel,
latencyMs,
clientIp,
lastSuccessAt,
lastCheckedAt,
checkConnection,
startConnectionMonitor,
} = useConnectionStatus();
let stopConnectionMonitor: (() => void) | undefined;
const nowTs = ref(Date.now());
let sessionClockTimer: number | undefined;
const securityChecking = ref(false);
const securityChecked = ref(true);
const securityCheckExpanded = ref(true);
const userDisplayName = computed(
() => auth.user?.full_name || auth.user?.username || auth.user?.email || "当前用户",
);
const projectRole = computed(() => getProjectRole(study.currentStudy, study.currentStudyRole));
const roleFieldLabel = computed(() => (isSystemAdmin(auth.user) ? "系统角色" : "当前项目角色"));
const roleLabel = computed(() => {
if (isSystemAdmin(auth.user)) return "系统管理员";
return projectRole.value ? displayRoleLabel(projectRole.value) : "未选择项目";
});
const roleContextTitle = computed(() => {
if (isSystemAdmin(auth.user)) return roleLabel.value;
if (!study.currentStudy) return "选择项目后显示该项目内的具体角色";
return `${study.currentStudy.name} · ${roleLabel.value}`;
});
const clientLabel = computed(() => {
if (!isDesktop) return "网页端";
const platform = appMetadata.platform === "macos" ? "macOS" : appMetadata.platform || "桌面系统";
return `桌面端 · ${platform}`;
});
const environmentLabel = computed(() =>
import.meta.env.VITE_RUNTIME_ENV === "production" ? "生产环境" : "开发环境",
);
const effectiveNow = computed(() => Math.max(lastCheckedAt.value || 0, nowTs.value));
const tokenExpiryAt = computed(() => (auth.token ? parseJwtExp(auth.token) : null));
const sessionDeadlineAt = computed(() => {
if (!auth.token) return null;
if (isDesktop) return tokenExpiryAt.value;
return session.lastUserActiveAt + IDLE_TIMEOUT_MINUTES * 60 * 1000;
});
const credentialDeadlineAt = computed(() => {
if (!tokenExpiryAt.value) return sessionDeadlineAt.value;
if (!sessionDeadlineAt.value) return tokenExpiryAt.value;
return Math.min(tokenExpiryAt.value, sessionDeadlineAt.value);
});
const sessionRemainingLabel = computed(() => {
if (!auth.token) return "未登录";
if (!sessionDeadlineAt.value) return "有效";
const remainingMs = sessionDeadlineAt.value - effectiveNow.value;
if (remainingMs <= 0) return "即将失效";
const calculatedMinutes = Math.max(1, Math.ceil(remainingMs / 60_000));
const totalMinutes = isDesktop
? calculatedMinutes
: Math.min(IDLE_TIMEOUT_MINUTES, calculatedMinutes);
if (totalMinutes < 60) return `剩余 ${totalMinutes} 分钟`;
const totalHours = Math.floor(totalMinutes / 60);
if (totalHours < 24) return `剩余 ${totalHours} 小时`;
return `剩余 ${Math.floor(totalHours / 24)}`;
});
const latencyLabel = computed(() => (latencyMs.value === null ? "--" : `${latencyMs.value} ms`));
const clientIpLabel = computed(() => clientIp.value || (status.value === "checking" ? "检测中" : "--"));
const lastCommunicationLabel = computed(() => {
if (!lastSuccessAt.value) return status.value === "checking" ? "检测中" : "暂无成功记录";
return new Date(lastSuccessAt.value).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
const securityResult = computed(() => evaluateConnectionSecurity({
apiBaseUrl: clientRuntime.apiBaseUrl(),
pageOrigin: window.location.origin,
status: status.value,
hasToken: Boolean(auth.token),
sessionDeadlineAt: credentialDeadlineAt.value,
now: effectiveNow.value,
clientIp: clientIp.value,
latencyMs: latencyMs.value,
sessionRemainingLabel: sessionRemainingLabel.value,
}));
const runSecurityCheck = async () => {
securityCheckExpanded.value = true;
securityChecking.value = true;
try {
await checkConnection();
nowTs.value = Date.now();
securityChecked.value = true;
} finally {
securityChecking.value = false;
}
};
onMounted(() => {
if (!isSystemAdmin(auth.user)) {
void loadRoleTemplates().catch(() => {});
}
stopConnectionMonitor = startConnectionMonitor();
nowTs.value = Date.now();
sessionClockTimer = window.setInterval(() => {
nowTs.value = Date.now();
}, 1_000);
});
onBeforeUnmount(() => {
stopConnectionMonitor?.();
if (sessionClockTimer !== undefined) window.clearInterval(sessionClockTimer);
});
</script>
<style scoped>
.account-connection-indicator,
.connection-state-badge {
display: inline-flex;
align-items: center;
gap: 5px;
color: #64748b;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.network-overview {
box-sizing: border-box;
width: 100%;
margin-top: 14px;
padding: 14px 16px;
border: 1px solid rgba(203, 213, 225, 0.82);
border-radius: 12px;
background: rgba(255, 255, 255, 0.66);
color: #334155;
}
.network-overview-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #334155;
font-size: 12.5px;
font-weight: 750;
}
.network-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 14px;
margin: 12px 0 0;
padding-top: 11px;
border-top: 1px solid rgba(226, 232, 240, 0.9);
}
.network-overview-grid div,
.network-overview-grid dt,
.network-overview-grid dd {
min-width: 0;
margin: 0;
}
.security-check-trigger {
appearance: none;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-top: 12px;
padding: 10px 0 0;
border: 0;
border-top: 1px solid rgba(226, 232, 240, 0.9);
background: transparent;
color: #475569;
font: inherit;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.security-check-trigger:disabled {
cursor: wait;
opacity: 0.72;
}
.security-check-trigger strong {
font-size: 10.5px;
font-weight: 750;
}
.security-check-trigger .is-safe {
color: #15803d;
}
.security-check-trigger .is-warning {
color: #b45309;
}
.security-check-trigger .is-danger {
color: #dc2626;
}
.security-check-list {
display: grid;
gap: 7px;
margin: 10px 0 0;
padding: 10px 0 0;
border-top: 1px dashed rgba(203, 213, 225, 0.9);
list-style: none;
}
.security-check-list li {
display: grid;
grid-template-columns: 7px minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
min-width: 0;
}
.security-check-list i {
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
}
.security-check-list i.is-safe {
background: #22a663;
}
.security-check-list i.is-warning {
background: #d99b21;
}
.security-check-list i.is-danger {
background: #e05252;
}
.security-check-list span,
.security-check-list small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.security-check-list span {
color: #475569;
font-size: 10.5px;
font-weight: 650;
}
.security-check-list small {
color: #94a3b8;
font-size: 9.5px;
}
.network-overview-grid .network-ip-row {
grid-column: 1 / -1;
}
.network-overview-grid dt {
margin-bottom: 3px;
color: #94a3b8;
font-size: 10px;
}
.network-overview-grid dd {
overflow: hidden;
color: #334155;
font-size: 11.5px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-connection-dot {
display: inline-block;
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: #94a3b8;
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.13);
}
.is-online .account-connection-dot {
background: #22a663;
box-shadow: 0 0 0 3px rgba(34, 166, 99, 0.13);
}
.is-degraded .account-connection-dot {
background: #d99b21;
box-shadow: 0 0 0 3px rgba(217, 155, 33, 0.14);
}
.is-offline .account-connection-dot {
background: #e05252;
box-shadow: 0 0 0 3px rgba(224, 82, 82, 0.14);
}
.account-connection-details {
box-sizing: border-box;
width: 290px;
padding: 12px 14px 11px;
color: #273449;
}
.connection-details-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.connection-details-title {
color: #7a8aa0;
font-size: 11px;
font-weight: 700;
}
.connection-state-badge {
padding: 3px 7px;
border-radius: 999px;
background: #f3f6f9;
}
.connection-account-name,
.connection-account-email {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.connection-account-name {
color: #172033;
font-size: 14px;
}
.connection-account-email {
margin-top: 2px;
color: #7a8aa0;
font-size: 11px;
}
.connection-details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
margin: 12px 0 0;
padding: 10px 0;
border-top: 1px solid #edf1f5;
border-bottom: 1px solid #edf1f5;
}
.connection-details-grid div,
.connection-details-grid dt,
.connection-details-grid dd {
min-width: 0;
margin: 0;
}
.connection-details-grid dt {
margin-bottom: 2px;
color: #91a0b3;
font-size: 10px;
}
.connection-details-grid dd {
overflow: hidden;
color: #334155;
font-size: 11px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
:global([data-ctms-theme="dark"]) .account-connection-details {
color: #dbe5f1;
}
:global([data-ctms-theme="dark"]) .network-overview {
border-color: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
}
:global([data-ctms-theme="dark"]) .network-overview-head,
:global([data-ctms-theme="dark"]) .network-overview-grid dd {
color: #e2e8f0;
}
:global([data-ctms-theme="dark"]) .network-overview-grid {
border-top-color: rgba(255, 255, 255, 0.06);
}
:global([data-ctms-theme="dark"]) .security-check-trigger,
:global([data-ctms-theme="dark"]) .security-check-list {
border-color: rgba(255, 255, 255, 0.06);
}
:global([data-ctms-theme="dark"]) .security-check-trigger,
:global([data-ctms-theme="dark"]) .security-check-list span {
color: #cbd5e1;
}
:global([data-ctms-theme="dark"]) .connection-account-name,
:global([data-ctms-theme="dark"]) .connection-details-grid dd {
color: #f8fafc;
}
:global([data-ctms-theme="dark"]) .connection-details-grid {
border-color: #334155;
}
:global([data-ctms-theme="dark"]) .connection-state-badge {
background: #243247;
}
</style>
@@ -74,11 +74,15 @@ describe("ApiEndpointPermissions.vue", () => {
it("keeps matrix read-only and renders authorization states", async () => {
const source = readFileSync(resolve(__dirname, "./ApiEndpointPermissions.vue"), "utf8");
expect(source).not.toContain("只读查看各角色权限覆盖");
expect(source).toContain("有效权限矩阵");
expect(source).toContain(">只读</el-tag>");
expect(source).toContain("permission-state--allowed");
expect(source).toContain("permission-state--disabled");
expect(source).toContain("CircleCloseFilled");
expect(source).not.toContain('? "✓" : "—"');
expect(source).not.toContain("CircleCloseFilled");
expect(source).toContain("<template v-else>—</template>");
expect(source).toContain('filterAuthorization.value === "different"');
expect(source).toContain('max-height="calc(100dvh - 330px)"');
expect(source).toContain('label="权限类型" width="120" fixed="left"');
expect(source).not.toContain("defineEmits");
expect(source).not.toContain("el-checkbox");
});
@@ -256,6 +260,9 @@ describe("ApiEndpointPermissions.vue", () => {
]);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2);
expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2);
expect((wrapper.vm as any).displayOperations).toHaveLength(5);
expect((wrapper.vm as any).totalPermissionCount).toBe(3);
expect((wrapper.vm as any).filteredPermissionCount).toBe(3);
});
it("keeps contract fee permissions as one module section", () => {
@@ -2,7 +2,11 @@
<div class="api-permissions">
<div class="api-permissions-toolbar">
<div class="toolbar-left">
<span class="toolbar-title">项目级权限矩阵</span>
<div class="toolbar-heading">
<span class="toolbar-title">有效权限矩阵</span>
<el-tag size="small" effect="plain" type="info">只读</el-tag>
</div>
<span class="toolbar-result">显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -29,6 +33,13 @@
<el-option label="删除" value="delete" />
<el-option label="导出" value="export" />
</el-select>
<el-select v-model="filterAuthorization" placeholder="授权状态" style="width: 140px">
<el-option label="全部授权状态" value="" />
<el-option label="存在角色差异" value="different" />
<el-option label="至少一项授权" value="granted" />
<el-option label="全部未授权" value="ungranted" />
</el-select>
<el-button :disabled="!hasActiveFilters" @click="resetFilters">重置</el-button>
</div>
</div>
@@ -37,10 +48,12 @@
:data="filteredOperations"
border
stripe
max-height="calc(100dvh - 330px)"
empty-text="未找到匹配的权限"
:span-method="spanMethod"
class="perm-matrix-table"
>
<el-table-column prop="permission_category" label="权限类型" width="120">
<el-table-column prop="permission_category" label="权限类型" width="120" fixed="left">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--category" :data-category="getPermissionCategory(row)">
<span class="hierarchy-bar" />
@@ -49,7 +62,7 @@
</template>
</el-table-column>
<el-table-column prop="module" label="模块" width="160">
<el-table-column prop="module" label="模块" width="160" fixed="left">
<template #default="{ row }">
<div class="hierarchy-cell hierarchy-cell--module">
<span class="module-dot" />
@@ -94,7 +107,7 @@
:title="isOperationAllowed(row.operation_key, role) ? '已授权' : '未授权'"
>
<template v-if="isOperationAllowed(row.operation_key, role)"></template>
<CircleCloseFilled v-else />
<template v-else></template>
</span>
</template>
</el-table-column>
@@ -105,7 +118,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { CircleCloseFilled, Search } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import type { ApiEndpointPermissionsResponse } from "@/types/api";
import { fetchApiOperations } from "@/api/projectPermissions";
@@ -144,8 +157,20 @@ const { roleLabel, compareRolesByTemplateOrder, loadRoleTemplates } = useRoleTem
const searchText = ref("");
const filterModule = ref("");
const filterAction = ref("");
const filterAuthorization = ref("");
const operations = ref<Operation[]>([]);
const hasActiveFilters = computed(() => Boolean(
searchText.value || filterModule.value || filterAction.value || filterAuthorization.value
));
const resetFilters = () => {
searchText.value = "";
filterModule.value = "";
filterAction.value = "";
filterAuthorization.value = "";
};
const moduleLabel = projectPermissionModuleLabel;
const getPermissionCategory = (row: Operation): "common" | "business" => projectPermissionCategory(row.module);
const getPermissionCategoryLabel = (row: Operation): string => projectPermissionCategoryLabel(row.module);
@@ -216,8 +241,14 @@ const filteredOperations = computed(() => {
const matchModule = !filterModule.value || operation.module === filterModule.value;
const matchAction = !filterAction.value || getOperationVerb(operation) === filterAction.value;
const roleStates = roles.value.map((role) => isOperationAllowed(operation.operation_key, role));
const matchAuthorization =
!filterAuthorization.value ||
(filterAuthorization.value === "different" && roleStates.some(Boolean) && roleStates.some((allowed) => !allowed)) ||
(filterAuthorization.value === "granted" && roleStates.some(Boolean)) ||
(filterAuthorization.value === "ungranted" && roleStates.every((allowed) => !allowed));
return matchSearch && matchModule && matchAction;
return matchSearch && matchModule && matchAction && matchAuthorization;
});
// 按权限类型、模块分组排序
@@ -235,6 +266,12 @@ const filteredOperations = computed(() => {
return filtered;
});
const countUniquePermissions = (rows: DisplayOperation[]): number =>
new Set(rows.map((operation) => operation.operation_key)).size;
const totalPermissionCount = computed(() => countUniquePermissions(displayOperations.value));
const filteredPermissionCount = computed(() => countUniquePermissions(filteredOperations.value));
// 权限类型、模块与模块细分列合并行
const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => {
if (columnIndex === 0) {
@@ -353,12 +390,13 @@ const SECTION_LABELS: Record<string, string> = {
faq_category: "医学咨询/FAQ",
faq_reply: "医学咨询/FAQ",
faq_attachments: "医学咨询/FAQ",
collaboration: "在线协作",
};
const SECTION_ORDER_BY_MODULE: Record<string, string[]> = {
subjects: ["参与者基础信息", "访视", "AE", "PD", "病史"],
risk_issues: ["AE/SAE", "PD", "监查访视问题"],
shared_library: ["医学咨询/FAQ", "注意事项"],
shared_library: ["医学咨询/FAQ", "注意事项", "在线协作"],
};
const getOperationSection = (row: Operation): string => {
@@ -394,19 +432,19 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
<style scoped>
.api-permissions {
padding: 20px 0;
padding: 8px 0;
}
.api-permissions-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 14px 18px;
gap: 10px;
margin-bottom: 8px;
padding: 8px 10px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -414,23 +452,35 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
}
.toolbar-heading {
display: flex;
align-items: center;
gap: 6px;
}
.toolbar-title {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: #1a2332;
}
.toolbar-result {
color: #7a8a9a;
font-size: 10.5px;
}
.toolbar-filters {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
}
.api-permissions-table-wrapper {
overflow-x: auto;
border-radius: 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -442,7 +492,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.hierarchy-cell {
display: flex;
align-items: center;
gap: 10px;
gap: 6px;
height: 100%;
}
@@ -458,7 +508,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 36px;
height: 24px;
border-radius: 0 3px 3px 0;
background: linear-gradient(180deg, #94a3b8 0%, #cbd5e1 100%);
}
@@ -472,7 +522,7 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
}
.category-label {
font-size: 14px;
font-size: 12px;
font-weight: 700;
color: #0f172a;
letter-spacing: 0.3px;
@@ -481,15 +531,15 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
/* 模块:实心圆点 + 中等加粗字 */
.module-dot {
flex-shrink: 0;
width: 7px;
height: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.module-label {
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: #1e293b;
}
@@ -498,11 +548,11 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.section-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
padding: 2px 7px;
border-radius: 999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 12px;
font-size: 10.5px;
font-weight: 500;
color: #475569;
white-space: nowrap;
@@ -511,17 +561,17 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
.operation-cell {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
}
.action-tag {
flex-shrink: 0;
min-width: 40px;
min-width: 36px;
text-align: center;
}
.operation-name {
font-size: 13px;
font-size: 12px;
color: #475569;
}
@@ -529,10 +579,10 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 18px;
height: 18px;
color: #94a3b8;
font-size: 14px;
font-size: 12px;
font-weight: 600;
}
@@ -544,11 +594,46 @@ defineExpose({ searchText, getOperationSection, getPermissionCategory, getPermis
}
.permission-state--disabled {
color: #94a3b8;
color: #c3ccd5;
font-weight: 500;
}
.permission-state--disabled :deep(svg) {
width: 16px;
height: 16px;
:deep(.api-permissions .el-input__wrapper),
:deep(.api-permissions .el-select__wrapper) {
min-height: 28px;
}
:deep(.api-permissions .el-button) {
min-height: 28px;
padding: 4px 8px;
font-size: 11.5px;
}
:deep(.perm-matrix-table .el-table__header-wrapper th.el-table__cell) {
padding: 6px 0;
font-size: 11px;
}
:deep(.perm-matrix-table .el-table__body-wrapper td.el-table__cell) {
padding: 4px 0;
font-size: 12px;
}
@media (max-width: 1180px) {
.api-permissions-toolbar {
align-items: flex-start;
flex-direction: column;
}
.toolbar-filters {
flex-wrap: wrap;
width: 100%;
}
}
@media (max-width: 720px) {
.toolbar-filters > * {
width: 100% !important;
}
}
</style>
File diff suppressed because it is too large Load Diff
-310
View File
@@ -1,310 +0,0 @@
<template>
<el-drawer
v-if="modelValue"
:model-value="modelValue"
direction="rtl"
size="520px"
:close-on-click-modal="true"
:show-close="false"
class="faq-category-editor-drawer"
@update:model-value="emit('update:modelValue', $event)"
@close="close"
>
<template #header>
<div class="editor-header">
<div class="editor-title">{{ isEdit ? TEXT.modules.knowledgeMedicalConsult.editCategory : TEXT.modules.knowledgeMedicalConsult.newCategory }}</div>
</div>
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="faq-category-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
{{ TEXT.modules.knowledgeMedicalConsult.title }}
</div>
<el-form-item :label="TEXT.modules.knowledgeMedicalConsult.categoryName" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.sortOrder">
<el-input-number v-model="form.sort_order" :min="0" :step="1" />
</el-form-item>
<el-form-item label="图标">
<div class="icon-picker">
<span
v-for="icon in iconOptions"
:key="icon.cls"
:title="icon.label"
:class="['icon-option', {
'icon-option--selected': form.icon === icon.cls,
'icon-option--used': isIconUsedByOther(icon.cls),
}]"
@click="selectIcon(icon.cls)"
><FaqIcon :icon="icon.cls" /></span>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer" :class="{ 'drawer-footer--edit': isEdit }">
<el-button v-if="isEdit && canDelete" type="danger" plain :loading="deleting" @click="onDelete">
{{ TEXT.common.actions.delete }}
</el-button>
<div class="drawer-footer-spacer" v-if="isEdit"></div>
<el-button @click="close">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import FaqIcon from "./FaqIcon.vue";
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
import { useStudyStore } from "../store/study";
import { TEXT, requiredMessage } from "../locales";
const props = defineProps<{ modelValue: boolean; category?: any; isAdmin: boolean; canDelete?: boolean; existingIcons?: string[] }>();
const emit = defineEmits(["update:modelValue", "success"]);
const study = useStudyStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const deleting = ref(false);
const form = reactive({
study_id: "",
name: "",
description: "",
sort_order: 0,
icon: "",
});
const iconOptions = [
{ cls: "fa-th-large", label: "全部" },
{ cls: "fa-ruler-combined", label: "入排标准" },
{ cls: "fa-calendar-check", label: "访视" },
{ cls: "fa-filter", label: "筛选" },
{ cls: "fa-flag-checkered", label: "启动会" },
{ cls: "fa-capsules", label: "试验药物" },
{ cls: "fa-prescription-bottle", label: "药品" },
{ cls: "fa-vial", label: "实验室" },
{ cls: "fa-heartbeat", label: "AE" },
{ cls: "fa-pills", label: "合并用药" },
{ cls: "fa-file-medical", label: "门诊病历" },
{ cls: "fa-camera", label: "拍照" },
{ cls: "fa-notes-medical", label: "医疗记录" },
{ cls: "fa-clipboard-list", label: "检查表" },
{ cls: "fa-syringe", label: "注射" },
{ cls: "fa-flask", label: "研究" },
{ cls: "fa-book-medical", label: "医学书籍" },
{ cls: "fa-user-md", label: "医生" },
{ cls: "fa-hospital", label: "医院" },
{ cls: "fa-ellipsis-h", label: "其他" },
];
const rules: FormRules = {
name: [{ required: true, message: requiredMessage(TEXT.modules.knowledgeMedicalConsult.categoryName), trigger: "blur" }],
};
const isEdit = computed(() => !!props.category);
const isIconUsedByOther = (icon: string) => {
if (form.icon === icon) return false;
return (props.existingIcons || []).includes(icon);
};
const selectIcon = (icon: string) => {
if (isIconUsedByOther(icon)) return;
form.icon = form.icon === icon ? "" : icon;
};
const reset = () => {
form.name = "";
form.description = "";
form.sort_order = 0;
form.icon = "";
};
watch(
() => props.category,
(val) => {
if (val) {
form.name = val.name;
form.description = val.description || "";
form.sort_order = val.sort_order || 0;
form.icon = val.icon || "";
} else {
reset();
}
},
{ immediate: true }
);
watch(
() => props.modelValue,
(val) => {
if (val && !props.category) {
reset();
}
}
);
const close = () => emit("update:modelValue", false);
const onSubmit = async () => {
if (!formRef.value) return;
// 验证当前项目存在
if (!study.currentStudy?.id) {
ElMessage.warning(TEXT.common.empty.selectProject);
return;
}
await formRef.value.validate();
submitting.value = true;
try {
const payload: Record<string, any> = {
study_id: study.currentStudy.id,
name: form.name,
description: form.description || null,
sort_order: form.sort_order,
icon: form.icon || null,
};
if (isEdit.value && props.category) {
await updateFaqCategory(props.category.id, payload);
} else {
await createFaqCategory(payload);
}
ElMessage.success(TEXT.common.messages.saveSuccess);
emit("success");
close();
} catch (e: any) {
const errorMsg = e?.response?.data?.message || e?.response?.data?.detail || TEXT.common.messages.saveFailed;
ElMessage.error(typeof errorMsg === 'string' ? errorMsg : TEXT.common.messages.saveFailed);
} finally {
submitting.value = false;
}
};
const onDelete = async () => {
if (!props.category) return;
const ok = await ElMessageBox.confirm(
TEXT.modules.knowledgeMedicalConsult.confirmDeleteCategory.replace("{name}", props.category.name),
TEXT.common.labels.tips,
).catch(() => null);
if (!ok) return;
deleting.value = true;
try {
await deleteFaqCategory(props.category.id, study.currentStudy?.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
emit("success");
close();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
} finally {
deleting.value = false;
}
};
</script>
<style scoped>
.editor-header {
display: flex;
align-items: center;
}
.editor-title {
color: var(--ctms-text-main);
font-size: 18px;
font-weight: 700;
}
.faq-category-form {
padding: 0 4px;
}
.form-group {
padding: 16px 0;
}
.form-group-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--ctms-text-main);
font-size: 15px;
font-weight: 700;
}
.group-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.group-dot-basic {
background: var(--ctms-primary);
}
.drawer-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.drawer-footer--edit {
justify-content: space-between;
}
.drawer-footer-spacer {
flex: 1;
}
.icon-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.icon-option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 2px solid #e8eef6;
border-radius: 10px;
font-size: 18px;
color: #697982;
cursor: pointer;
transition: all 0.15s ease;
background: #ffffff;
}
.icon-option:hover {
border-color: #93c5fd;
background: #eff6ff;
transform: scale(1.08);
}
.icon-option--selected {
border-color: #3b82f6;
background: #dbeafe;
color: #1e40af;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.icon-option--used {
opacity: 0.3;
pointer-events: none;
cursor: not-allowed;
border-color: #e8eef6;
background: #f5f5f5;
}
</style>
@@ -97,7 +97,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "element-plus";
import { ArrowDown, ArrowUp, Delete, Plus } from "@element-plus/icons-vue";
import FaqIcon from "./FaqIcon.vue";
import { createFaqCategory, updateFaqCategory, deleteFaqCategory } from "../api/faqs";
+60 -20
View File
@@ -88,12 +88,18 @@ const openManager = () => {
<style scoped>
.panel {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
border: 0;
border-radius: 0;
box-shadow: none;
overflow: hidden;
padding: 24px 14px;
background: transparent;
background:
radial-gradient(circle at 18% 0%, rgba(47, 123, 232, 0.12) 0%, transparent 38%),
linear-gradient(180deg, #eef7ff 0%, #f8fbff 52%, #edf3fb 100%);
}
.header {
@@ -101,37 +107,69 @@ const openManager = () => {
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 0 8px 16px;
border-bottom: 1px solid #edf2f8;
color: #8b98aa;
padding: 12px 12px;
border: 1px solid rgba(110, 153, 205, 0.18);
border-radius: 12px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba(232, 244, 255, 0.86) 100%);
box-shadow:
0 10px 24px rgba(42, 86, 143, 0.07),
0 1px 0 rgba(255, 255, 255, 0.9) inset;
color: #385b7f;
font-size: 14px;
font-weight: 800;
}
.header > span {
display: inline-flex;
align-items: center;
gap: 8px;
}
.header > span::before {
content: "";
width: 6px;
height: 20px;
border-radius: 999px;
background: linear-gradient(180deg, #2f7be8 0%, #23b7d9 100%);
box-shadow: 0 0 14px rgba(47, 123, 232, 0.26);
}
.menu {
flex: 1 1 auto;
max-height: calc(100vh - 240px);
min-height: 0;
overflow: auto;
border-right: 0;
padding: 14px 0 0;
padding: 14px 2px 0;
background: transparent;
}
.menu :deep(.el-menu-item) {
position: relative;
height: 44px;
margin: 4px 0;
padding: 0 10px !important;
border: 1px solid transparent;
border-radius: 10px;
color: #5f6f7a;
color: #506475;
font-size: 14px;
font-weight: 700;
line-height: 44px;
transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.menu :deep(.el-menu-item:hover) {
background: rgba(31, 95, 184, 0.06);
border-color: rgba(47, 123, 232, 0.1);
background: rgba(255, 255, 255, 0.66);
color: #1f5fb8;
}
.menu :deep(.el-menu-item.is-active) {
background: #e7f4ff;
color: #1f5fb8;
border-color: rgba(47, 123, 232, 0.18);
background:
linear-gradient(135deg, rgba(220, 239, 255, 0.96) 0%, rgba(240, 248, 255, 0.96) 100%);
box-shadow:
0 10px 22px rgba(47, 123, 232, 0.12),
3px 0 0 #2f7be8 inset;
color: #1559ad;
}
.cat-icon {
display: inline-flex;
@@ -142,10 +180,11 @@ const openManager = () => {
height: 24px;
margin-right: 10px;
border-radius: 7px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: linear-gradient(135deg, rgba(221, 234, 249, 0.9) 0%, rgba(242, 248, 255, 0.9) 100%);
color: #517194;
font-size: 13px;
font-weight: 800;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.82) inset;
}
.cat-icon :deep(svg) {
font-size: 13px;
@@ -153,20 +192,21 @@ const openManager = () => {
height: 1em;
}
.menu :deep(.el-menu-item.is-active) .cat-icon {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #2f7be8 0%, #24b7d8 100%);
color: #ffffff;
box-shadow: 0 8px 18px rgba(47, 123, 232, 0.25);
}
.category-create-btn {
height: 34px;
border: 0;
border-radius: 10px;
background: #415a77;
background: linear-gradient(135deg, #2f527a 0%, #203a5c 100%);
font-weight: 800;
box-shadow: 0 8px 18px rgba(65, 90, 119, 0.16);
box-shadow: 0 8px 18px rgba(37, 67, 105, 0.2);
}
.category-create-btn:hover,
.category-create-btn:focus {
background: #344960;
background: linear-gradient(135deg, #254866 0%, #172f4e 100%);
}
.name {
flex: 1;
@@ -183,8 +223,8 @@ const openManager = () => {
height: 18px;
padding: 0 6px;
border-radius: 999px;
background: rgba(95, 111, 122, 0.08);
color: #697982;
background: rgba(88, 111, 137, 0.1);
color: #5d7590;
font-size: 10px;
font-weight: 700;
line-height: 18px;
@@ -192,7 +232,7 @@ const openManager = () => {
}
.menu :deep(.el-menu-item.is-active) .cat-count {
background: #d7ebff;
color: #1f5fb8;
background: linear-gradient(135deg, #cfe7ff 0%, #dcf7ff 100%);
color: #1559ad;
}
</style>
+154 -19
View File
@@ -1,18 +1,20 @@
<template>
<div class="faq-list-card">
<div class="faq-list-card faq-list-card--workbench">
<el-table
:data="items"
v-loading="loading"
style="width: 100%"
class="faq-table"
:class="{ 'faq-table--with-actions': canDelete, 'faq-table--without-actions': !canDelete }"
:row-class-name="rowClassName"
height="100%"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="question" :label="TEXT.common.fields.question" show-overflow-tooltip>
<template #default="scope">
<div class="question-cell">
<span class="question-icon">{{ questionInitial(scope.row.question) }}</span>
<span class="question-icon"><el-icon><QuestionFilled /></el-icon></span>
<el-link type="primary" :underline="false" class="question-link">
{{ scope.row.question }}
</el-link>
@@ -52,6 +54,7 @@
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import { computed } from "vue";
import { useRouter } from "vue-router";
import { deleteFaqItem } from "../api/faqs";
@@ -81,10 +84,6 @@ const categoryLabel = (categoryId: string) => {
return categoryMap.value[categoryId] || categoryId || "--";
};
const questionInitial = (question?: string) => {
return String(question || "?").trim().slice(0, 1) || "?";
};
const splitDateTime = (value?: string | number | Date | null) => {
const displayValue = displayDateTime(value);
const [date, time] = displayValue.split(" ");
@@ -145,9 +144,10 @@ const remove = async (row: FaqItem) => {
}
.faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
border-bottom: 0;
background: transparent;
color: #8a98aa;
height: 46px;
border-bottom: 1px solid #dce8f7;
background: linear-gradient(180deg, #f7fbff 0%, #eef5fc 100%);
color: #5d7088;
font-size: 14px;
font-weight: 800;
}
@@ -166,21 +166,23 @@ const remove = async (row: FaqItem) => {
}
.faq-table :deep(.faq-row td.el-table__cell) {
padding: 12px 0;
padding: 14px 0;
border-bottom: 1px solid #e8eef6;
background: #ffffff;
transition: background 0.18s ease;
}
.faq-table :deep(.faq-row:hover td.el-table__cell) {
background: #f8f9fb;
background: #f7fbff;
}
.faq-table :deep(col:nth-child(1)) { width: 43%; }
.faq-table :deep(col:nth-child(2)) { width: 14%; }
.faq-table :deep(col:nth-child(3)) { width: 14%; }
.faq-table :deep(col:nth-child(4)) { width: 14%; }
.faq-table :deep(col:nth-child(5)) { width: 15%; }
.faq-table--with-actions :deep(col) {
width: 20%;
}
.faq-table--without-actions :deep(col) {
width: 25%;
}
@@ -205,10 +207,19 @@ const remove = async (row: FaqItem) => {
width: 34px;
height: 34px;
border-radius: 10px;
background: #f3f7fb;
color: #5e6f7b;
font-size: 15px;
background:
radial-gradient(circle at 26% 22%, rgba(255, 255, 255, 0.42) 0%, transparent 28%),
linear-gradient(135deg, #2f7be8 0%, #23b7d9 100%);
color: #ffffff;
font-size: 17px;
font-weight: 800;
box-shadow:
0 10px 20px rgba(47, 123, 232, 0.2),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.question-icon :deep(.el-icon) {
font-size: 1em;
}
.question-link {
@@ -257,4 +268,128 @@ const remove = async (row: FaqItem) => {
.question-link {
cursor: pointer;
}
.faq-list-card--workbench {
display: flex;
height: 100%;
min-height: 0;
flex: 1 1 auto;
background: #ffffff;
}
.faq-list-card--workbench .faq-table {
height: 100%;
min-height: 0;
flex: 1 1 auto;
}
.faq-list-card--workbench .faq-table :deep(.el-table__inner-wrapper) {
height: 100%;
}
.faq-list-card--workbench .faq-table :deep(.el-table__header-wrapper th.el-table__cell) {
height: 42px;
padding: 0 14px;
border-bottom: 1px solid rgba(121, 152, 188, 0.28) !important;
background: linear-gradient(180deg, #f4f9ff 0%, #eaf3fc 100%) !important;
color: #49647f;
font-size: 12px;
font-weight: 850;
}
.faq-list-card--workbench .faq-table :deep(.el-table__body) {
border-collapse: separate;
border-spacing: 0 6px;
}
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper) {
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
scrollbar-width: none;
}
.faq-list-card--workbench .faq-table :deep(.el-table__body-wrapper::-webkit-scrollbar),
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap::-webkit-scrollbar) {
display: none;
width: 0;
height: 0;
}
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__wrap) {
scrollbar-width: none;
}
.faq-list-card--workbench .faq-table :deep(.el-scrollbar__bar.is-vertical) {
display: none !important;
}
.faq-list-card--workbench .faq-table :deep(.el-table__empty-block) {
min-height: 100%;
background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%);
}
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell) {
padding: 10px 14px;
border-top: 1px solid rgba(226, 235, 246, 0.92);
border-bottom: 1px solid rgba(226, 235, 246, 0.92);
background: #ffffff;
transition: background 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
}
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:first-child) {
border-left: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 9px 0 0 9px;
}
.faq-list-card--workbench .faq-table :deep(.faq-row td.el-table__cell:last-child) {
border-right: 1px solid rgba(226, 235, 246, 0.92);
border-radius: 0 9px 9px 0;
}
.faq-list-card--workbench .faq-table :deep(.faq-row:hover td.el-table__cell) {
border-color: rgba(111, 159, 220, 0.34);
background: #f5faff;
box-shadow: 0 8px 18px rgba(45, 86, 143, 0.06);
}
.faq-list-card--workbench .question-cell {
gap: 10px;
}
.faq-list-card--workbench .question-icon {
width: 28px;
height: 28px;
border-radius: 8px;
font-size: 14px;
box-shadow:
0 8px 16px rgba(47, 123, 232, 0.18),
0 1px 0 rgba(255, 255, 255, 0.45) inset;
}
.faq-list-card--workbench .question-link {
color: #0f172a;
font-size: 13px;
font-weight: 700;
}
.faq-list-card--workbench .category-pill {
height: 24px;
padding: 0 9px;
border-radius: 7px;
background: linear-gradient(135deg, #e7f3ff 0%, #eefaff 100%);
font-size: 12px;
}
.faq-list-card--workbench .time-text {
gap: 0;
font-size: 11px;
}
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row td.el-table__cell) {
border-color: #26364a;
background: #172033;
}
:global([data-ctms-theme="dark"] .faq-list-card--workbench .faq-table .faq-row:hover td.el-table__cell) {
background: #1d2b42;
}
</style>
+728 -6
View File
@@ -2,27 +2,68 @@ 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");
const normalizeSource = (source: string) => source.replace(/\r\n/g, "\n");
const readSource = (path: string) => normalizeSource(readFileSync(path, "utf8"));
const readLayoutSource = () => readSource(resolve(__dirname, "./Layout.vue"));
const readAppSource = () => readSource(resolve(__dirname, "../App.vue"));
const readDesktopLayoutSource = () => readSource(resolve(__dirname, "./DesktopLayout.vue"));
const readWebLayoutSource = () => readSource(resolve(__dirname, "./WebLayout.vue"));
const readNavigationSource = () => readSource(resolve(__dirname, "./layout/navigation.ts"));
const readTauriConfigSource = () => readSource(resolve(__dirname, "../../src-tauri/tauri.conf.json"));
const readTauriLibSource = () => readSource(resolve(__dirname, "../../src-tauri/src/lib.rs"));
const readDesktopPreferencesSource = () => readSource(resolve(__dirname, "../views/DesktopPreferences.vue"));
const readDesktopServerSettingsSource = () => readSource(resolve(__dirname, "../views/DesktopServerSettings.vue"));
const readDesktopUpdateManagerSource = () => readSource(resolve(__dirname, "../session/desktopUpdateManager.ts"));
const readDesktopNotificationManagerSource = () => readSource(resolve(__dirname, "../session/desktopNotificationManager.ts"));
const readDesktopActivityCenterSource = () => readSource(resolve(__dirname, "../session/desktopActivityCenter.ts"));
const readProfileSettingsSource = () => readSource(resolve(__dirname, "../views/ProfileSettings.vue"));
const readLoginSource = () => readSource(resolve(__dirname, "../views/Login.vue"));
const readRegisterSource = () => readSource(resolve(__dirname, "../views/Register.vue"));
const readForgotPasswordSource = () => readSource(resolve(__dirname, "../views/ForgotPassword.vue"));
const readFileTaskFeedbackSource = () => readSource(resolve(__dirname, "../utils/fileTaskFeedback.ts"));
const readAttachmentListSource = () => readSource(resolve(__dirname, "./attachments/AttachmentList.vue"));
const readDocumentDetailSource = () => readSource(resolve(__dirname, "../views/documents/DocumentDetail.vue"));
const readMainStyleSource = () => readSource(resolve(__dirname, "../styles/main.css"));
const readFaqSource = () => readSource(resolve(__dirname, "../views/Faq.vue"));
const readFaqListSource = () => readSource(resolve(__dirname, "./FaqList.vue"));
const readProjectOverviewSource = () => readSource(resolve(__dirname, "../views/ia/ProjectOverview.vue"));
const readProjectMilestonesSource = () => readSource(resolve(__dirname, "../views/ia/ProjectMilestones.vue"));
describe("desktop layout shell", () => {
it("routes desktop and web shells through the runtime flag", () => {
const source = readLayoutSource();
const app = readAppSource();
expect(source).toContain("const isDesktop = isTauriRuntime()");
expect(source).toContain('defineAsyncComponent(() => import("./DesktopLayout.vue"))');
expect(source).toContain('defineAsyncComponent(() => import("./WebLayout.vue"))');
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
expect(source).toContain("<WebLayout v-else />");
expect(app).toContain("const isDesktopRuntime = isTauriRuntime();");
expect(app).toContain("initializeDesktopThemePreference();");
expect(app).toContain("initializeDesktopMenuShortcutSync();");
expect(app).toContain('document.body.classList.add("is-desktop-runtime")');
expect(app).toContain('document.body.classList.remove("is-desktop-runtime")');
});
it("uses route-only desktop preference storage", () => {
const source = readDesktopLayoutSource();
const webSource = readWebLayoutSource();
expect(source).toContain("readDesktopRecentRoutes");
expect(source).toContain("readDesktopFavoriteRoutes");
expect(source).toContain("recordDesktopRecentRoute");
expect(source).toContain("currentDesktopRoutePreference");
expect(source).toContain("toggleDesktopFavoriteRoute");
expect(source).not.toContain("readDesktopRecentRoutes");
expect(source).not.toContain("recordDesktopRecentRoute");
expect(source).not.toContain("desktopRecentRoutes");
expect(source).not.toContain("最近访问");
expect(webSource).toContain("readDesktopFavoriteRoutes");
expect(webSource).toContain("toggleDesktopFavoriteRoute");
expect(webSource).not.toContain("readDesktopRecentRoutes");
expect(webSource).not.toContain("recordDesktopRecentRoute");
expect(webSource).not.toContain("desktopRecentRoutes");
expect(webSource).not.toContain("最近访问");
});
it("shares active route mapping between web and desktop shells", () => {
@@ -34,4 +75,685 @@ describe("desktop layout shell", () => {
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
expect(navigation).toContain("export const getActiveLayoutPath");
});
it("keeps web project switching routed through the workbench entry", () => {
const webLayout = readWebLayoutSource();
expect(webLayout).not.toContain("workbench-return-button");
expect(webLayout).toContain('<el-dropdown-item command="workbench">');
expect(webLayout).toContain('await router.push("/workbench")');
expect(webLayout).toContain("const hasProjectContext = computed(() => Boolean(study.currentStudy) && !isAdminContext.value);");
expect(webLayout).toContain('v-if="hasProjectContext && hasAnyProjectModuleAccess"');
expect(webLayout).toContain("const showAdminNavigation = computed(() => Boolean(auth.user) && !hasProjectContext.value)");
expect(webLayout).not.toContain("hasDropdown: true");
expect(webLayout).not.toContain("type: 'study'");
expect(webLayout).not.toContain('type: "study"');
expect(webLayout).not.toContain("`切换项目:${item.name}`");
expect(webLayout).not.toContain("study.setCurrentStudy(cmd.value)");
expect(webLayout).not.toContain('router.push("/project/overview");');
});
it("keeps desktop context labels and command entry points de-duplicated", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("<h1>{{ TEXT.common.appName }}</h1>");
expect(source).toContain('const DESKTOP_ADMIN_SECTION_LABEL = "系统管理";');
expect(source).toContain('data-tauri-drag-region');
expect(source).toContain('class="toolbar-title"');
expect(source).toContain('class="toolbar-subtitle"');
expect(source).toContain("desktopToolbarTitle");
expect(source).toContain("desktopToolbarSubtitle");
expect(source).not.toContain("desktopBreadcrumbs");
expect(source).not.toContain("breadcrumb-chip");
expect(source).toContain('class="workspace-history-controls"');
expect(source).toContain('title="后退(当前任务)"');
expect(source).toContain('title="前进(当前任务)"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabBack"');
expect(source).toContain(':disabled="!canNavigateWorkspaceTabForward"');
expect(source).toContain('class="toolbar-history-divider"');
expect(source).toContain('v-for="(item, index) in desktopToolbarContextItems"');
expect(source).toContain('class="toolbar-context-link"');
expect(source).not.toContain('title="刷新当前视图"');
expect(source).not.toContain("const sidebarTitle");
expect(source).not.toContain('title="搜索命令"');
expect(source).not.toContain("desktop-statusbar");
expect(source).not.toContain("status-item");
expect(source).not.toContain("accountProjectRoleLabel");
expect(source).not.toContain("projectStatusLabel");
expect(source).toContain("grid-template-rows: 52px auto minmax(0, 1fr);");
expect(source).toContain("const adminDesktopNavigationItems");
expect(source).toContain("const projectDesktopNavigationItems");
expect(source).toContain("hasCurrentStudy: Boolean(study.currentStudy) && !isAdminContext.value");
expect(source).toContain('id: "desktop:refresh"');
expect(source).toContain("shortcut: formatDesktopShortcut(desktopShortcutPreferences.value.refresh)");
expect(source).toContain('command === "ctms.desktop.back"');
expect(source).toContain('command === "ctms.desktop.forward"');
expect(source).not.toContain("const root = study.currentStudy?.name || TEXT.menu.currentProject");
});
it("uses macOS overlay titlebar without adding window permissions", () => {
const tauriConfig = readTauriConfigSource();
const layout = readDesktopLayoutSource();
expect(tauriConfig).toContain('"decorations": true');
expect(tauriConfig).toContain('"titleBarStyle": "Overlay"');
expect(tauriConfig).toContain('"hiddenTitle": true');
expect(tauriConfig).toContain('"backgroundColor": "#f9fafb"');
expect(tauriConfig).toContain('"backgroundThrottling": "disabled"');
expect(tauriConfig).toContain('"trafficLightPosition": { "x": 16, "y": 18 }');
expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("-webkit-app-region: drag;");
expect(layout).toContain("-webkit-app-region: no-drag;");
expect(layout).not.toContain(["@tauri-apps", "api", "window"].join("/"));
});
it("uses a native macOS application menu and rebuilds a closed main window from the Dock", () => {
const tauriLib = readTauriLibSource();
expect(tauriLib.indexOf("package.name.clone()")).toBeLessThan(tauriLib.indexOf('"文件"'));
expect(tauriLib).toContain('Some("关于 CTMS")');
expect(tauriLib).toContain("short_version: Some(String::new())");
expect(tauriLib).toContain("icon: handle.default_window_icon().cloned()");
expect(tauriLib).toContain('"ctms.desktop.preferences"');
expect(tauriLib).toContain("PredefinedMenuItem::services");
expect(tauriLib).toContain("PredefinedMenuItem::hide_others");
expect(tauriLib).toContain("PredefinedMenuItem::show_all");
expect(tauriLib).toContain("WINDOW_SUBMENU_ID");
expect(tauriLib).toContain("HELP_SUBMENU_ID");
expect(tauriLib).toContain("TOGGLE_SIDEBAR_COMMAND_ID");
expect(tauriLib).toContain('"显示/隐藏导航栏"');
expect(tauriLib).toContain("RunEvent::Reopen");
expect(tauriLib).toContain("WebviewWindowBuilder::from_config");
expect(tauriLib).toContain('find(|config| config.label == "main")');
expect(tauriLib).not.toContain("WindowEvent::CloseRequested");
expect(tauriLib).not.toContain("api.prevent_close()");
expect(tauriLib).not.toContain("window.hide()");
});
it("keeps the desktop sidebar dense enough for navigation-heavy screens", () => {
const layout = readDesktopLayoutSource();
const sidebarHeadStart = layout.indexOf('<header class="sidebar-head">');
const sidebarHeadEnd = layout.indexOf("</header>", sidebarHeadStart);
const sidebarHeadTemplate = layout.slice(sidebarHeadStart, sidebarHeadEnd);
expect(layout).toContain("grid-template-columns: 248px minmax(0, 1fr);");
expect(layout).toContain("padding: 44px 10px 10px;");
expect(layout).toContain("grid-template-columns: auto minmax(0, 1fr);");
expect(layout).toContain("margin-top: 0;");
expect(layout).toContain("const desktopProjectSectionLabel = computed(() => study.currentStudy?.name || TEXT.menu.currentProject)");
expect(layout).toContain("{{ desktopProjectSectionLabel }}");
expect(layout).toContain('command="projectEntry"');
expect(layout).toContain("projectEntryMenuLabel");
expect(layout).toContain("openDesktopProjectEntry");
expect(layout).toContain("const showSidebarProjectChooser = computed(() => !study.currentStudy && !isAdminContext.value);");
expect(layout).toContain("padding: 10px 8px 24px;");
expect(layout).toContain("margin-top: 12px;");
expect(layout).toContain("font-size: 14px;");
expect(sidebarHeadTemplate).toContain('<div class="sidebar-title-row">');
expect(sidebarHeadTemplate.indexOf("<h1>{{ TEXT.common.appName }}</h1>")).toBeLessThan(
sidebarHeadTemplate.indexOf('<button v-if="showSidebarProjectChooser" class="study-switcher-trigger empty"'),
);
expect(sidebarHeadTemplate).not.toContain("study-context-badge");
expect(layout).not.toContain(".study-context-badge");
expect(layout).not.toContain(".study-name");
expect(sidebarHeadTemplate).not.toContain('@command="handleStudySwitch"');
expect(layout).not.toContain("handleStudySwitch");
expect(layout).not.toContain("`切换项目:${item.name}`");
expect(layout).not.toContain("grid-template-columns: 272px minmax(0, 1fr);");
expect(layout).not.toContain("padding: 54px 16px 14px;");
expect(layout).not.toContain("padding: 44px 12px 12px;");
});
it("persists a fully hidden desktop sidebar with a macOS-safe restore control", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("const desktopSidebarVisible = ref(readDesktopSidebarVisible());");
expect(layout).toContain("desktopSidebarVisible.value = setDesktopSidebarVisible(!desktopSidebarVisible.value);");
expect(layout).toContain("'is-sidebar-hidden': !desktopSidebarVisible");
expect(layout).toContain("'is-macos': desktopMetadata.platform === 'macos'");
expect(layout).toContain('class="toolbar-nav-button sidebar-toggle-button"');
expect(layout).toContain(':title="desktopSidebarToggleLabel"');
expect(layout).toContain('<Fold v-if="desktopSidebarVisible" /><Expand v-else />');
expect(layout).toContain('command === "ctms.desktop.toggleSidebar"');
expect(layout).toContain(".desktop-workbench.is-sidebar-hidden {");
expect(layout).toContain("grid-template-columns: 0 minmax(0, 1fr);");
expect(layout).toContain(".desktop-workbench.is-macos.is-sidebar-hidden .desktop-toolbar {");
expect(layout).toContain("padding-left: 88px;");
expect(layout).toContain("pointer-events: none;");
});
it("uses the collaboration route immersive mode in the desktop shell", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain("'is-immersive-workspace': route.meta.immersiveWorkspace");
expect(layout).toContain('<aside v-if="!route.meta.immersiveWorkspace" class="desktop-sidebar">');
expect(layout).toContain('<header v-if="!route.meta.immersiveWorkspace" class="desktop-toolbar">');
expect(layout).toContain('v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1"');
expect(layout).toContain(".desktop-workbench.is-immersive-workspace {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-main {");
expect(layout).toContain(".desktop-workbench.is-immersive-workspace .desktop-content {");
});
it("renders desktop preferences as a split settings panel", () => {
const preferences = readDesktopPreferencesSource();
const desktopLayout = readDesktopLayoutSource();
const webLayout = readWebLayoutSource();
expect(preferences).toContain('class="preferences-sidebar"');
expect(preferences).toContain('class="preferences-pane"');
expect(preferences).toContain("const activeSectionId = ref<PreferenceSectionId>");
expect(preferences).toContain('grid-template-columns: 232px minmax(0, 1fr);');
expect(preferences).toContain("height: 700px;");
expect(preferences).toContain("max-height: calc(100vh - 96px);");
expect(preferences).toContain("scrollbar-gutter: stable;");
expect(preferences).toContain('{ id: "connection", label: "连接"');
expect(preferences).toContain('{ id: "appearance", label: "外观"');
expect(preferences).toContain('{ value: "system" as const, label: "跟随系统"');
expect(preferences).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
expect(preferences).toContain("width: min(100%, 336px);");
expect(preferences).toContain("white-space: nowrap;");
expect(preferences).toContain('{ id: "shortcuts", label: "快捷键"');
expect(preferences).toContain('{ id: "notifications", label: "通知"');
expect(preferences).toContain('{ id: "updates", label: "更新"');
expect(preferences).toContain('{ id: "diagnostics", label: "诊断信息"');
expect(preferences).toContain("testDesktopServerConnection");
expect(preferences).toContain("saveDesktopServerConfig");
expect(preferences).toContain("测试连通性");
expect(preferences).not.toContain("保存服务器");
expect(preferences).toContain('ElMessage.success("服务器设置已保存")');
expect(preferences).toContain("ElMessage.error(`保存失败:${message}`)");
expect(preferences).toContain("normalizeDesktopServerUrl");
expect(preferences).toContain("确认切换服务器");
expect(preferences).toContain('<Transition name="preferences-header" mode="out-in">');
expect(preferences).toContain('<Transition name="preferences-panel" mode="out-in">');
expect(preferences).toContain('<Transition name="connection-feedback">');
expect(preferences).toContain("const selectSection = (sectionId: PreferenceSectionId)");
expect(preferences).toContain("const setConnectionPending = (message: string)");
expect(preferences).toContain("const scheduleConnectionPending = (message: string)");
expect(preferences).toContain("}, 180);");
expect(preferences).toContain('scrollTo({ top: 0, behavior: "smooth" })');
expect(preferences).not.toContain("connectionStatus.value = null");
expect(preferences).toContain("min-width: 100px;");
expect(preferences).toContain("connection-feedback-enter-active");
expect(preferences).toContain("@media (prefers-reduced-motion: reduce)");
expect(preferences).toContain(':global([data-ctms-theme="dark"] .desktop-preferences)');
expect(preferences).not.toContain(':global([data-ctms-theme="dark"]) .desktop-preferences');
expect(desktopLayout).toContain(':global([data-ctms-theme="dark"] .desktop-workbench)');
expect(desktopLayout).not.toContain(':global([data-ctms-theme="dark"]) .desktop-workbench');
expect(desktopLayout).toContain('width="940px"');
expect(webLayout).toContain('width="940px"');
expect(desktopLayout).not.toContain(":global(.desktop-preferences-dialog .el-dialog__body > *)");
expect(desktopLayout).toContain('title: "连接设置"');
expect(webLayout).toContain('title: "连接设置"');
expect(desktopLayout).not.toContain('router.push("/desktop/server-settings")');
expect(webLayout).not.toContain('router.push("/desktop/server-settings")');
expect(desktopLayout).not.toContain('width="720px"');
expect(webLayout).not.toContain('width="720px"');
});
it("keeps client diagnostics out of profile while desktop controls stay in preferences", () => {
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(profile).not.toContain("form-section--desktop");
expect(profile).not.toContain("客户端与通知");
expect(profile).not.toContain("getDesktopNotificationSubscription");
expect(profile).not.toContain("setDesktopNotificationSubscription");
expect(profile).not.toContain("checkDesktopUpdateAndPrompt");
expect(profile).not.toContain("desktopNotificationsEnabled");
expect(profile).not.toContain("form-section--diagnostics");
expect(profile).not.toContain("客户端诊断");
expect(profile).not.toContain("clientDiagnosticRows");
expect(profile).not.toContain("copyClientDiagnostics");
expect(profile).not.toContain("getAppMetadata");
expect(profile).not.toContain("getDesktopServerUrl");
expect(profile).not.toContain("clientRuntime.capabilities");
expect(profile).not.toContain("诊断信息已复制");
expect(preferences).toContain("getDesktopNotificationSubscription");
expect(preferences).toContain("checkDesktopUpdateAndPrompt");
expect(preferences).toContain("clientMetadataRows");
expect(preferences).not.toContain('{ label: "主题"');
expect(preferences).not.toContain('{ label: "服务器"');
});
it("keeps workspace tabs stable and draggable like browser tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('@click="navigateWorkspaceTab(item.path)"');
expect(tabsTemplate).toContain('draggable="true"');
expect(tabsTemplate).toContain('@dragstart="startWorkspaceTabDrag(item.path, $event)"');
expect(tabsTemplate).toContain('@drop.prevent="dropWorkspaceTab(item.path, $event)"');
expect(source).toContain("const draggingWorkspaceTabPath = ref(\"\")");
expect(source).toContain("const navigateWorkspaceTab = (path: string) => {");
expect(source).toContain("if (activeMenu.value === path) return;");
expect(source).toContain("next[existingIndex] = { ...next[existingIndex], ...item };");
expect(source).toContain("const [moved] = next.splice(sourceIndex, 1);");
expect(source).toContain("<KeepAlive :max=\"DESKTOP_WORKSPACE_TAB_CACHE_MAX\">");
expect(source).toContain(":key=\"desktopRouteCacheKey(currentRoute)\"");
expect(source).toContain("const desktopRouteCacheKey = (currentRoute: { fullPath: string }) => currentRoute.fullPath;");
expect(source).not.toContain('<div :key="currentRoute.fullPath" class="desktop-route-shell">');
expect(tabsTemplate).not.toContain('@click="router.push(item.path)"');
expect(source).not.toContain(".slice(-7)");
expect(tabsTemplate).toContain('@contextmenu.prevent.stop="openWorkspaceTabMenu(item, $event)"');
expect(tabsTemplate).not.toContain('class="tab-group"');
expect(tabsTemplate).not.toContain("{{ item.group }}");
expect(source).toContain("const lastClosedWorkspaceTab = ref<DesktopRoutePreference | null>(null);");
expect(source).toContain("const workspaceTabMenu = ref<");
expect(source).toContain("const closeOtherWorkspaceTabs = (path: string) => {");
expect(source).toContain("const restoreLastClosedWorkspaceTab = () => {");
expect(source).toContain("const workspaceTabFallbackPath = computed(() => {");
expect(source).toContain('if (isAdminContext.value) return adminDesktopNavigationItems.value[0]?.path || "/admin/projects";');
expect(source).toContain("router.push(workspaceTabFallbackPath.value);");
expect(source).not.toContain('router.push(study.currentStudy ? "/project/overview" : "/desktop/project-entry");');
expect(source).toContain('id: `workspace-tab:${item.path}`');
expect(source).toContain('title: `切换标签:${item.title}`');
expect(source).toContain('title: "重新打开最近关闭标签"');
});
it("keeps shortcut navigation history isolated to the active workspace tab", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTabHistories = ref<Record<string, WorkspaceTabHistory>>({});");
expect(source).toContain("recordWorkspaceTabRoute(current.path, fullPath);");
expect(source).toContain("replaceWorkspaceTabRoute(current.path, fullPath);");
expect(source).toContain("currentWorkspaceTabPath(workspaceTabHistories.value[tabPath], tabPath)");
expect(source).toContain("moveWorkspaceTabHistory(workspaceTabHistories.value[tabPath], offset)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, -1)");
expect(source).toContain("canMoveWorkspaceTabHistory(activeWorkspaceTabHistory.value, 1)");
expect(source).toContain("navigateBack: () => navigateCurrentWorkspaceTabHistory(-1)");
expect(source).toContain("navigateForward: () => navigateCurrentWorkspaceTabHistory(1)");
expect(source).toContain('id: "desktop:tab-back"');
expect(source).toContain('id: "desktop:tab-forward"');
expect(source).not.toContain("navigateBack: () => router.back()");
expect(source).not.toContain("navigateForward: () => router.forward()");
});
it("updates a detail workspace tab after its business context has loaded", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("buildWorkspaceTabTitle,");
expect(source).toContain("buildWorkspaceToolbarTitle,");
expect(source).toContain("resolveWorkspaceTabContextTitle,");
expect(source).toContain('} from "./layout/workspaceTabTitle";');
expect(source).toContain("const workspaceTabContexts = ref<Record<string, WorkspaceTabContext>>({});");
expect(source).toContain("title: buildWorkspaceTabTitle({");
expect(source).toContain("const pageTitle = resolveWorkspaceTabContextTitle({");
expect(source).toContain("() => [study.viewContext?.pageTitle, study.viewContext?.objectType] as const,");
expect(source).toContain("[activeMenu.value]: {");
expect(source).toContain("([pageTitle, objectType]) => {");
expect(source).toContain("if (current) addWorkspaceTab(current);");
expect(source).toContain('{ flush: "sync" },');
expect(source).toContain("if (!previousRoute || previousRoute.path !== route.path) {");
expect(source).toContain("const removeWorkspaceTabContext = (tabPath: string) => {");
});
it("closes transient preview tasks back to their originating workspace tab", () => {
const source = readDesktopLayoutSource();
expect(source).toContain("const workspaceTaskOrigins = ref<Record<string, string>>({});");
expect(source).toContain('group: String(route.meta.workspaceTaskGroup || "工作区任务")');
expect(source).toContain("if (route.meta.transientWorkspaceTask && previousTabPath && previousTabPath !== current.path)");
expect(source).toContain("workspaceTabs.value.find((tab) => tab.path === originPath)");
expect(source).toContain("provide(workspaceTaskControllerKey, { closeTransientTask: closeTransientWorkspaceTask });");
expect(source).toContain("if (!workspaceTaskOrigins.value[path] && workspaceTabs.value.length <= 1) return false;");
});
it("allows desktop shortcuts to be customized from system preferences", () => {
const preferences = readDesktopPreferencesSource();
const tauriLib = readTauriLibSource();
expect(preferences).toContain("activeSectionId === 'shortcuts'");
expect(preferences).toContain("desktopShortcutFromKeyboardEvent");
expect(preferences).toContain("setDesktopShortcutPreference");
expect(preferences).toContain("resetDesktopShortcutPreferences");
expect(preferences).toContain("startDesktopShortcutCapture($event, item.action)");
expect(preferences).toContain('window.addEventListener("keydown", captureDesktopShortcut, { capture: true })');
expect(preferences).toContain('window.addEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true })');
expect(preferences).toContain("event.stopImmediatePropagation()");
expect(preferences).toContain("恢复默认");
expect(tauriLib).toContain('Some("CmdOrCtrl+R")');
expect(tauriLib).toContain('Some("CmdOrCtrl+[")');
expect(tauriLib).toContain('Some("CmdOrCtrl+]")');
expect(tauriLib).toContain("desktop_menu_set_shortcuts");
expect(tauriLib).toContain("set_menu_accelerator");
});
it("shows the four most recently opened tasks with a searchable all-tasks menu after five tabs", () => {
const source = readDesktopLayoutSource();
const tabsStart = source.indexOf(
'<div v-if="!route.meta.immersiveWorkspace && workspaceTabs.length > 1" class="workspace-tabs">',
);
const tabsEnd = source.indexOf('<main class="desktop-content"', tabsStart);
const tabsTemplate = source.slice(tabsStart, tabsEnd);
expect(tabsTemplate).toContain('v-for="item in visibleWorkspaceTabs"');
expect(tabsTemplate).toContain('v-if="hasWorkspaceTabsOverflow"');
expect(tabsTemplate).toContain('popper-class="workspace-tabs-overflow-popper"');
expect(tabsTemplate).toContain('<span>全部任务</span>');
expect(tabsTemplate).toContain('{{ workspaceTabs.length }}');
expect(tabsTemplate).toContain('@command="navigateOverflowWorkspaceTab"');
expect(tabsTemplate).toContain('gridTemplateColumns: `repeat(${visibleWorkspaceTabs.length}, minmax(0, 238px))`');
expect(tabsTemplate).toContain('class="workspace-tabs-overflow-header"');
expect(tabsTemplate).toContain('v-model="workspaceTabsSearchQuery"');
expect(tabsTemplate).toContain('v-for="item in filteredWorkspaceTabs"');
expect(tabsTemplate).toContain('@click.stop="closeOtherWorkspaceTabs(activeMenu)"');
expect(tabsTemplate).toContain('@click.stop="closeAllWorkspaceTabs"');
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_OVERFLOW_THRESHOLD = 5;");
expect(source).toContain("const DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT = 4;");
expect(source).toContain("const workspaceTabsByRecentUse = computed(() =>");
expect(source).toContain("const visibleWorkspaceTabs = computed(() => {");
expect(source).toContain("const recentVisiblePaths = new Set(");
expect(source).toContain("workspaceTabsByRecentUse.value.slice(-DESKTOP_WORKSPACE_TABS_RECENT_VISIBLE_COUNT)");
expect(source).toContain("return workspaceTabs.value.filter((item) => recentVisiblePaths.has(item.path));");
expect(source).toContain("const filteredWorkspaceTabs = computed(() => {");
expect(source).toContain("const closeAllWorkspaceTabs = () => {");
expect(source).not.toContain("visibleWorkspaceTabCapacity");
expect(source).not.toContain("workspaceTabsResizeObserver");
expect(source).toContain(".workspace-tabs-list {\n display: grid;");
expect(source).toContain("padding: 0 10px;");
expect(source).toContain("padding: 0 14px;");
expect(source).toContain("overflow: hidden;");
expect(source).not.toContain("overflow-x: auto;");
});
it("keeps desktop navigation hierarchy aligned with the web sidebar", () => {
const desktopLayout = readDesktopLayoutSource();
const navigation = readNavigationSource();
const sidebarStart = desktopLayout.indexOf('<div class="sidebar-scroll">');
const sidebarEnd = desktopLayout.indexOf("</aside>", sidebarStart);
const sidebarTemplate = desktopLayout.slice(sidebarStart, sidebarEnd);
expect(sidebarTemplate.indexOf('v-if="adminNavigationItems.length"')).toBeLessThan(
sidebarTemplate.indexOf('v-if="projectNavigationItems.length"'),
);
expect(sidebarTemplate).toContain('@click="toggleNavigationGroup(item)"');
expect(sidebarTemplate).toContain(':aria-expanded="isNavigationGroupExpanded(item)"');
expect(sidebarTemplate).toContain('v-show="isNavigationGroupExpanded(item)"');
expect(desktopLayout).toContain("const expandedNavigationGroups = ref<Set<string>>(new Set());");
expect(desktopLayout).toContain("const collapsedNavigationGroups = ref<Set<string>>(new Set());");
expect(desktopLayout).toContain("isNavigationGroupActive(item) && !collapsedNavigationGroups.value.has(key)");
expect(desktopLayout).toContain("nextCollapsed.add(key);");
expect(desktopLayout).toContain("nextCollapsed.delete(key);");
expect(navigation).toContain('label: "系统设置"');
expect(navigation).toContain('label: "邮件服务"');
expect(navigation).toContain('group: "系统设置"');
expect(navigation).toContain("item.children?.length ? item.children : [item]");
});
it("shows the real notification delivery path and keeps native notification access constrained", () => {
const serverSettings = readDesktopServerSettingsSource();
const preferences = readDesktopPreferencesSource();
const desktopNotificationManager = readDesktopNotificationManagerSource();
const desktopUpdateManager = readDesktopUpdateManagerSource();
const desktopLayout = readDesktopLayoutSource();
expect(serverSettings).toContain("connectionDiagnostic");
expect(serverSettings).not.toContain("复制连接诊断");
expect(serverSettings).not.toContain("copyConnectionDiagnostic");
expect(serverSettings).toContain("确认切换服务器");
expect(serverSettings).toContain("切换服务器会退出当前会话并清除当前项目上下文");
expect(serverSettings).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(serverSettings).not.toContain("服务器连接已确认");
expect(preferences).toContain("connectionDiagnostic");
expect(preferences).not.toContain("复制连接诊断");
expect(preferences).not.toContain("copyConnectionDiagnostic");
expect(preferences).not.toContain("服务器连通性正常");
expect(preferences).not.toContain("服务器连接已确认");
expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })");
expect(preferences).toContain("系统权限:{{ notificationPermissionText }}");
expect(preferences).toContain("服务端订阅");
expect(preferences).toContain("真实业务路径");
expect(preferences).toContain("授权并开启");
expect(preferences).toContain("重新检测权限");
expect(preferences).toContain("立即检查业务提醒");
expect(preferences).toContain("系统设置 → 通知 → CTMS");
expect(preferences).toContain("设置 → 系统 → 通知 → CTMS");
expect(preferences).toContain("showSystemNotificationProbe");
expect(preferences).toContain("sendDesktopNotificationTest");
expect(preferences).toContain("发送测试通知");
expect(preferences).toContain("openProjectNotificationCenter");
expect(preferences).toContain("listenDesktopNotificationStatus");
expect(preferences).not.toContain("更新诊断");
expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/"));
expect(desktopNotificationManager).toContain("claimDesktopNotifications");
expect(desktopNotificationManager).toContain("acknowledgeDesktopNotifications");
expect(desktopNotificationManager).toContain('state: "permission-required"');
expect(desktopNotificationManager).toContain('state: deliveredIds.length ? "delivered" : "ready"');
expect(preferences).toContain("listenDesktopUpdateStatus");
expect(preferences).toContain("promptForPendingDesktopUpdate");
expect(preferences).toContain("当前已是最新版本");
expect(desktopUpdateManager).not.toContain("当前构建未启用桌面端自动更新");
expect(desktopUpdateManager).not.toContain("该版本已选择稍后提醒");
expect(desktopUpdateManager).toContain("当前已是最新版本");
expect(desktopUpdateManager).toContain("桌面端更新检查失败,请稍后重试或联系管理员。");
expect(desktopLayout).toContain("desktopUpdateNoticeVisible");
expect(desktopLayout).toContain("listenDesktopUpdateStatus");
expect(desktopLayout).toContain("新版本 ${pending.version}");
});
it("routes user-facing file actions through feedback helpers", () => {
const helper = readFileTaskFeedbackSource();
const attachments = readAttachmentListSource();
const documentDetail = readDocumentDetailSource();
expect(helper).toContain("export const pickFilesWithFeedback");
expect(helper).toContain("export const saveFileWithFeedback");
expect(helper).toContain("export const openFileWithFeedback");
expect(helper).toContain("startDesktopActivity");
expect(helper).toContain("finishDesktopActivity");
expect(helper).toContain("desktopFileActivitiesEnabled");
expect(attachments).toContain("pickFilesWithFeedback");
expect(attachments).toContain("saveFileWithFeedback");
expect(attachments).toContain("openFileWithFeedback");
expect(attachments).toContain("startDesktopActivity");
expect(attachments).toContain('title: "下载附件"');
expect(attachments).toContain('title: "上传附件"');
expect(documentDetail).toContain("pickFilesWithFeedback");
expect(documentDetail).toContain("saveFileWithFeedback");
expect(documentDetail).toContain("openFileWithFeedback");
expect(attachments).not.toContain("openFile, pickFiles, saveFile");
expect(documentDetail).not.toContain("openFile, pickFiles, saveFile");
});
it("keeps attachment upload mode flat and responsive across web and desktop", () => {
const attachments = readAttachmentListSource();
expect(attachments).not.toContain('class="attachment-list-panel unified-shell"');
expect(attachments).toContain("'unified-shell': displayMode === 'table'");
expect(attachments).toContain("'attachment-list-panel--upload': displayMode === 'upload'");
expect(attachments).toContain("'upload-grid--single': uploadGroups.length === 1");
expect(attachments).toContain(".upload-grid--single {");
expect(attachments).toContain('class="pending-file-info"');
expect(attachments).toContain('class="pending-file-meta"');
});
it("surfaces persistent desktop activity feedback without local task persistence", () => {
const desktopLayout = readDesktopLayoutSource();
const activityCenter = readDesktopActivityCenterSource();
const updateManager = readDesktopUpdateManagerSource();
expect(desktopLayout).toContain('popper-class="desktop-activity-popover"');
expect(desktopLayout).toContain("desktopActivities");
expect(desktopLayout).toContain("listenDesktopActivities");
expect(desktopLayout).toContain("clearFinishedDesktopActivities");
expect(desktopLayout).toContain("desktopActivityBadgeCount");
expect(activityCenter).toContain("DESKTOP_ACTIVITY_CHANGED_EVENT");
expect(activityCenter).toContain("safeActivityText");
expect(activityCenter).toContain("unsafeActivityTextPattern");
expect(activityCenter).not.toContain("localStorage");
expect(activityCenter).not.toContain("sessionStorage");
expect(updateManager).toContain('title: "检查桌面更新"');
expect(updateManager).toContain('title: "安装桌面更新"');
expect(updateManager).toContain("finishUpdateActivity");
});
it("keeps desktop business pages dense and workbench-like", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop workbench density */");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.page");
expect(styles).toContain(".desktop-workbench .desktop-route-shell.ctms-page-shell");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-body");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-inner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .overview");
expect(styles).toContain("padding: 0 !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .table-card");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .hero-banner");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .faq-hero");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .page-bg-dots");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .module-placeholder-surface");
expect(styles).toContain("box-shadow: none !important;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table th.el-table__cell");
expect(styles).toContain("height: 34px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell");
expect(styles).toContain("padding: 7px 10px;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-radio-button__inner");
expect(styles).toContain("display: inline-flex;");
expect(styles).toContain("align-items: center;");
expect(styles).toContain("justify-content: center;");
expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body");
expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat");
});
it("keeps routed desktop pages stretched to the content boundary", () => {
const layout = readDesktopLayoutSource();
expect(layout).toContain(".desktop-toolbar {\n grid-row: 1;");
expect(layout).toContain(".workspace-tabs {\n grid-row: 2;");
expect(layout).toContain(".desktop-content {\n grid-row: 3;");
expect(layout).toContain(".desktop-route-shell {");
expect(layout).toContain("width: 100%;");
expect(layout).toContain("max-width: 100%;");
expect(layout).toContain("box-sizing: border-box;");
});
it("keeps authentication styles self-contained for desktop CSP", () => {
const sources = [
readMainStyleSource(),
readLoginSource(),
readRegisterSource(),
readForgotPasswordSource(),
].join("\n");
expect(sources).not.toContain("fonts.googleapis.com");
expect(sources).not.toContain("fonts.gstatic.com");
expect(sources).not.toMatch(/@import\s+url\(["']https?:\/\//);
});
it("renders Element Plus modals with desktop-native surfaces in the desktop runtime", () => {
const styles = readMainStyleSource();
expect(styles).toContain("/* Desktop runtime modal surfaces */");
expect(styles).toContain("body.is-desktop-runtime .el-overlay");
expect(styles).toContain("left: 0 !important;");
expect(styles).toContain("backdrop-filter: blur(12px) saturate(135%);");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-dialog");
expect(styles).toContain("body.is-desktop-runtime .el-overlay-message-box");
expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog),");
expect(styles).toContain("body.is-desktop-runtime :where(.el-dialog__body) {");
expect(styles).toContain("body.is-desktop-runtime .el-message-box");
expect(styles).toContain("border-radius: 12px;");
expect(styles).toContain("body.is-desktop-runtime .el-dialog__header");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__header");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body");
expect(styles).toContain("body.is-desktop-runtime .desktop-preferences-dialog .el-dialog__body {\n height: 700px;");
expect(styles).toContain("max-height: calc(100vh - 96px);");
expect(styles).toContain("padding: 0;");
expect(styles).toContain("body.is-desktop-runtime .el-message-box__btns");
expect(styles).toContain("body.is-desktop-runtime .dialog-fade-enter-from .el-dialog");
expect(styles).toContain("body.is-desktop-runtime .msgbox-fade-enter-from .el-message-box");
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .desktop-preferences-dialog');
expect(styles).toContain(':root[data-ctms-theme="dark"] body.is-desktop-runtime .el-dialog');
});
it("shares the medical-consult workbench UI across web and desktop shells", () => {
const faq = readFaqSource();
const faqList = readFaqListSource();
const projectOverview = readProjectOverviewSource();
const projectMilestones = readProjectMilestonesSource();
const webLayout = readWebLayoutSource();
expect(faq).toContain("medical-consult-page--workbench");
expect(faq).toContain('class="workbench-toolbar-meta"');
expect(faq).toContain('<PermissionAction action="faq.create">');
expect(faq).not.toContain("const isDesktop = isTauriRuntime();");
expect(faq).not.toContain("page-bg-dots");
expect(faq).not.toContain("list-toolbar");
expect(faq).toContain(".medical-consult-page--workbench .faq-workspace");
expect(faq).toContain("grid-template-columns: 236px minmax(0, 1fr);");
expect(faqList).toContain('class="faq-list-card faq-list-card--workbench"');
expect(faqList).toContain('height="100%"');
expect(faqList).not.toContain("const isDesktop = isTauriRuntime();");
expect(faqList).toContain(".faq-list-card--workbench .faq-table");
expect(projectOverview).toContain(":class=\"{ 'project-overview--desktop': isDesktop }\"");
expect(projectOverview).not.toContain("overview-summary-strip");
expect(projectOverview).not.toContain("const activeCenterCount");
expect(projectOverview).toContain("desktop-attention-section");
expect(projectOverview).not.toContain("项目关注");
expect(projectOverview).not.toContain("desktop-attention-head");
expect(projectOverview).toContain('class="overview-live-clock">数据 {{ overviewClockText }}</span>');
expect(projectOverview).toContain('v-else class="overview-updated-at">更新 {{ overviewUpdatedAtLabel }}</span>');
expect(projectOverview.indexOf("刷新")).toBeLessThan(projectOverview.indexOf('class="overview-live-clock"'));
expect(projectOverview.indexOf('class="overview-live-clock"')).toBeLessThan(projectOverview.indexOf('class="progress-legend"'));
expect(projectOverview).toContain("overviewClockTimer = window.setInterval");
expect(webLayout).not.toContain("headerClockText");
expect(webLayout).not.toContain('key: "updatedAt"');
expect(webLayout).not.toContain('class="header-center"');
expect(webLayout).not.toContain("headerProjectInfo");
expect(webLayout).not.toContain("fetchProjectOverview");
expect(webLayout).not.toContain("loadHeaderOverviewStats");
expect(webLayout).not.toContain("header-status-pill");
expect(webLayout).not.toContain("header-meta-item");
expect(projectMilestones).toContain('class="unified-action-bar milestone-action-bar"');
expect(projectMilestones).not.toContain('class="unified-action-bar bar--flush"');
expect(projectMilestones).not.toContain("sourceLabel");
expect(projectMilestones).not.toContain("dataSourceLabel");
expect(projectMilestones).not.toContain("updatedAtLabel");
expect(projectMilestones).toContain(".milestone-action-bar {\n min-height: 56px;\n box-sizing: border-box;");
expect(projectOverview).toContain("overview-workbench");
expect(projectOverview).toContain("enrollment-snapshot");
expect(projectOverview).toContain("desktop-attention-board");
expect(projectOverview).toContain("const stageStatusSummary");
expect(projectOverview).toContain("const activeStageItems");
expect(projectOverview).toContain("const attentionItems");
expect(projectOverview).toContain("grid-template-columns: minmax(620px, 1fr) minmax(300px, 360px);");
expect(projectOverview).toContain("max-height: min(360px, calc(100vh - 300px));");
expect(projectOverview).toContain("@media (max-width: 1240px)");
expect(projectOverview).toContain(".project-overview--desktop .overview-card");
expect(projectOverview).toContain(".project-overview--desktop .overview-card--enrollment");
expect(projectOverview).toContain(".project-overview--desktop :deep(.center-row)");
expect(projectOverview).toContain(".project-overview--desktop :deep(.stage-node)");
});
it("keeps desktop entry surfaces stable at the minimum window size", () => {
const login = readLoginSource();
const serverSettings = readDesktopServerSettingsSource();
const profile = readProfileSettingsSource();
const preferences = readDesktopPreferencesSource();
expect(login).toContain("@media (max-width: 1280px)");
expect(login).toContain("width: min(440px, 100%);");
expect(login).toContain("overflow-wrap: anywhere;");
expect(login).toContain("white-space: nowrap;");
expect(serverSettings).toContain("max-height: calc(100vh - 64px);");
expect(serverSettings).toContain("overflow: auto;");
expect(serverSettings).toContain(".diagnostic-grid code");
expect(profile).toContain('<div class="profile-layout">');
expect(profile).not.toContain('class="page"');
expect(profile).toContain("height: min(720px, calc(100vh - 64px));");
expect(profile).toContain("overflow: hidden;");
expect(profile).not.toContain("overflow: auto;");
expect(profile).toContain("overflow-wrap: anywhere;");
expect(profile).toContain(".dialog-close:hover");
expect(profile).toContain(".dialog-close :deep(.el-icon)");
expect(preferences).toContain("flex-wrap: wrap;");
expect(preferences).toContain("overflow-wrap: anywhere;");
});
});
+4 -2
View File
@@ -4,9 +4,11 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { isTauriRuntime } from "../runtime";
import DesktopLayout from "./DesktopLayout.vue";
import WebLayout from "./WebLayout.vue";
const DesktopLayout = defineAsyncComponent(() => import("./DesktopLayout.vue"));
const WebLayout = defineAsyncComponent(() => import("./WebLayout.vue"));
const isDesktop = isTauriRuntime();
</script>
@@ -0,0 +1,207 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { isProxy, reactive } from "vue";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
vi.mock("../runtime", () => ({
resolveOnlyOfficeHostUrl: () => new URL("https://ctms.example/onlyoffice-host.html"),
}));
import OnlyOfficeViewer from "./OnlyOfficeViewer.vue";
const dispatchHostMessage = (source: MessageEventSource | null, origin: string, data: Record<string, unknown>) => {
window.dispatchEvent(new MessageEvent("message", { source, origin, data }));
};
describe("OnlyOfficeViewer bridge", () => {
beforeEach(() => {
vi.useFakeTimers();
});
it("initializes when the host iframe finishes loading without waiting for host-ready", async () => {
const config = reactive({ token: "signed-config", document: { title: "原始文件名.xlsx" } });
expect(isProxy(config)).toBe(true);
const wrapper = mount(OnlyOfficeViewer, { props: { config } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(postMessage).toHaveBeenCalledOnce();
expect(postMessage.mock.calls[0][0]).toMatchObject({
type: "ctms.onlyoffice.init",
config: { token: "signed-config", document: { title: "原始文件名.xlsx" } },
});
expect(isProxy((postMessage.mock.calls[0][0] as { config: object }).config)).toBe(false);
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).toHaveBeenCalledOnce();
wrapper.unmount();
});
it("reports a host initialization error when the config cannot be posted", async () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-config" } } });
const frameWindow = {
postMessage: vi.fn(() => {
throw new DOMException("could not be cloned", "DataCloneError");
}),
} as unknown as Window;
const iframe = wrapper.get("iframe");
Object.defineProperty(iframe.element, "contentWindow", { configurable: true, value: frameWindow });
await iframe.trigger("load");
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_INIT_FAILED" });
wrapper.unmount();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("accepts one initialization from the exact host window and validates the nonce", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: { token: "signed-config" } } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).toHaveBeenCalledOnce();
const initMessage = postMessage.mock.calls[0][0] as { type: string; nonce: string; config: Record<string, unknown> };
expect(initMessage.type).toBe("ctms.onlyoffice.init");
expect(initMessage.config).toEqual({ token: "signed-config" });
expect(postMessage.mock.calls[0][1]).toBe("https://ctms.example");
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: "wrong",
});
expect(wrapper.emitted("ready")).toBeUndefined();
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.document-ready",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("ready")).toHaveLength(1);
wrapper.unmount();
});
it("rejects messages with the wrong origin or source", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://evil.example", { type: "ctms.onlyoffice.host-ready" });
dispatchHostMessage(window, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
expect(postMessage).not.toHaveBeenCalled();
wrapper.unmount();
});
it("enables clipboard only for the explicit collaboration editor mode", () => {
const preview = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(preview.get("iframe").attributes("allow")).toContain("'none'");
preview.unmount();
const editor = mount(OnlyOfficeViewer, { props: { config: {}, allowClipboard: true } });
expect(editor.get("iframe").attributes("allow")).toBe("clipboard-read; clipboard-write");
editor.unmount();
});
it("permits iframe downloads only when the document capability allows them", () => {
const restricted = mount(OnlyOfficeViewer, { props: { config: {} } });
expect(restricted.get("iframe").attributes("sandbox")).not.toContain("allow-downloads");
restricted.unmount();
const downloadable = mount(OnlyOfficeViewer, {
props: { config: {}, allowDownload: true },
});
expect(downloadable.get("iframe").attributes("sandbox")).toContain("allow-downloads");
downloadable.unmount();
});
it("reports host and document timeouts without falling back to a download", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
vi.advanceTimersByTime(15_000);
expect(wrapper.emitted("error")?.[0]?.[0]).toMatchObject({ code: "HOST_LOAD_TIMEOUT" });
wrapper.unmount();
});
it("forwards a nonce-bound edit-right request from ONLYOFFICE", () => {
const wrapper = mount(OnlyOfficeViewer, { props: { config: {} } });
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as { nonce: string };
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.request-edit-rights",
nonce: initMessage.nonce,
});
expect(wrapper.emitted("requestEditRights")).toHaveLength(1);
wrapper.unmount();
});
it("forwards independent Save As and download capabilities and accepts only nonce-bound file bytes", () => {
const wrapper = mount(OnlyOfficeViewer, {
props: { config: {}, allowSaveAs: true, allowDownload: true },
});
const postMessage = vi.fn();
const frameWindow = { postMessage } as unknown as Window;
Object.defineProperty(wrapper.get("iframe").element, "contentWindow", { configurable: true, value: frameWindow });
dispatchHostMessage(frameWindow, "https://ctms.example", { type: "ctms.onlyoffice.host-ready" });
const initMessage = postMessage.mock.calls[0][0] as {
nonce: string;
allowSaveAs: boolean;
allowDownload: boolean;
};
expect(initMessage.allowSaveAs).toBe(true);
expect(initMessage.allowDownload).toBe(true);
const data = new ArrayBuffer(8);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.save-as",
nonce: initMessage.nonce,
detail: { fileType: "xlsx", title: "副本.xlsx", mimeType: "application/octet-stream", data },
});
expect(wrapper.emitted("saveAs")?.[0]?.[0]).toMatchObject({ fileType: "xlsx", title: "副本.xlsx", data });
const downloadData = new ArrayBuffer(12);
dispatchHostMessage(frameWindow, "https://ctms.example", {
type: "ctms.onlyoffice.download",
nonce: initMessage.nonce,
detail: {
fileType: "xlsx",
title: "原始文件.xlsx",
mimeType: "application/octet-stream",
data: downloadData,
},
});
expect(wrapper.emitted("download")?.[0]?.[0]).toMatchObject({
fileType: "xlsx",
title: "原始文件.xlsx",
data: downloadData,
});
wrapper.unmount();
});
it("keeps Save As URLs inside the isolated host and only posts validated bytes to the parent", () => {
const hostSource = readFileSync(resolve(__dirname, "../../public/onlyoffice-host.js"), "utf8");
expect(hostSource).toContain("events.onRequestSaveAs = handleRequestSaveAs");
expect(hostSource).toContain("events.onDownloadAs = handleDownloadAs");
expect(hostSource).toContain('url.origin !== window.location.origin');
expect(hostSource).toContain('url.pathname.startsWith("/onlyoffice/")');
expect(hostSource).toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, mimeType, data }, [data])");
expect(hostSource).toContain("postToParent(MESSAGE.DOWNLOAD, { fileType, title, mimeType, data }, [data])");
expect(hostSource).not.toContain("postToParent(MESSAGE.SAVE_AS, { fileType, title, url");
});
});
@@ -0,0 +1,216 @@
<template>
<div class="onlyoffice-viewer">
<iframe
:key="nonce"
ref="frameRef"
class="onlyoffice-viewer__frame"
:src="hostUrl.href"
:title="frameTitle"
:sandbox="sandboxPolicy"
:allow="allowPolicy"
referrerpolicy="no-referrer"
@load="handleFrameLoad"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, toRaw } from "vue";
import { resolveOnlyOfficeHostUrl } from "../runtime";
import type { OnlyOfficeHostMessage, OnlyOfficeSaveAsPayload } from "../types/onlyoffice";
const HOST_READY = "ctms.onlyoffice.host-ready";
const INIT = "ctms.onlyoffice.init";
const DOCUMENT_READY = "ctms.onlyoffice.document-ready";
const WARNING = "ctms.onlyoffice.warning";
const ERROR = "ctms.onlyoffice.error";
const SAVE_AS = "ctms.onlyoffice.save-as";
const SAVE_AS_ERROR = "ctms.onlyoffice.save-as-error";
const DOWNLOAD = "ctms.onlyoffice.download";
const DOWNLOAD_ERROR = "ctms.onlyoffice.download-error";
const REQUEST_EDIT_RIGHTS = "ctms.onlyoffice.request-edit-rights";
const HOST_LOAD_TIMEOUT_MS = 15_000;
const DOCUMENT_LOAD_TIMEOUT_MS = 120_000;
const props = withDefaults(defineProps<{
config: Record<string, unknown>;
allowClipboard?: boolean;
allowSaveAs?: boolean;
allowDownload?: boolean;
frameTitle?: string;
}>(), {
allowClipboard: false,
allowSaveAs: false,
allowDownload: false,
frameTitle: "ONLYOFFICE 文档查看器",
});
const emit = defineEmits<{
ready: [];
warning: [detail: Record<string, unknown>];
error: [detail: Record<string, unknown>];
stateChange: [changed: boolean];
saveAs: [payload: OnlyOfficeSaveAsPayload];
saveAsError: [message: string];
download: [payload: OnlyOfficeSaveAsPayload];
downloadError: [message: string];
requestEditRights: [];
}>();
const frameRef = ref<HTMLIFrameElement | null>(null);
const hostUrl = resolveOnlyOfficeHostUrl();
const allowPolicy = props.allowClipboard
? "clipboard-read; clipboard-write"
: "clipboard-read 'none'; clipboard-write 'none'";
const sandboxPolicy = computed(() => [
"allow-scripts",
"allow-same-origin",
"allow-forms",
"allow-popups",
"allow-modals",
...(props.allowDownload ? ["allow-downloads"] : []),
].join(" "));
const createNonce = () => {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, "0")).join("");
};
const nonce = createNonce();
let initialized = false;
let hostTimer: number | undefined;
let documentTimer: number | undefined;
const clearTimers = () => {
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
hostTimer = undefined;
documentTimer = undefined;
};
const emitTimeout = (code: string, message: string) => {
clearTimers();
initialized = true;
emit("error", { code, message });
};
const initializeHost = () => {
if (initialized) return;
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow) return;
try {
// Vue 会将接口响应递归包装为 Proxy,而 Proxy 不能通过 structured clone
// 传递给 iframe。JSON 配置本身只包含 ONLYOFFICE 支持的 JSON 类型。
const config = JSON.parse(JSON.stringify(toRaw(props.config))) as Record<string, unknown>;
frameWindow.postMessage({
type: INIT,
nonce,
config,
allowSaveAs: props.allowSaveAs,
allowDownload: props.allowDownload,
}, hostUrl.origin);
} catch {
emitTimeout("HOST_INIT_FAILED", "预览配置初始化失败,请重新加载");
return;
}
initialized = true;
if (hostTimer !== undefined) window.clearTimeout(hostTimer);
hostTimer = undefined;
documentTimer = window.setTimeout(
() => emitTimeout("DOCUMENT_LOAD_TIMEOUT", "文档转换或加载超时,请稍后重试"),
DOCUMENT_LOAD_TIMEOUT_MS,
);
};
// iframe 的 load 事件发生在宿主页 defer 脚本完成执行之后,可以直接初始化。
// host-ready 仍作为慢加载和桌面 WebView 场景的兜底握手。
const handleFrameLoad = initializeHost;
const handleMessage = (event: MessageEvent<OnlyOfficeHostMessage>) => {
const frameWindow = frameRef.value?.contentWindow;
if (!frameWindow || event.source !== frameWindow || event.origin !== hostUrl.origin) return;
const message = event.data;
if (!message || typeof message !== "object" || typeof message.type !== "string") return;
if (message.type === HOST_READY && !initialized) {
initializeHost();
return;
}
if (message.nonce !== nonce) return;
if (message.type === DOCUMENT_READY) {
if (documentTimer !== undefined) window.clearTimeout(documentTimer);
documentTimer = undefined;
emit("ready");
} else if (message.type === WARNING) {
emit("warning", message.detail || {});
} else if (message.type === ERROR) {
clearTimers();
emit("error", message.detail || {});
} else if (message.type === "ctms.onlyoffice.document-state-change") {
emit("stateChange", Boolean(message.detail?.changed));
} else if (message.type === SAVE_AS) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("saveAsError", "另存为文件数据无效");
return;
}
emit("saveAs", { fileType, title, mimeType, data });
} else if (message.type === SAVE_AS_ERROR) {
emit("saveAsError", typeof message.detail?.message === "string" ? message.detail.message : "另存为失败");
} else if (message.type === DOWNLOAD) {
const detail = message.detail;
const fileType = typeof detail?.fileType === "string" ? detail.fileType : "";
const title = typeof detail?.title === "string" ? detail.title : "";
const mimeType = typeof detail?.mimeType === "string" ? detail.mimeType : undefined;
const data = detail?.data;
if (!/^[a-z0-9]{1,16}$/.test(fileType) || !title || !(data instanceof ArrayBuffer)) {
emit("downloadError", "下载文件数据无效");
return;
}
emit("download", { fileType, title, mimeType, data });
} else if (message.type === DOWNLOAD_ERROR) {
emit("downloadError", typeof message.detail?.message === "string" ? message.detail.message : "下载失败");
} else if (message.type === REQUEST_EDIT_RIGHTS) {
emit("requestEditRights");
}
};
onMounted(() => {
window.addEventListener("message", handleMessage);
hostTimer = window.setTimeout(
() => emitTimeout("HOST_LOAD_TIMEOUT", "预览服务加载超时,请检查服务状态后重试"),
HOST_LOAD_TIMEOUT_MS,
);
});
onBeforeUnmount(() => {
clearTimers();
window.removeEventListener("message", handleMessage);
if (frameRef.value) frameRef.value.src = "about:blank";
});
</script>
<style scoped>
.onlyoffice-viewer,
.onlyoffice-viewer__frame {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.onlyoffice-viewer {
overflow: hidden;
background: #f3f5f8;
}
.onlyoffice-viewer__frame {
display: block;
border: 0;
background: #fff;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
import { flushPromises, mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const getDocumentMock = vi.hoisted(() => vi.fn());
const renderMock = vi.hoisted(() => vi.fn());
const destroyMock = vi.hoisted(() => vi.fn());
vi.mock("pdfjs-dist", () => ({
GlobalWorkerOptions: { workerSrc: "" },
getDocument: getDocumentMock,
}));
import PdfViewer from "./PdfViewer.vue";
describe("PdfViewer", () => {
beforeEach(() => {
vi.clearAllMocks();
renderMock.mockReturnValue({ promise: Promise.resolve(), cancel: vi.fn() });
destroyMock.mockResolvedValue(undefined);
getDocumentMock.mockReturnValue({
promise: Promise.resolve({
numPages: 2,
getPage: vi.fn().mockResolvedValue({
getViewport: ({ scale }: { scale: number }) => ({ width: 600 * scale, height: 800 * scale }),
render: renderMock,
}),
destroy: destroyMock,
}),
destroy: destroyMock,
});
});
it("renders PDF pages to canvas without an iframe", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
await flushPromises();
expect(getDocumentMock).toHaveBeenCalledWith({ url: "blob:test-pdf" });
expect(wrapper.findAll("canvas")).toHaveLength(2);
expect(wrapper.find("iframe").exists()).toBe(false);
expect(renderMock).toHaveBeenCalled();
expect(wrapper.text()).toContain("/ 2");
await wrapper.get("button:nth-of-type(2)").trigger("click");
await flushPromises();
expect(wrapper.text()).toContain("/ 2");
wrapper.unmount();
await flushPromises();
expect(destroyMock).toHaveBeenCalled();
});
it("integrates the dialog close action into the viewer toolbar", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf", showClose: true } });
await flushPromises();
await wrapper.get('[aria-label="关闭预览"]').trigger("click");
expect(wrapper.emitted("close")).toHaveLength(1);
});
it("keeps the visible document center anchored while zooming", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
await flushPromises();
const viewport = wrapper.get(".pdf-viewer__viewport").element as HTMLElement;
Object.defineProperties(viewport, {
clientWidth: { configurable: true, value: 800 },
clientHeight: { configurable: true, value: 600 },
scrollWidth: {
configurable: true,
get: () => Math.max(800, Number.parseInt(wrapper.get("canvas").element.style.width, 10) || 0),
},
scrollHeight: {
configurable: true,
get: () => Math.max(600, Number.parseInt(wrapper.get("canvas").element.style.height, 10) || 0),
},
});
await wrapper.get("button.is-active").trigger("click");
await flushPromises();
await wrapper.get('[aria-label="放大"]').trigger("click");
await flushPromises();
expect(viewport.scrollLeft).toBeGreaterThan(0);
expect(viewport.scrollTop).toBeGreaterThan(0);
});
it("shows the original filename in the center of the toolbar", async () => {
const wrapper = mount(PdfViewer, {
props: { src: "blob:test-pdf", filename: "研究方案最终版.pdf" },
});
await flushPromises();
expect(wrapper.get(".pdf-viewer__filename").text()).toBe("研究方案最终版.pdf");
expect(wrapper.text()).not.toContain("页文档");
});
it("updates the current page while scrolling through the continuous document", async () => {
const wrapper = mount(PdfViewer, { props: { src: "blob:test-pdf" } });
await flushPromises();
const viewport = wrapper.get(".pdf-viewer__viewport");
const pages = wrapper.findAll(".pdf-viewer__page");
Object.defineProperties(viewport.element, {
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 850 },
});
Object.defineProperties(pages[0].element, {
offsetTop: { configurable: true, value: 0 },
offsetHeight: { configurable: true, value: 800 },
});
Object.defineProperties(pages[1].element, {
offsetTop: { configurable: true, value: 816 },
offsetHeight: { configurable: true, value: 800 },
});
await viewport.trigger("scroll");
expect((wrapper.get(".pdf-viewer__page-input").element as HTMLInputElement).value).toBe("2");
});
});
+835
View File
@@ -0,0 +1,835 @@
<template>
<section class="pdf-viewer" aria-label="PDF 预览器">
<header class="pdf-viewer__toolbar">
<div class="pdf-viewer__group" aria-label="翻页控制">
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber <= 1" aria-label="上一页" title="上一页" @click="previousPage">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 15-5-5 5-5" /></svg>
</button>
<label class="pdf-viewer__page-control">
<span class="sr-only">当前页</span>
<input
v-model.number="pageInput"
class="pdf-viewer__page-input"
type="number"
min="1"
:max="pageCount || 1"
:disabled="busy || !pageCount"
@change="commitPageInput"
@keyup.enter="commitPageInput"
/>
<span class="pdf-viewer__page-total">/ {{ pageCount || 0 }}</span>
</label>
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || pageNumber >= pageCount" aria-label="下一页" title="下一页" @click="nextPage">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 5 5 5-5 5" /></svg>
</button>
</div>
<div class="pdf-viewer__document-meta" :title="filename || 'PDF 文档'">
<span class="pdf-viewer__filename">{{ filename || "PDF 文档" }}</span>
</div>
<div class="pdf-viewer__actions">
<div class="pdf-viewer__group" aria-label="缩放控制">
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent <= MIN_ZOOM" aria-label="缩小" title="缩小" @click="zoomOut">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10" /></svg>
</button>
<span class="pdf-viewer__zoom">{{ renderedScalePercent }}%</span>
<button type="button" class="pdf-viewer__button pdf-viewer__button--icon" :disabled="busy || zoomPercent >= MAX_ZOOM" aria-label="放大" title="放大" @click="zoomIn">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10M10 5v10" /></svg>
</button>
<button type="button" class="pdf-viewer__button" :class="{ 'is-active': fitWidth }" :disabled="busy" @click="useFitWidth">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3.5 6v8M16.5 6v8M6.5 10h7M8.5 8l-2 2 2 2M11.5 8l2 2-2 2" /></svg>
适合宽度
</button>
</div>
<span v-if="showClose" class="pdf-viewer__divider" aria-hidden="true" />
<button v-if="showClose" type="button" class="pdf-viewer__button pdf-viewer__button--icon pdf-viewer__close" aria-label="关闭预览" title="关闭预览" @click="emit('close')">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m5 5 10 10M15 5 5 15" /></svg>
</button>
</div>
</header>
<div ref="viewportRef" class="pdf-viewer__viewport" @scroll.passive="handleViewportScroll">
<div v-if="errorMessage" class="pdf-viewer__state pdf-viewer__state--error" role="alert">
{{ errorMessage }}
</div>
<div v-else-if="loadingDocument" class="pdf-viewer__state" aria-live="polite">
<span class="pdf-viewer__spinner" aria-hidden="true" />
<span>正在加载 PDF</span>
</div>
<div v-else class="pdf-viewer__pages">
<article
v-for="pageIndex in pageCount"
:key="pageIndex"
:ref="(element) => setPageElement(element, pageIndex)"
class="pdf-viewer__page"
:class="{ 'is-rendering': renderingPages.has(pageIndex) }"
:data-page-number="pageIndex"
>
<canvas
:ref="(element) => setCanvasElement(element, pageIndex)"
class="pdf-viewer__canvas"
:aria-label="`PDF ${pageIndex} `"
/>
<div v-if="renderingPages.has(pageIndex)" class="pdf-viewer__rendering" aria-live="polite">
<span class="pdf-viewer__spinner pdf-viewer__spinner--small" aria-hidden="true" />
正在渲染第 {{ pageIndex }}
</div>
</article>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
getDocument,
GlobalWorkerOptions,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
type PDFPageProxy,
type RenderTask,
} from "pdfjs-dist";
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
const MIN_ZOOM = 25;
const MAX_ZOOM = 300;
const ZOOM_STEP = 25;
const props = defineProps<{
src: string;
filename?: string;
showClose?: boolean;
}>();
const emit = defineEmits<{
close: [];
}>();
const viewportRef = ref<HTMLElement | null>(null);
const loadingDocument = ref(false);
const renderingPages = ref(new Set<number>());
const errorMessage = ref("");
const pageNumber = ref(1);
const pageInput = ref(1);
const pageCount = ref(0);
const zoomPercent = ref(100);
const renderedScalePercent = ref(100);
const fitWidth = ref(true);
let loadingTask: PDFDocumentLoadingTask | null = null;
let pdfDocument: PDFDocumentProxy | null = null;
const renderTasks = new Map<number, RenderTask>();
const canvasElements = new Map<number, HTMLCanvasElement>();
const pageElements = new Map<number, HTMLElement>();
const pageScales = new Map<number, number>();
const pageLayouts = new Map<number, {
pageIndex: number;
page: PDFPageProxy;
canvas: HTMLCanvasElement;
viewport: ReturnType<PDFPageProxy["getViewport"]>;
outputScale: number;
}>();
const renderedPages = new Set<number>();
let loadSequence = 0;
let renderSequence = 0;
let resizeObserver: ResizeObserver | null = null;
let resizeFrame = 0;
const busy = computed(() => loadingDocument.value || !pdfDocument);
const clamp = (value: number, minimum: number, maximum: number) => Math.min(maximum, Math.max(minimum, value));
const describePdfError = (error: unknown) => {
const name = error instanceof Error ? error.name : "";
if (name === "PasswordException") return "该 PDF 已加密,暂时无法在线预览";
if (name === "InvalidPDFException") return "PDF 文件格式无效或已损坏";
return "PDF 加载失败,请下载后查看";
};
const setCanvasElement = (element: unknown, pageIndex: number) => {
if (element instanceof HTMLCanvasElement) canvasElements.set(pageIndex, element);
else canvasElements.delete(pageIndex);
};
const setPageElement = (element: unknown, pageIndex: number) => {
if (element instanceof HTMLElement) pageElements.set(pageIndex, element);
else pageElements.delete(pageIndex);
};
const cancelRenderTasks = () => {
renderTasks.forEach((task) => task.cancel());
renderTasks.clear();
};
const updateRenderingPage = (pageIndex: number, rendering: boolean) => {
const nextPages = new Set(renderingPages.value);
if (rendering) nextPages.add(pageIndex);
else nextPages.delete(pageIndex);
renderingPages.value = nextPages;
};
const updateCurrentPage = (value: number) => {
const nextPage = clamp(value, 1, pageCount.value || 1);
pageNumber.value = nextPage;
pageInput.value = nextPage;
const scale = pageScales.get(nextPage);
if (scale) renderedScalePercent.value = Math.round(scale * 100);
};
const renderPage = async (pageIndex: number, sequence: number) => {
if (sequence !== renderSequence || renderedPages.has(pageIndex) || renderTasks.has(pageIndex)) return;
const layout = pageLayouts.get(pageIndex);
if (!layout) return;
const { page, canvas, viewport, outputScale } = layout;
canvas.width = Math.floor(viewport.width * outputScale);
canvas.height = Math.floor(viewport.height * outputScale);
updateRenderingPage(pageIndex, true);
const task = page.render({
canvas,
viewport,
background: "#ffffff",
transform: outputScale === 1 ? undefined : [outputScale, 0, 0, outputScale, 0, 0],
});
renderTasks.set(pageIndex, task);
try {
await task.promise;
if (sequence === renderSequence) renderedPages.add(pageIndex);
} catch (error) {
if (sequence === renderSequence && !(error instanceof Error && error.name === "RenderingCancelledException")) {
errorMessage.value = describePdfError(error);
}
} finally {
if (renderTasks.get(pageIndex) === task) {
renderTasks.delete(pageIndex);
if (sequence === renderSequence) updateRenderingPage(pageIndex, false);
}
}
};
const renderVisiblePages = async (sequence = renderSequence) => {
const viewport = viewportRef.value;
if (!viewport || sequence !== renderSequence) return;
const viewportHeight = Math.max(viewport.clientHeight, 1);
const renderTop = Math.max(0, viewport.scrollTop - viewportHeight);
const renderBottom = viewport.scrollTop + viewportHeight * 2;
const retainTop = Math.max(0, viewport.scrollTop - viewportHeight * 3);
const retainBottom = viewport.scrollTop + viewportHeight * 4;
const pagesToRender: number[] = [];
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
const pageElement = pageElements.get(pageIndex);
if (!pageElement) continue;
const pageTop = pageElement.offsetTop;
const pageBottom = pageTop + pageElement.offsetHeight;
if (pageBottom >= renderTop && pageTop <= renderBottom) pagesToRender.push(pageIndex);
if (renderedPages.has(pageIndex) && (pageBottom < retainTop || pageTop > retainBottom)) {
const canvas = canvasElements.get(pageIndex);
if (canvas) {
canvas.width = 1;
canvas.height = 1;
}
renderedPages.delete(pageIndex);
}
}
await Promise.all(pagesToRender.map((pageIndex) => renderPage(pageIndex, sequence)));
};
const renderAllPages = async (preservePosition = true) => {
const sequence = ++renderSequence;
const document = pdfDocument;
const viewportElement = viewportRef.value;
if (!document || !viewportElement || errorMessage.value) return;
cancelRenderTasks();
renderingPages.value = new Set();
renderedPages.clear();
pageLayouts.clear();
try {
const previousScrollWidth = Math.max(viewportElement.scrollWidth, viewportElement.clientWidth);
const previousScrollHeight = Math.max(viewportElement.scrollHeight, viewportElement.clientHeight);
const horizontalAnchor = (viewportElement.scrollLeft + viewportElement.clientWidth / 2) / Math.max(previousScrollWidth, 1);
const verticalAnchor = (viewportElement.scrollTop + viewportElement.clientHeight / 2) / Math.max(previousScrollHeight, 1);
const pageNumbers = Array.from({ length: pageCount.value }, (_, index) => index + 1);
const pages = await Promise.all(pageNumbers.map((pageIndex) => document.getPage(pageIndex)));
if (sequence !== renderSequence) return;
const availableWidth = Math.max(240, viewportElement.clientWidth - 2);
const outputScale = Math.min(window.devicePixelRatio || 1, 2);
pages.forEach((page, index) => {
const pageIndex = index + 1;
const canvas = canvasElements.get(pageIndex);
if (!canvas) return;
const unscaledViewport = page.getViewport({ scale: 1 });
const scale = fitWidth.value
? clamp(availableWidth / unscaledViewport.width, MIN_ZOOM / 100, MAX_ZOOM / 100)
: zoomPercent.value / 100;
const viewport = page.getViewport({ scale });
canvas.width = 1;
canvas.height = 1;
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
pageScales.set(pageIndex, scale);
pageLayouts.set(pageIndex, { pageIndex, page, canvas, viewport, outputScale });
});
const currentScale = pageScales.get(pageNumber.value);
if (currentScale) renderedScalePercent.value = Math.round(currentScale * 100);
await nextTick();
if (preservePosition) {
viewportElement.scrollLeft = Math.max(0, horizontalAnchor * viewportElement.scrollWidth - viewportElement.clientWidth / 2);
viewportElement.scrollTop = Math.max(0, verticalAnchor * viewportElement.scrollHeight - viewportElement.clientHeight / 2);
} else {
viewportElement.scrollLeft = 0;
viewportElement.scrollTop = 0;
}
await renderVisiblePages(sequence);
} catch (error) {
if (!(error instanceof Error && error.name === "RenderingCancelledException")) {
errorMessage.value = describePdfError(error);
}
} finally {
if (sequence === renderSequence) {
renderTasks.clear();
renderingPages.value = new Set();
}
}
};
const disposeDocument = async () => {
renderSequence += 1;
cancelRenderTasks();
renderingPages.value = new Set();
canvasElements.clear();
pageElements.clear();
pageScales.clear();
pageLayouts.clear();
renderedPages.clear();
const task = loadingTask;
loadingTask = null;
pdfDocument = null;
await task?.destroy().catch(() => {});
};
const loadPdf = async () => {
const sequence = ++loadSequence;
await disposeDocument();
errorMessage.value = "";
pageCount.value = 0;
pageNumber.value = 1;
pageInput.value = 1;
loadingDocument.value = true;
if (!props.src) {
loadingDocument.value = false;
return;
}
try {
const task = getDocument({ url: props.src });
loadingTask = task;
const document = await task.promise;
if (sequence !== loadSequence) {
await task.destroy();
return;
}
pdfDocument = document;
pageCount.value = document.numPages;
loadingDocument.value = false;
await nextTick();
await renderAllPages(false);
} catch (error) {
if (sequence === loadSequence) errorMessage.value = describePdfError(error);
} finally {
if (sequence === loadSequence) loadingDocument.value = false;
}
};
const scrollToPage = (value: number, behavior: ScrollBehavior = "smooth") => {
const nextPage = clamp(value, 1, pageCount.value || 1);
updateCurrentPage(nextPage);
const viewport = viewportRef.value;
const page = pageElements.get(nextPage);
if (!viewport || !page) return;
if (typeof viewport.scrollTo === "function") viewport.scrollTo({ top: page.offsetTop, behavior });
else viewport.scrollTop = page.offsetTop;
};
const previousPage = () => scrollToPage(pageNumber.value - 1);
const nextPage = () => scrollToPage(pageNumber.value + 1);
const handleViewportScroll = () => {
const viewport = viewportRef.value;
if (!viewport || !pageCount.value) return;
const readingLine = viewport.scrollTop + viewport.clientHeight / 2;
let visiblePage = pageCount.value;
for (let pageIndex = 1; pageIndex <= pageCount.value; pageIndex += 1) {
const page = pageElements.get(pageIndex);
if (!page) continue;
const pageBottom = page.offsetTop + page.offsetHeight + 8;
if (readingLine < pageBottom) {
visiblePage = pageIndex;
break;
}
}
updateCurrentPage(visiblePage);
void renderVisiblePages();
};
const commitPageInput = () => {
scrollToPage(Number(pageInput.value) || 1);
};
const zoomOut = () => {
fitWidth.value = false;
zoomPercent.value = clamp(renderedScalePercent.value - ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
};
const zoomIn = () => {
fitWidth.value = false;
zoomPercent.value = clamp(renderedScalePercent.value + ZOOM_STEP, MIN_ZOOM, MAX_ZOOM);
};
const useFitWidth = () => {
fitWidth.value = true;
void renderAllPages(true);
};
watch(() => props.src, () => void loadPdf(), { immediate: true });
watch(zoomPercent, () => {
if (!fitWidth.value) void renderAllPages(true);
});
onMounted(() => {
if (typeof ResizeObserver === "undefined" || !viewportRef.value) return;
resizeObserver = new ResizeObserver(() => {
if (!fitWidth.value) return;
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(() => void renderAllPages(true));
});
resizeObserver.observe(viewportRef.value);
});
onBeforeUnmount(() => {
loadSequence += 1;
window.cancelAnimationFrame(resizeFrame);
resizeObserver?.disconnect();
void disposeDocument();
});
</script>
<style scoped>
.pdf-viewer {
display: flex;
min-height: 0;
height: 100%;
flex-direction: column;
overflow: hidden;
border: 0;
border-radius: 0;
background: #e8edf2;
}
.pdf-viewer__toolbar {
position: relative;
z-index: 2;
display: grid;
min-height: 52px;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
padding: 7px 12px;
border-bottom: 1px solid #dce2e8;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(12px);
}
.pdf-viewer__group {
display: flex;
align-items: center;
justify-self: start;
gap: 3px;
}
.pdf-viewer__actions {
display: flex;
align-items: center;
justify-self: end;
gap: 6px;
}
.pdf-viewer__divider {
width: 1px;
height: 24px;
margin-left: 2px;
background: #e1e6eb;
}
.pdf-viewer__page-control {
display: flex;
align-items: center;
gap: 7px;
margin: 0 4px;
color: #66717e;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.pdf-viewer__button {
display: inline-flex;
min-height: 32px;
align-items: center;
justify-content: center;
gap: 6px;
padding: 5px 10px;
border: 0;
border-radius: 6px;
color: #43505e;
background: transparent;
font: inherit;
font-size: 13px;
line-height: 1;
cursor: pointer;
transition: color 0.16s ease, background-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.pdf-viewer__button--icon {
width: 32px;
padding: 0;
}
.pdf-viewer__button svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.pdf-viewer__button:hover:not(:disabled) {
color: #2f536d;
background: #e8f0f5;
}
.pdf-viewer__button.is-active {
color: #365d77;
background: #e5eef4;
box-shadow: inset 0 0 0 1px rgba(69, 103, 127, 0.12);
}
.pdf-viewer__button:active:not(:disabled) {
transform: translateY(1px);
}
.pdf-viewer__button:focus-visible,
.pdf-viewer__page-input:focus-visible {
outline: 2px solid rgba(64, 111, 145, 0.35);
outline-offset: 1px;
}
.pdf-viewer__button:disabled {
cursor: not-allowed;
opacity: 0.36;
}
.pdf-viewer__page-input {
width: 42px;
height: 28px;
box-sizing: border-box;
border: 1px solid #d7dde4;
border-radius: 6px;
color: #25313d;
background: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
text-align: center;
appearance: textfield;
}
.pdf-viewer__page-input::-webkit-inner-spin-button,
.pdf-viewer__page-input::-webkit-outer-spin-button {
margin: 0;
appearance: none;
}
.pdf-viewer__page-total {
min-width: 26px;
}
.pdf-viewer__document-meta {
min-width: 0;
max-width: min(38vw, 460px);
justify-self: center;
color: #536171;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.pdf-viewer__filename {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.pdf-viewer__zoom {
min-width: 52px;
color: #42505d;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
text-align: center;
}
.pdf-viewer__close {
color: #7a8693;
}
.pdf-viewer__close:hover:not(:disabled) {
color: #344252;
background: #eef2f5;
}
.pdf-viewer__viewport {
position: relative;
min-height: 0;
flex: 1;
overflow: auto;
padding: 0;
background-color: #e7ebf0;
background-image: radial-gradient(rgba(92, 106, 121, 0.12) 0.7px, transparent 0.7px);
background-size: 12px 12px;
scrollbar-color: #aeb7c1 transparent;
scrollbar-width: thin;
}
.pdf-viewer__viewport::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.pdf-viewer__viewport::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: #aeb7c1;
background-clip: padding-box;
}
.pdf-viewer__viewport::-webkit-scrollbar-corner {
background: transparent;
}
.pdf-viewer__pages {
width: max-content;
min-width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pdf-viewer__page {
position: relative;
width: max-content;
flex: 0 0 auto;
}
.pdf-viewer__canvas {
display: block;
border: 1px solid rgba(116, 127, 140, 0.2);
background: #fff;
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.08),
0 12px 34px rgba(42, 51, 62, 0.2);
transition: opacity 0.18s ease;
}
.pdf-viewer__page.is-rendering .pdf-viewer__canvas {
opacity: 0.76;
}
.pdf-viewer__rendering {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 7px;
padding: 7px 12px;
border-radius: 999px;
color: #fff;
background: rgba(41, 52, 64, 0.82);
box-shadow: 0 5px 16px rgba(15, 23, 42, 0.18);
backdrop-filter: blur(8px);
font-size: 12px;
}
.pdf-viewer__state {
display: flex;
min-height: 100%;
align-items: center;
justify-content: center;
gap: 10px;
color: #6b7280;
font-size: 14px;
}
.pdf-viewer__state--error {
color: #c45656;
}
.pdf-viewer__spinner {
width: 19px;
height: 19px;
box-sizing: border-box;
border: 2px solid rgba(69, 103, 127, 0.2);
border-top-color: #45677f;
border-radius: 50%;
animation: pdf-viewer-spin 0.8s linear infinite;
}
.pdf-viewer__spinner--small {
width: 13px;
height: 13px;
border-color: rgba(255, 255, 255, 0.32);
border-top-color: #fff;
}
@keyframes pdf-viewer-spin {
to { transform: rotate(360deg); }
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 760px) {
.pdf-viewer {
min-height: 0;
height: 100%;
}
.pdf-viewer__toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 7px;
padding: 8px;
}
.pdf-viewer__document-meta {
display: none;
}
}
</style>
<style>
.pdf-preview-dialog.el-dialog {
overflow: hidden;
padding: 0;
border: 1px solid rgba(205, 214, 223, 0.8);
border-radius: 16px;
background: #f8fafb;
box-shadow: 0 24px 72px rgba(15, 23, 42, 0.24);
}
.pdf-preview-dialog .el-dialog__header {
margin: 0;
padding: 19px 24px 17px;
border-bottom: 1px solid #e6ebef;
background: #fff;
}
.pdf-preview-dialog .el-dialog__title {
color: #202c38;
font-size: 18px;
font-weight: 650;
letter-spacing: -0.01em;
}
.pdf-preview-dialog .el-dialog__headerbtn {
top: 7px;
right: 8px;
width: 44px;
height: 44px;
border-radius: 9px;
transition: background-color 0.16s ease;
}
.pdf-preview-dialog .el-dialog__headerbtn:hover {
background: #f1f4f7;
}
.pdf-preview-dialog .el-dialog__close {
color: #7a8693;
font-size: 20px;
}
.pdf-preview-dialog .el-dialog__body {
padding: 18px 20px 22px;
}
.pdf-preview-dialog--flush.el-dialog {
height: min(84dvh, 880px);
margin-bottom: 0;
background: #e7ebf0;
}
.pdf-preview-dialog--flush .el-dialog__header {
display: none;
}
.pdf-preview-dialog--flush .el-dialog__body {
height: 100%;
box-sizing: border-box;
padding: 0;
}
.pdf-preview-dialog--flush .preview-body,
.pdf-preview-dialog--flush .pdf-viewer {
height: 100%;
min-height: 0;
}
@media (max-width: 760px) {
.pdf-preview-dialog--flush.el-dialog {
width: calc(100vw - 16px) !important;
height: calc(100dvh - 16px);
margin-top: 8px;
}
.pdf-preview-dialog .el-dialog__header {
padding: 16px 18px 14px;
}
.pdf-preview-dialog .el-dialog__body {
padding: 12px;
}
.pdf-preview-dialog--flush .el-dialog__body {
padding: 0;
}
}
</style>
@@ -15,18 +15,38 @@ describe("PermissionAccessLogs", () => {
expect(source).toContain('parts.join(" / ")');
});
it("renders user behavior audit instead of endpoint table or region detail", () => {
it("renders access log audit with IP ranking as a dialog instead of side panels", () => {
const source = readSource();
expect(source).toContain("访问日志");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessDialogLines");
expect(source).toContain("log-surface");
expect(source).toContain("access-table");
expect(source).toContain("filters.keyword");
expect(source).toContain("clientSourceLabel");
expect(source).toContain("IP访问排行");
expect(source).toContain("接口访问审计日志");
expect(source).toContain("terminal-lines");
expect(source).toContain("keyword");
expect(source).toContain("ipRankingDialogVisible");
expect(source).toContain("openIpRankingDialog");
expect(source).toContain('<TimeRangeSelector v-model="ipRankingPeriod"');
expect(source).toContain('class="access-toolbar"');
expect(source).toContain('class="access-toolbar-actions"');
expect(source).toContain('@click="loadData">刷新');
expect(source).not.toContain("来自 permission_access_logs");
expect(source).not.toContain("安全访问只记录匿名、无效令牌和异常状态请求");
expect(source).not.toContain("audit-ranking-section");
expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("log-mode-card");
expect(source).not.toContain("raw-log-panel");
expect(source).not.toContain("终端式访问流水");
expect(source).not.toContain("每行保留接口访问情况,便于快速扫日志");
expect(source).not.toContain("来源地域明细");
expect(source).not.toContain('prop="endpoint_key" label="接口"');
expect(source).not.toContain("fetchIpLocations");
expect(source).toContain("访问日志加载失败,表格中可能是上次成功获取的数据");
expect(source).toContain("lastUpdatedAt");
expect(source).not.toContain("logs.value = []");
});
it("exposes QA and CTA in role filter presets", () => {
@@ -47,24 +67,43 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain('label="医学审核" value="QA"');
});
it("uses narrow metric cards without helper subtitles", () => {
it("integrates compact metrics into the access overview without helper subtitles", () => {
const source = readSource();
expect(source).toContain("min-height: 64px");
expect(source).toContain("padding: 10px 16px");
expect(source).toContain('class="access-overview"');
expect(source).toContain('aria-label="访问指标"');
expect(source).toContain("min-height: 44px");
expect(source).toContain("padding: 5px 12px");
expect(source).toContain("border-left: 1px solid #edf1f6");
expect(source).not.toContain("<small>{{ card.hint }}</small>");
expect(source).not.toContain("hint:");
expect(source).not.toContain("筛选范围内行为总量");
});
it("keeps audit grid widths consistent with SecurityCenter", () => {
it("uses a full-width log layout instead of the old left-right split", () => {
const source = readSource();
expect(source).toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
expect(source).toContain("access-log-card");
expect(source).toContain("log-surface");
expect(source).toContain("@media (max-width: 1100px)");
expect(source).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
expect(source).toContain("@media (max-width: 720px)");
expect(source).toContain("grid-template-columns: 1fr;");
expect(source).not.toContain("audit-ranking-section");
expect(source).not.toContain("grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));");
expect(source).not.toContain("audit-grid");
expect(source).not.toContain("grid-template-columns: minmax(320px, 1fr) minmax(0, 1.35fr);");
});
it("keeps the access log table edge-to-edge without removing filter or pagination spacing", () => {
const source = readSource();
expect(source).toMatch(/\.access-log-card \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
expect(source).toMatch(/\.audit-filters \{[\s\S]*?padding: 10px 12px;[\s\S]*?margin-bottom: 0;/);
expect(source).toMatch(/\.table-shell \{[\s\S]*?border: 0;[\s\S]*?border-radius: 0;/);
expect(source).toMatch(/\.logs-pagination \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
expect(source).toContain(".access-table :deep(.el-scrollbar__wrap)");
expect(source).toContain(".access-table :deep(.el-scrollbar__bar.is-horizontal)");
});
it("removes the duplicated audit hero copy", () => {
@@ -78,117 +117,259 @@ describe("PermissionAccessLogs", () => {
expect(source).not.toContain("查看详细的接口访问与安全事件记录");
});
it("streams terminal logs from top to bottom and refreshes in realtime", () => {
it("keeps structured access rows compact and delegates long fields to details", () => {
const source = readSource();
expect(source).toContain("terminalLogRows");
expect(source).toContain("new Date(a.created_at).getTime() - new Date(b.created_at).getTime()");
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
expect(source).toContain("startRealtimePolling");
expect(source).toContain("onBeforeUnmount(stopRealtimePolling)");
expect(source).toContain('ref="terminalWindowRef"');
expect(source).toContain("scrollTerminalToBottom");
expect(source).toContain("terminal.scrollTop = terminal.scrollHeight");
expect(source).toContain('label="时间" width="160"');
expect(source).toContain('label="IP" width="120"');
expect(source).toContain('label="位置" width="110"');
expect(source).toContain('label="账号" min-width="160"');
expect(source).toContain('label="请求" min-width="380"');
expect(source).toContain('label="耗时" width="100"');
expect(source).toContain('label="操作" width="64"');
expect(source).toContain("account-cell");
expect(source).toContain("request-cell");
expect(source).toContain("elapsed-cell");
expect(source).toContain("accountSecondary(row)");
expect(source).toContain("requestMain(row)");
expect(source).toContain("requestSub(row)");
expect(source).toContain("row.elapsed_ms.toFixed(1)");
expect(source).not.toContain('label="账号/IP"');
expect(source).not.toContain('label="类型" width="88"');
expect(source).not.toContain('label="来源" width="132"');
expect(source).not.toContain('label="接口/角色"');
expect(source).not.toContain('label="状态" width="104"');
expect(source).not.toContain('label="账号" min-width="180"');
expect(source).not.toContain('label="请求来源"');
expect(source).not.toContain('label="结果" width="92"');
});
it("surfaces source IPs in terminal logs and user ranking for network monitoring", () => {
it("opens all raw access logs in the same dialog pattern as security logs", () => {
const source = readSource();
expect(source).toContain("buildTerminalLine");
expect(source).toContain("REALTIME_POLL_INTERVAL_MS");
expect(source).toContain("startRealtimePolling");
expect(source).toContain("document.addEventListener(\"visibilitychange\", onVisibilityChange)");
expect(source).toContain("document.removeEventListener(\"visibilitychange\", onVisibilityChange)");
expect(source).toContain("stopTimers()");
expect(source).toContain("const REALTIME_POLL_INTERVAL_MS = 30_000;");
expect(source).toContain('const timeRangePreset = ref<AccessTimeRangePreset>("24h")');
expect(source).toContain("权限趋势下钻{{ drilldownRangeLabel");
expect(source).toContain("const applyTrendFilter = async");
expect(source).toContain('params.event_type = "permission"');
expect(source).toContain("defineExpose({ refresh, applyTrendFilter })");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("accessTerminalWindowRef");
expect(source).toContain("accessDialogLines.join");
expect(source).toContain("openAccessLogDialog");
expect(source).toContain("scrollAccessTerminalToBottom");
expect(source).toContain("fetchAllRawAccessLogLines");
expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
expect(source).not.toContain('ref="terminalWindowRef"');
expect(source).not.toContain("scrollTerminalToBottom");
});
it("surfaces source IPs in table, terminal logs and the IP ranking dialog", () => {
const source = readSource();
expect(source).toContain("ip=${ip}");
expect(source).toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).toContain("ipRankingItems");
expect(source).toContain('label="IP" width="120"');
expect(source).toContain('label="位置" width="110"');
expect(source).toContain("formatIpLocation(row)");
expect(source).toContain("row.ip_address || \"未知 IP\"");
expect(source).toContain("IP访问排行");
expect(source).toContain("账号");
expect(source).toContain("rank-location");
expect(source).toContain("loadIpRankingData");
expect(source).toContain("fetchAccessRowsForIpRanking");
expect(source).toContain("buildIpRankingRows");
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("ip-rank-grid");
expect(source).toContain("ip-rank-card");
expect(source).toContain("rank-card-stats");
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(source).toContain("rank-no");
expect(source).toContain("riskDescriptorForRow");
expect(source).toContain("row.riskLabel");
expect(source).toContain("row.riskTagType");
expect(source).toContain("riskRankCardClass");
expect(source).toContain("risk-rank-card--danger");
expect(source).toContain("risk-rank-card--warning");
expect(source).toContain("risk-rank-card--info");
expect(source).toContain("权限拒绝");
expect(source).toContain("敏感探测");
expect(source).toContain("TOP {{ ipRankingRows.length }}");
expect(source).toContain("ranking-period-cards");
expect(source).toContain("ranking-period-card");
expect(source).toContain("selectIpRankingPeriod");
expect(source).toContain("全部");
expect(source).toContain("当前时段暂无风险访问");
expect(source).not.toContain("ranking-period-group");
expect(source).not.toContain("ipLine(row)");
expect(source).not.toContain("user.sample_ip_address || \"未知 IP\"");
expect(source).not.toContain("ipRankingItems");
expect(source).not.toContain("ip-rank-row");
expect(source).not.toContain("rank-location");
expect(source).not.toContain('class="rank-risk-tag">异常IP');
expect(source).not.toContain("TOP {{ userStats.length }}");
expect(source).not.toContain("rank-ip-line");
expect(source).not.toContain("来源IP");
expect(source).not.toContain("去重IP");
});
it("merges abnormal security IPs into the IP ranking and limits the list to top 10", () => {
it("includes security events in the unified access log without a separate security dialog", () => {
const source = readSource();
expect(source).toContain("const IP_RANKING_LIMIT = 10;");
expect(source).toContain("const SECURITY_RANKING_PAGE_SIZE = 200;");
expect(source).toContain("const ipRankingItems = computed<IpRankingItem[]>(() =>");
expect(source).toContain("securityLogs.value.forEach((event) =>");
expect(source).toContain('const isAbnormal = event.category === "ABNORMAL_IP";');
expect(source).toContain('ABNORMAL_IP: "异常IP"');
expect(source).toContain(".slice(0, IP_RANKING_LIMIT)");
expect(source).toContain("TOP {{ ipRankingItems.length }}");
expect(source).toContain('v-for="(item, index) in ipRankingItems"');
expect(source).toContain("异常IP</el-tag>");
});
it("sorts IP ranking by overall access total before risk labels", () => {
const source = readSource();
expect(source).toContain("existing.total += user.total_count;");
expect(source).toContain("existing.riskCount += user.denied_count;");
expect(source).toContain("if (b.total !== a.total) return b.total - a.total;");
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
source.indexOf("if (b.riskCount !== a.riskCount) return b.riskCount - a.riskCount;"),
);
expect(source.indexOf("if (b.total !== a.total) return b.total - a.total;")).toBeLessThan(
source.indexOf("if (b.isAbnormal !== a.isAbnormal) return Number(b.isAbnormal) - Number(a.isAbnormal);"),
);
});
it("loads all security pages for complete abnormal IP ranking data", () => {
const source = readSource();
expect(source).toContain("const first = await fetchSecurityAccessLogs({ status_min: 400, page: 1, page_size: SECURITY_RANKING_PAGE_SIZE });");
expect(source).toContain("const totalPages = Math.ceil(first.data.total / SECURITY_RANKING_PAGE_SIZE);");
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
expect(source).toContain("allItems.push(...res.data.items);");
expect(source).toContain("securityLogs.value = allItems;");
expect(source).not.toContain("page_size: 80");
});
it("renders low-level security access logs for anonymous and invalid requests", () => {
const source = readSource();
expect(source).toContain("showSecurityLog");
expect(source).toContain('v-if="showSecurityLog"');
expect(source).toContain("fetchSecurityAccessLogs");
expect(source).toContain("安全事件审计日志");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("auth_status");
expect(source).toContain("account_label");
expect(source).toContain("client_ip");
expect(source).toContain("status_code");
expect(source).toContain("ANONYMOUS");
expect(source).toContain("INVALID_TOKEN");
expect(source).toContain("isSecurityLog");
expect(source).toContain("安全事件");
expect(source).toContain("业务访问");
expect(source).toContain("SECURITY_CATEGORY_LABELS");
expect(source).toContain("SECURITY_SEVERITY_LABELS");
expect(source).toContain("endpointTitle(row)");
expect(source).toContain("endpointMeta(row)");
expect(source).toContain("accessResultLabel(row)");
expect(source).toContain("authStatusLabel(row.auth_status)");
expect(source).toContain("[SECURITY:");
expect(source).not.toContain("showSecurityLog");
expect(source).not.toContain('v-if="showSecurityLog"');
expect(source).not.toContain("fetchSecurityAccessLogs");
expect(source).not.toContain("安全访问日志");
expect(source).not.toContain("记录匿名、无效令牌、拒绝和异常状态请求,用于排查未知 IP 攻击");
expect(source).not.toContain("安全事件审计日志");
expect(source).not.toContain("securityTerminalLines");
expect(source).not.toContain("securityLogDialogVisible");
expect(source).not.toContain("SecurityAccessLogItem");
});
it("shows interface audit log total instead of current page line count", () => {
it("shows total access logs in the table and access log dialog", () => {
const source = readSource();
expect(source).toContain("{{ formatNumber(total) }} 条");
expect(source).toContain("共 {{ formatNumber(total) }} 条");
expect(source).toContain(":total=\"total\"");
expect(source).toContain("v-model:page-size=\"pageSize\"");
expect(source).toContain(":page-sizes=\"[50, 100, 200]\"");
expect(source).toContain("accessLogDialogVisible");
expect(source).not.toContain("{{ terminalLines.length }} 条");
expect(source).not.toContain("最近 {{ terminalLines.length }} 条");
});
it("opens both audit logs in realtime downloadable dialogs", () => {
it("renders access rows directly and keeps raw access logs downloadable", () => {
const source = readSource();
expect(source).toContain("interfaceLogDialogVisible");
expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("openInterfaceLogDialog");
expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("if (!showSecurityLog.value) return");
expect(source).toContain("detailDrawerVisible");
expect(source).toContain("selectedLog");
expect(source).toContain("openLogDetail");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("openAccessLogDialog");
expect(source).toContain("downloadInterfaceLog");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("downloadLogFile");
expect(source).toContain("<el-dialog v-model=\"interfaceLogDialogVisible\"");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).toContain("accessLogDialogVisible");
expect(source).toContain("<el-drawer v-model=\"detailDrawerVisible\"");
expect(source).toContain("请求概览");
expect(source).toContain("原始 HTTP 请求");
expect(source).toContain("User-Agent");
expect(source).toContain("更多审计信息");
expect(source).toContain("日志标识");
expect(source).toContain("传输信息");
expect(source).toContain("客户端标识");
expect(source).toContain("requestHeaderEntries(row)");
expect(source).toContain("requestOverviewFieldItems(selectedLog)");
expect(source).toContain("auditMetadataFieldItems(selectedLog)");
expect(source).toContain("requestSnapshotFieldItems(selectedLog)");
expect(source).toContain("buildRawRequestBlock(selectedLog)");
expect(source).toContain("rawRequestBodyLine");
expect(source).toContain("hasCapturedRequestSnapshot");
expect(source).toContain("历史日志未采集原始请求快照");
expect(source).toContain("legacy_permission_endpoint");
expect(source).toContain("# audit_line: ${buildTerminalLine(row)}");
expect(source).not.toContain("detailFieldItems(selectedLog)");
expect(source).not.toContain("<h4>接口</h4>");
expect(source).not.toContain("<h4>访问字段</h4>");
expect(source).not.toContain("<h4>请求快照</h4>");
expect(source).not.toContain("<h4>请求头</h4>");
expect(source).not.toContain("requestHeaderEntries(selectedLog)");
expect(source).not.toContain("未记录请求头");
expect(source).not.toContain("原始请求快照(脱敏)");
expect(source).not.toContain("原始日志行");
expect(source).not.toContain("请求头(脱敏)");
expect(source).toContain("<el-dialog v-model=\"accessLogDialogVisible\"");
expect(source).toContain("下载日志");
expect(source).toContain("securityTerminalWindowRef");
expect(source).toContain("scrollSecurityTerminalToBottom");
expect(source).not.toContain("securityLogDialogVisible");
expect(source).not.toContain("openSecurityLogDialog");
expect(source).not.toContain("downloadSecurityLog");
expect(source).not.toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).not.toContain("securityTerminalWindowRef");
expect(source).not.toContain("scrollSecurityTerminalToBottom");
expect(source).not.toContain("interfaceLogDialogVisible");
});
it("exports all raw access logs across paginated backend pages", () => {
const source = readSource();
expect(source).toContain("const ACCESS_LOG_EXPORT_PAGE_SIZE = 200;");
expect(source).toContain("const rawExportLoading = ref(false);");
expect(source).toContain("fetchAllRawAccessLogLines");
expect(source).toContain("page_size: ACCESS_LOG_EXPORT_PAGE_SIZE");
expect(source).toContain("const totalPages = Math.ceil(first.data.total / ACCESS_LOG_EXPORT_PAGE_SIZE);");
expect(source).toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1)");
expect(source).toContain("raw-access-logs.log");
expect(source).not.toContain("interface-access-audit.log");
});
it("supports account, location and client source filtering on the server query", () => {
const source = readSource();
expect(source).toContain('placeholder="搜索账号、IP、属地、接口或来源"');
expect(source).toContain('<TimeRangeSelector v-model="timeRangePreset"');
expect(source).toContain("onTimePresetChange");
expect(source).toContain("buildTimeRangeFromPreset");
expect(source).toContain("全部");
expect(source).toContain("24小时");
expect(source).toContain("7天");
expect(source).toContain("30天");
expect(source).toContain("clientSourceOptions");
expect(source).toContain("filters.clientType");
expect(source).toContain("params.client_type = filters.clientType");
expect(source).toContain("params.keyword = keywordToken");
expect(source).toContain("params.client_ip = keywordToken");
expect(source).toContain("onKeywordInput");
});
it("keeps access-log filter controls on one compact visual scale", () => {
const source = readSource();
expect(source).toContain("--filter-control-height: 26px");
expect(source).toContain("--filter-font-size: 12px");
expect(source).toContain('import TimeRangeSelector from "./TimeRangeSelector.vue"');
expect(source).not.toContain(".filter-time-presets :deep(.el-radio-button__inner)");
expect(source).toContain(".audit-filters :deep(.el-select__wrapper)");
expect(source).toContain(".audit-filters :deep(.el-input__wrapper)");
expect(source).toContain('class="filter-reset"');
expect(source).toContain("height: var(--filter-control-height)");
expect(source).toContain("font-size: var(--filter-font-size)");
});
it("does not render the manual date range filter in access logs", () => {
const source = readSource();
expect(source).not.toContain("el-date-picker");
expect(source).not.toContain("datetimerange");
expect(source).not.toContain("dateRange");
expect(source).not.toContain("onDateRangeChange");
expect(source).not.toContain("filter-date");
expect(source).not.toContain("开始时间");
expect(source).not.toContain("结束时间");
});
it("includes request headers in the access log item contract", () => {
const source = readFileSync(resolve(__dirname, "../types/api.ts"), "utf8");
expect(source).toContain('event_type?: "permission" | "security" | string;');
expect(source).toContain("request_headers: Record<string, string> | null;");
expect(source).toContain("request_snapshot: RequestSnapshot | null;");
expect(source).toContain("export interface RequestSnapshot");
expect(source).toContain("query_params?: RequestSnapshotParam[] | null;");
expect(source).toContain("status_code?: number | null;");
expect(source).toContain("auth_status?: string | null;");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionIpLocations.vue"), "utf8");
describe("PermissionIpLocations", () => {
it("integrates source metrics into a compact overview above the map", () => {
const source = readSource();
expect(source).toContain('class="ip-overview"');
expect(source).toContain('aria-label="来源指标"');
expect(source).toContain('class="distribution-card"');
expect(source).toContain("min-height: 44px");
expect(source).toContain("grid-template-columns: minmax(0, 1fr) 300px");
expect(source).toContain("padding: 10px");
});
it("keeps compact metric grids responsive", () => {
const source = readSource();
expect(source).toContain("@media (max-width: 1100px)");
expect(source).toContain("@media (max-width: 520px)");
expect(source).toContain(".metric-card:nth-child(odd)");
});
it("uses the complete desktop viewport for fullscreen maps", () => {
const source = readSource();
expect(source).toContain('modal-class="source-map-overlay"');
expect(source).toContain(':option="fullscreenSourceMapOption"');
expect(source).toContain("buildFlowMapOption(flowSourceData.value, \"ctms-world\", 1, \"全球访问链路\")");
expect(source).toContain("buildFlowMapOption(chinaFlowSourceData.value, \"ctms-china\", 0.92, \"中国访问链路\")");
expect(source).toContain(".source-map-overlay .source-map-dialog.el-dialog.is-fullscreen");
expect(source).toContain("inset: 0 !important");
expect(source).toContain("flex: 1 1 auto");
expect(source).toContain("height: 100%;");
expect(source).toContain("min-height: 48px");
});
it("keeps the all-time label and request semantics consistent", () => {
const source = readSource();
expect(source).toContain('all: "全部"');
expect(source).toContain("all: undefined");
expect(source).toContain('all_time: timeRangePreset.value === "all"');
expect(source).not.toContain('all: "近 90 天"');
});
it("keeps fullscreen animation sharp without multiplying large canvas layers", () => {
const source = readSource();
expect(source).toContain('v-if="!fullscreenVisible"');
expect(source).toContain("useDirtyRect: true");
expect(source).toContain("Math.min(getDevicePixelRatio(), 2)");
expect(source).not.toContain("getDevicePixelRatio() * 1.5");
expect(source).not.toContain("zlevel:");
expect(source).toContain("constantSpeed: 72");
expect(source).toContain("trailLength: 0");
expect(source).toContain('symbol: "circle"');
expect(source).toContain('type: "scatter"');
expect(source).toContain("chartAutoresize = { throttle: 120 }");
});
it("does not classify foreign locations as abnormal by geography alone", () => {
const source = readSource();
expect(source).toContain('mapMetric = ref<"traffic" | "risk">("traffic")');
expect(source).toContain("riskColor(item.risk_level)");
expect(source).not.toContain('item.country !== "中国"');
});
it("uses backend coordinates and keeps unmapped locations in the ranking", () => {
const source = readSource();
expect(source).toContain('const mapView = ref<MapView>("flow");');
expect(source).toContain('load: () => import("../assets/china.json")');
expect(source).toContain('load: () => import("../assets/world.json")');
expect(source).toContain("item.longitude");
expect(source).toContain("item.latitude");
expect(source).toContain("external_fallback_ip_count");
expect(source).toContain("外部补全");
expect(source).toContain("chinaRankRows");
expect(source).toContain('全部(保留期 ${periodMetadata.value.retention_days} 天)');
expect(source).not.toContain('from "./worldMapSource"');
});
});
File diff suppressed because it is too large Load Diff
@@ -35,19 +35,26 @@ vi.mock("@/api/projectPermissions", () => ({
status: "healthy", health_score: 95, issues: [],
sample_sufficient: true,
score_breakdown: {
performance: { weight: 40, deduction: 0 },
performance: { weight: 25, deduction: 0 },
deny_rate: { weight: 20, deduction: 0 },
cache: {
weight: 25, deduction: 5, sample_sufficient: true,
scope: "sliding_window", window_seconds: 3600,
},
alerts: { weight: 15, deduction: 0 },
alerts: { weight: 10, deduction: 0 },
runtime: { weight: 20, deduction: 0 },
},
last_hour: {
total_checks: 30, denied_checks: 2, deny_rate: 6.67,
avg_elapsed_ms: 3.5, error_alerts: 0, warning_alerts: 0,
},
cache_stats: { cache_items: { total_count: 60 }, cache_metrics: {} },
components: {
database: { status: "healthy", latency_ms: 2.1 },
permission_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 10000, dropped_entries: 0 },
security_log_writer: { status: "healthy", running: true, queue_size: 0, queue_capacity: 20000, dropped_entries: 0 },
retention: { status: "healthy", running: true, access_log_retention_days: 90, metric_retention_days: 400 },
},
},
}),
fetchPermissionAlerts: vi.fn().mockResolvedValue({ data: { total: 0, alerts: [] } }),
@@ -98,11 +105,13 @@ describe("PermissionMonitoring.vue", () => {
expect(typeof wrapper.vm.refresh).toBe("function");
});
it("passes admin-only access to security logs", () => {
it("keeps security events in security center instead of access logs", () => {
const source = readFileSync(resolve(__dirname, "./PermissionMonitoring.vue"), "utf8");
expect(source).toContain("isAdmin?: boolean");
expect(source).toContain(':show-security-log="props.isAdmin"');
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
expect(source).toContain('<PermissionAccessLogs ref="accessLogsRef" />');
expect(source).not.toContain(':show-security-log="props.isAdmin"');
});
it("uses a full-height source-analysis tab without forcing scroll on other tabs", () => {
@@ -113,7 +122,13 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain(".permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tabs__content)");
expect(source).toContain("overflow: hidden");
expect(source).toContain(".soybean-monitoring-tabs :deep(.el-tabs__content)");
expect(source).toContain("overflow: auto");
expect(source).toContain("flex: 1 1 0;");
expect(source).toContain("height: auto;");
expect(source).toContain("overflow-y: auto;");
expect(source).toContain('height: auto;\n min-height: 100%;');
expect(source).toContain('.permission-monitoring.is-source-tab .soybean-monitoring-tabs :deep(.el-tab-pane[aria-hidden="false"])');
expect(source).toContain("flex: 0 0 52px;");
expect(source).toContain("overscroll-behavior: contain;");
});
it("resizes the source-analysis chart when its tab becomes active", () => {
@@ -132,25 +147,59 @@ describe("PermissionMonitoring.vue", () => {
expect(source).toContain('label="运行概览"');
expect(source).toContain('label="安全中心"');
expect(source).toContain('label="性能趋势"');
expect(source).toContain('label="访问审计"');
expect(source).toContain('label="运行趋势"');
expect(source).toContain('label="访问日志"');
expect(source).toContain('label="来源分析"');
expect(source).toContain('label="运行趋势" name="trends" lazy');
expect(source).toContain('@drilldown="openTrendDrilldown"');
expect(source).toContain('accessLogsRef.value?.applyTrendFilter(payload)');
expect(source).toContain('label="访问日志" name="logs" lazy');
expect(source).toContain('label="来源分析" name="ip-locations" lazy');
expect(source).toContain('<SecurityCenter v-if="props.isAdmin"');
expect(source).toContain('name="security"');
expect(source).toContain('label: "今日监测事件"');
expect(source).toContain('label: "每分钟事件"');
expect(source).toContain('label: "今日通过率"');
expect(source).toContain('label: "历史事件总数"');
expect(source).toContain('class="metric-strip"');
expect(source).toContain('class="metric-strip-detail"');
expect(source).toContain('detail: `历史 ${statsSummary.value.total_logs.toLocaleString()}`');
expect(source).toContain("运行指标");
expect(source).toContain('const formatResponseTime');
expect(source).toContain('return "<0.1ms"');
expect(source).toContain("业务样本不足");
expect(source).not.toContain("组件在线不代表指标已完成评估");
expect(source).not.toContain("评分由性能、拒绝率、缓存、告警和组件状态共同计算");
expect(source).not.toContain("最近一次检查未发现需要处理的问题");
expect(source).toContain('class="health-summary-label">综合状态');
expect(source).toContain('class="health-signal-item"');
expect(source).toContain('item.deduction ? `-${item.deduction}` : "正常"');
expect(source).toContain('class="overview-pane"');
expect(source).toContain('class="overview-hero-card"');
expect(source).toContain('class="operations-card"');
expect(source).toContain('class="operations-grid"');
expect(source).toContain('class="alerts-section"');
expect(source).toContain("grid-template-columns: minmax(210px, 0.85fr)");
expect(source).toContain('class="status-section runtime-section"');
expect(source).toContain('class="empty-alert-state"');
expect(source).toContain('const hasCacheSamples = computed');
expect(source).toContain("异常拒绝排行");
expect(source).toContain("告警数据加载失败,不能据此判断系统正常");
expect(source).toContain("const formatUpdatedTime");
expect(source).toContain("更新 {{ formatUpdatedTime(lastUpdatedAt) }}");
expect(source).toContain("runtimeComponentItems");
expect(source).not.toContain('label="实时概览"');
expect(source).not.toContain('label="趋势分析"');
expect(source).not.toContain('label="访问日志"');
expect(source).not.toContain('label="访问审计"');
expect(source).not.toContain('label="IP属地"');
expect(source).not.toContain('label: "今日总检查"');
expect(source).not.toContain('label: "每分钟请求"');
expect(source).not.toContain('label: "今日允许率"');
expect(source).not.toContain('label: "历史总记录"');
expect(source).not.toContain('label: "历史事件总数"');
expect(source).not.toContain('class="summary-card"');
expect(source).not.toContain('class="overview-toolbar"');
expect(source).not.toContain("被拒绝最多的权限");
expect(source).not.toContain("暂无告警,系统运行正常");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTemplateSelector.vue"), "utf8");
describe("PermissionTemplateSelector.vue", () => {
it("renders accessible role actions with explicit project status", () => {
const source = readSource();
expect(source).toContain("<button");
expect(source).toContain(":aria-label=");
expect(source).toContain("使用中");
expect(source).toContain("未启用");
expect(source).toContain("配置权限");
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr));");
expect(source).toContain("min-height: 122px;");
expect(source).toContain("margin-top: auto;");
expect(source).not.toContain('PM: "👑"');
});
});
@@ -2,17 +2,20 @@
<div class="template-selector">
<div class="selector-header">
<div class="selector-title-group">
<h3>角色权限概览</h3>
<h3>角色策略概览</h3>
</div>
<el-tag effect="plain" size="small" type="info" round>{{ templates.length }} 个角色</el-tag>
</div>
<div class="template-grid">
<div
<button
v-for="template in sortedTemplates"
:key="template.id"
type="button"
class="template-card"
:class="{ inactive: template.category && !isRoleActive(template.category) }"
:disabled="!template.category"
:aria-label="template.category ? `配置 ${template.name} 权限` : template.name"
@click="template.category && emit('edit-role', template.category!)"
>
<div class="card-top">
@@ -21,7 +24,9 @@
<span class="card-name">{{ template.name }}</span>
<span v-if="template.description" class="card-desc">{{ template.description }}</span>
</div>
<el-tag v-if="!template.is_system" size="small" effect="dark" round type="success" class="card-badge">自定义</el-tag>
<span :class="['card-status', isRoleActive(template.category || '') ? 'card-status--active' : 'card-status--inactive']">
{{ isRoleActive(template.category || '') ? '使用中' : '未启用' }}
</span>
</div>
<div class="card-stats">
<template v-if="props.currentPermissions && template.category && props.currentPermissions[template.category]">
@@ -31,11 +36,11 @@
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countCurrentEnabled(template.category) }} 启用
{{ countCurrentEnabled(template.category) }} 已授权
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countCurrentDisabled(template.category) }} 禁用
{{ countCurrentDisabled(template.category) }} 未授权
</span>
</div>
</template>
@@ -46,16 +51,17 @@
<div class="stat-numbers">
<span class="stat enabled">
<span class="stat-dot green" />
{{ countEnabled(template) }} 启用
{{ countEnabled(template) }} 已授权
</span>
<span class="stat disabled">
<span class="stat-dot red" />
{{ countDisabled(template) }} 禁用
{{ countDisabled(template) }} 未授权
</span>
</div>
</template>
</div>
</div>
<div class="card-action">配置权限 <span aria-hidden="true"></span></div>
</button>
</div>
</div>
</template>
@@ -94,10 +100,10 @@ const sortedTemplates = computed(() =>
const roleIcon = (category: string | null) => {
const icons: Record<string, string> = {
PM: "👑", CRA: "📋", PV: "🔍",
QA: "🏥", CTA: "📦",
PM: "PM", CRA: "CRA", PV: "PV",
QA: "QA", CTA: "CTA",
};
return icons[category ?? ""] ?? "📄";
return icons[category ?? ""] ?? "";
};
const isRoleActive = (role: string) => props.activeRoles?.includes(role) ?? false;
@@ -155,38 +161,45 @@ watch(() => props.refreshKey, loadTemplates);
<style scoped>
.template-selector {
padding: 8px 0 12px;
padding: 4px 0 6px;
}
.selector-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
margin-bottom: 8px;
}
.selector-title-group h3 {
margin: 0 0 4px;
font-size: 15px;
margin: 0;
font-size: 13px;
font-weight: 600;
color: #1a2332;
}
.template-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-template-columns: repeat(5, minmax(0, 1fr));
align-items: start;
gap: 12px;
margin-bottom: 12px;
gap: 8px;
margin-bottom: 6px;
}
.template-card {
position: relative;
padding: 12px 14px;
width: 100%;
min-height: 122px;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: 8px;
background: #fff;
cursor: pointer;
color: inherit;
font: inherit;
display: flex;
flex-direction: column;
text-align: left;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
}
@@ -197,11 +210,19 @@ watch(() => props.refreshKey, loadTemplates);
transform: translateY(-2px);
}
.template-card:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.25);
outline-offset: 2px;
}
.template-card:disabled {
cursor: default;
}
.template-card.inactive {
border-style: dashed;
border-color: #e2e8f0;
background: #fafbfc;
opacity: 0.7;
}
.template-card.inactive:hover {
@@ -213,12 +234,21 @@ watch(() => props.refreshKey, loadTemplates);
.card-top {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
gap: 7px;
margin-bottom: 7px;
}
.card-icon {
font-size: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 7px;
background: #eef5fa;
color: #315f7a;
font-size: 9px;
font-weight: 800;
line-height: 1;
flex-shrink: 0;
}
@@ -232,7 +262,7 @@ watch(() => props.refreshKey, loadTemplates);
}
.card-name {
font-size: 14px;
font-size: 12px;
font-weight: 600;
color: #1a2332;
}
@@ -242,13 +272,49 @@ watch(() => props.refreshKey, loadTemplates);
}
.card-desc {
font-size: 11px;
font-size: 10px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-status {
display: inline-flex;
align-items: center;
flex-shrink: 0;
padding: 2px 5px;
border: 1px solid transparent;
border-radius: 999px;
font-size: 9.5px;
font-weight: 600;
}
.card-status--active {
border-color: #b9ddcc;
background: #edf8f2;
color: #267054;
}
.card-status--inactive {
border-color: #dbe3ea;
background: #f5f7f9;
color: #7a8997;
}
.card-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
margin-top: auto;
padding-top: 6px;
border-top: 1px solid #edf1f5;
color: #416b83;
font-size: 10.5px;
font-weight: 600;
}
.card-badge {
flex-shrink: 0;
}
@@ -256,11 +322,11 @@ watch(() => props.refreshKey, loadTemplates);
.card-stats {
display: flex;
flex-direction: column;
gap: 8px;
gap: 5px;
}
.stat-bar {
height: 4px;
height: 3px;
border-radius: 2px;
background: #f1f5f9;
overflow: hidden;
@@ -275,20 +341,20 @@ watch(() => props.refreshKey, loadTemplates);
.stat-numbers {
display: flex;
gap: 12px;
gap: 8px;
}
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-size: 10.5px;
color: #64748b;
}
.stat-dot {
width: 6px;
height: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
flex-shrink: 0;
}
@@ -300,4 +366,22 @@ watch(() => props.refreshKey, loadTemplates);
.template-card.inactive .stat-dot.red { background: #cbd5e1; }
.template-card.inactive .stat-fill { background: #cbd5e1; }
@media (max-width: 1180px) {
.template-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 920px) {
.template-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.template-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./PermissionTrendCharts.vue"), "utf8");
describe("PermissionTrendCharts", () => {
it("uses accurate permission trend language and removes the misleading all-period option", () => {
const source = readSource();
expect(source).toContain("权限检查趋势");
expect(source).toContain("权限检查量");
expect(source).toContain("权限检查耗时");
expect(source).toContain("权限拒绝率");
expect(source).toContain("权限缓存命中率");
expect(source).not.toContain("系统性能趋势");
expect(source).not.toContain('{ value: "all", label: "全部" }');
});
it("renders dense period summaries with previous-period and sample context", () => {
const source = readSource();
expect(source).toContain('class="metric-strip"');
expect(source).toContain("P95 检查耗时");
expect(source).toContain("活跃范围");
expect(source).toContain("较上期");
expect(source).toContain("cache_sample_available");
expect(source).toContain("不等于命中率为 0");
});
it("uses honest chart semantics and visible health thresholds", () => {
const source = readSource();
expect(source).toContain('stack: "checks"');
expect(source).toContain("smooth: false");
expect(source).toContain('formatter: "慢检查 50ms"');
expect(source).toContain('formatter: "健康线 80%"');
expect(source).toContain("connectNulls: false");
});
it("provides denial, latency and client attribution with access-log drilldown", () => {
const source = readSource();
expect(source).toContain("拒绝最多");
expect(source).toContain("耗时最高");
expect(source).toContain("客户端表现");
expect(source).toContain('@click="onChecksChartClick"');
expect(source).toContain('emit("drilldown"');
expect(source).toContain("openAttributionLogs");
});
it("keeps the information-dense layout responsive", () => {
const source = readSource();
expect(source).toContain("grid-template-columns: repeat(5, minmax(0, 1fr))");
expect(source).toContain("grid-template-columns: minmax(0, 2.1fr)");
expect(source).toContain("grid-template-columns: repeat(3, minmax(0, 1fr))");
expect(source).toContain("@media (max-width: 900px)");
});
});
File diff suppressed because it is too large Load Diff
-145
View File
@@ -1,145 +0,0 @@
<template>
<div class="quick-actions-section">
<div class="section-header">
<el-icon class="header-icon"><Pointer /></el-icon>
<span class="header-title">{{ TEXT.common.labels.quickActions }}</span>
</div>
<div class="actions-grid">
<div
v-for="item in visibleActions"
:key="item.path"
class="action-item"
@click="go(item.path)"
>
<div class="action-icon-wrapper">
<el-icon><component :is="item.icon" /></el-icon>
</div>
<span class="action-label">{{ item.label }}</span>
<el-icon class="action-arrow"><ArrowRight /></el-icon>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import {
Pointer, ArrowRight, Timer, UserFilled,
Management, Money, Collection
} from "@element-plus/icons-vue";
import { TEXT } from "../locales";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { isSystemAdmin } from "../utils/roles";
import { getProjectRoutePermission, hasProjectPermission } from "../utils/projectRoutePermissions";
const router = useRouter();
const auth = useAuthStore();
const study = useStudyStore();
const actions = [
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", icon: Timer },
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", icon: Timer },
{ label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled },
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management },
{ label: TEXT.menu.finance, path: "/fees/contracts", icon: Money },
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", icon: Collection },
];
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
const visibleActions = computed(() =>
actions.filter((item) =>
hasProjectPermission(
study.currentPermissions,
projectRole.value,
getProjectRoutePermission(item.path),
isSystemAdmin(auth.user),
)
)
);
const go = (path: string) => router.push(path);
</script>
<style scoped>
.quick-actions-section {
background-color: #fff;
border-radius: var(--ctms-radius);
padding: 24px;
border: 1px solid var(--ctms-border-color);
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.header-icon {
font-size: 16px;
color: var(--ctms-text-secondary);
}
.header-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.actions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.action-item {
display: flex;
align-items: center;
padding: 12px 16px;
background-color: #ffffff;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
cursor: pointer;
transition: var(--ctms-transition);
position: relative;
overflow: hidden;
}
.action-item:hover {
background-color: #ffffff;
border-color: var(--ctms-border-color-hover);
}
.action-icon-wrapper {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: 12px;
color: var(--ctms-text-secondary);
background-color: var(--ctms-bg-muted);
}
.action-label {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
flex: 1;
}
.action-arrow {
font-size: 14px;
color: var(--ctms-text-disabled);
opacity: 1;
transition: var(--ctms-transition);
}
.action-item:hover .action-arrow {
color: var(--ctms-text-secondary);
}
</style>
+107 -24
View File
@@ -5,21 +5,33 @@ import { resolve } from "node:path";
const readSource = () => readFileSync(resolve(__dirname, "./SecurityCenter.vue"), "utf8");
describe("SecurityCenter", () => {
it("renders the security dashboard in the same audit-card style as access logs", () => {
it("renders a compact security analysis summary in the same audit-card style as access logs", () => {
const source = readSource();
expect(source).toContain("audit-filters");
expect(source).toContain("audit-metrics");
expect(source).toContain("audit-grid");
expect(source).toContain("metric-card");
expect(source).toContain("ranking-card");
expect(source).toContain("security-analysis-card");
expect(source).toContain("risk-preview-list");
expect(source).toContain("risk-summary-panel");
expect(source).toContain("risk-summary-title");
expect(source).toContain("topRiskPreview");
expect(source).toContain('class="security-overview"');
expect(source).toContain('aria-label="安全指标"');
expect(source).toContain('class="security-toolbar"');
expect(source).toContain("timeRangePreset");
expect(source).toContain("timeRangeOptions");
expect(source).toContain("buildTimeRangeFromPreset");
expect(source).toContain("onTimeRangeChange");
expect(source).not.toContain("audit-grid");
expect(source).not.toContain("ranking-card");
expect(source).not.toContain("security-summary");
expect(source).not.toContain("security-card");
});
it("places filters inside the event details section instead of the page header", () => {
const source = readSource();
const tableIndex = source.indexOf('<section class="security-table-wrap">');
const tableIndex = source.indexOf('<section ref="securityEventsSectionRef" class="security-table-wrap">');
const filtersIndex = source.indexOf('<section class="audit-filters detail-filters">');
expect(tableIndex).toBeGreaterThan(-1);
@@ -27,20 +39,42 @@ describe("SecurityCenter", () => {
expect(source).not.toContain('<section class="audit-filters">\n <el-select');
});
it("surfaces abnormal IP location ranking, endpoint type distribution and risk level distribution", () => {
it("keeps the security table edge-to-edge while preserving filter and pagination spacing", () => {
const source = readSource();
expect(source).toContain("异常排行");
expect(source).toContain("事件分布");
expect(source).toContain("访问的接口类型");
expect(source).toMatch(/\.security-table-wrap \{[\s\S]*?padding: 0;[\s\S]*?overflow: hidden;/);
expect(source).toMatch(/\.table-head \{[\s\S]*?margin-bottom: 0;[\s\S]*?padding: 10px 12px;/);
expect(source).toMatch(/\.pagination-wrap \{[\s\S]*?padding: 8px 12px;[\s\S]*?border-top:/);
expect(source).toContain(".security-table :deep(.el-scrollbar__wrap)");
expect(source).toContain(".security-table :deep(.el-scrollbar__bar.is-horizontal)");
});
it("keeps risk levels on the page and opens abnormal sources in a compact ranking dialog", () => {
const source = readSource();
expect(source).toContain("异常分析");
expect(source).toContain("查看异常分析");
expect(source).toContain("analysisDialogVisible");
expect(source).toContain("openAnalysisDialog");
expect(source).toContain("jumpToSecurityEvents");
expect(source).toContain("securityEventsSectionRef");
expect(source).toContain("scrollIntoView");
expect(source).toContain('<el-dialog v-model="analysisDialogVisible"');
expect(source).toContain('width="min(1180px, 96vw)"');
expect(source).toContain("analysis-ranking-dialog");
expect(source).toContain("analysis-ranking-head");
expect(source).toContain("endpoint-summary-chips");
expect(source).toContain("analysis-rank-grid");
expect(source).toContain("analysis-rank-card");
expect(source).toContain("analysis-rank-card-stats");
expect(source).toContain("查看事件明细");
expect(source).not.toContain('<section class="analysis-dialog-panel');
expect(source).toContain("风险等级");
expect(source).toContain("event-distribution-card");
expect(source).toContain("distribution-section");
expect(source).toContain(".event-distribution-card > .section-head");
expect(source).toContain("padding-top: 24px");
expect(source).toContain("ipRiskStats");
expect(source).toContain("endpointTypeStats");
expect(source).toContain("riskLevelStats");
expect(source.match(/v-for="item in riskLevelStats"/g)).toHaveLength(1);
expect(source).not.toContain("compact-risk-section");
expect(source).toContain("resolveEndpointType");
expect(source).toContain("formatIpLocation");
expect(source).toContain("fallbackIpLocation");
@@ -49,11 +83,13 @@ describe("SecurityCenter", () => {
expect(source).toContain("row.ip_isp");
expect(source).toContain("item.location");
expect(source).toContain("categoryText");
expect(source).toContain("rank-main-line");
expect(source).toContain("rank-meta-line");
expect(source).toContain("analysis-rank-card-ip");
expect(source).toContain("analysis-rank-card-meta");
expect(source).toContain("lastSeenAt");
expect(source).toContain("最后访问");
expect(source).toContain("slice(0, 10)");
expect(source).toContain("securitySummary.value.top_ips");
expect(source).toContain("securitySummary.value.endpoint_type_counts");
expect(source).toContain("securitySummary.value.severity_counts");
expect(source).not.toContain("公网位置待解析");
expect(source).not.toContain("异常IP(位置)排行");
expect(source).not.toContain("risk-level-card");
@@ -62,37 +98,84 @@ describe("SecurityCenter", () => {
it("keeps detailed security events searchable and inspectable", () => {
const source = readSource();
expect(source).toContain("detailFilteredEvents");
expect(source).toContain("securityQueryParams");
expect(source).toContain("keyword: keyword.value.trim() || undefined");
expect(source).toContain("paginatedEvents");
expect(source).toContain("openDetail");
expect(source).toContain("安全事件明细");
expect(source).toContain("security-table-wrap");
expect(source).toContain('label="IP"');
expect(source).toContain('label="位置"');
expect(source).not.toContain('label="来源 IP"');
expect(source).not.toContain('label="IP来源"');
expect(source).toContain("搜索 IP、位置、账号、路径或 UA");
expect(source).toContain("loadSecurityEvents");
expect(source).toContain("安全事件加载失败,页面中可能是上次成功获取的数据");
expect(source).toContain("lastUpdatedAt");
expect(source).toContain("安全事件详情");
expect(source).toContain("请求概览");
expect(source).toContain("原始 HTTP 请求");
expect(source).toContain("User-Agent");
expect(source).toContain("构建信息");
expect(source).toContain("更多审计信息");
expect(source).toContain("日志标识");
expect(source).toContain("传输信息");
expect(source).toContain("客户端标识");
expect(source).toContain("securityRequestOverviewFields(selectedEvent)");
expect(source).toContain("securityAuditMetadataFields(selectedEvent)");
expect(source).toContain("securityRequestSnapshotFields(selectedEvent)");
expect(source).toContain("buildSecurityRawRequestBlock(selectedEvent)");
expect(source).toContain("securityClientSourceLabel(selectedEvent)");
expect(source).toContain("历史日志未采集原始请求快照");
expect(source).toContain("历史日志未采集原始 HTTP 请求");
expect(source).not.toContain("securityDetailFieldItems(selectedEvent)");
expect(source).not.toContain("<h4>接口</h4>");
expect(source).not.toContain("<h4>访问字段</h4>");
expect(source).not.toContain("<h4>请求快照</h4>");
expect(source).not.toContain("原始请求快照(脱敏)");
expect(source).not.toContain("请求头(脱敏)");
expect(source).not.toContain("未记录请求头");
});
it("opens full security logs as a dialog inside security center", () => {
const source = readSource();
expect(source).toContain("安全日志");
expect(source).toContain("securityLogDialogVisible");
expect(source).toContain("securityLogDialogLoading");
expect(source).toContain("securityTerminalWindowRef");
expect(source).toContain("securityTerminalLines");
expect(source).toContain("buildSecurityTerminalLine");
expect(source).toContain("openSecurityLogDialog");
expect(source).toContain("scrollSecurityTerminalToBottom");
expect(source).toContain("downloadSecurityLog");
expect(source).toContain("saveFileWithFeedback");
expect(source).toContain("securityRequestTarget(event)");
expect(source).toContain("<el-dialog v-model=\"securityLogDialogVisible\"");
expect(source).toContain("securityTerminalLines.join");
expect(source).toContain("security-access-logs.log");
expect(source).toContain("terminal-window dialog-terminal-window");
});
it("labels abnormal IP events and paginates details like account management", () => {
const source = readSource();
expect(source).toContain('{ label: "异常IP", value: "ABNORMAL_IP" }');
expect(source).not.toContain('value: "ABNORMAL_IP"');
expect(source).toContain('label: "风险来源 IP"');
expect(source).toContain('v-model:current-page="page"');
expect(source).toContain('v-model:page-size="pageSize"');
expect(source).toContain(':page-sizes="[10, 20, 50]"');
expect(source).toContain('layout="prev, pager, next, sizes, total"');
});
it("calculates dashboard statistics from all events instead of detail filters or current page", () => {
it("uses server-side pagination and aggregate facets instead of loading every event", () => {
const source = readSource();
expect(source).toContain("statsEvents");
expect(source).toContain("detailFilteredEvents");
expect(source).toContain("securitySummary");
expect(source).toContain("loadSecurityStats");
expect(source).toContain("SECURITY_STATS_PAGE_SIZE");
expect(source).toContain("statsEvents.value");
expect(source).not.toContain("const uniqueIpCount = new Set(events.value");
expect(source).not.toContain("filteredStatsEvents");
expect(source).toContain("response.data.summary");
expect(source).toContain("@current-change=\"loadSecurityEvents\"");
expect(source).not.toContain("for (let nextPage = 2; nextPage <= totalPages; nextPage += 1) {\n const res = await fetchSecurityAccessLogs");
expect(source).toContain("fetchAllSecurityEvents");
});
});
File diff suppressed because it is too large Load Diff

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