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
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS build
|
||||
FROM node:22.13-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 74 B After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 387 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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>
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
|
Before Width: | Height: | Size: 293 B After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 99 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 154 B After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 233 B After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 322 B After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 341 B After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 756 B After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 96 B After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 846 B After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 114 B After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 166 B After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 194 B After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 123 B After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 374 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
@@ -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`);
|
||||
@@ -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,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) =>
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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`] },
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,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,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);
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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: "导出文件已保存",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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("暂无告警,系统运行正常");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||