完善桌面端界面、发布检查与邮箱域名同步
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
"desktop:build": "tauri build",
|
||||
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
|
||||
"desktop:bundle:dmg": "tauri build --bundles dmg",
|
||||
"desktop:update-feed:check": "node scripts/verify-desktop-update-feed.mjs",
|
||||
"release:env:check": "node scripts/verify-release-build-env.mjs",
|
||||
"version:check": "node scripts/client-version.mjs --check",
|
||||
"version:set": "node scripts/client-version.mjs --set",
|
||||
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
|
||||
|
||||
@@ -48,6 +48,7 @@ const verifyTauriConfig = async () => {
|
||||
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
|
||||
const csp = tauriConfig.app?.security?.csp || "";
|
||||
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
|
||||
const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0];
|
||||
|
||||
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
|
||||
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
|
||||
@@ -68,6 +69,8 @@ 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.");
|
||||
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
|
||||
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
|
||||
};
|
||||
|
||||
const verifyCapabilities = async () => {
|
||||
@@ -151,16 +154,61 @@ const verifySourceSafety = async () => {
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(rootDir, path);
|
||||
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
|
||||
assert(
|
||||
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
|
||||
`${file}: token-related values must not be written to console logs.`,
|
||||
);
|
||||
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
|
||||
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
|
||||
}
|
||||
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
|
||||
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyNotificationBoundary = async () => {
|
||||
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
|
||||
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
|
||||
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
|
||||
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
|
||||
};
|
||||
|
||||
const verifyUpdaterBoundary = async () => {
|
||||
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
|
||||
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
|
||||
assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds.");
|
||||
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
|
||||
};
|
||||
|
||||
const verifyWorkflowGates = async () => {
|
||||
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
|
||||
const requiredCommands = [
|
||||
"npm run version:check",
|
||||
"npm run runtime:check",
|
||||
"npm run desktop:release:check",
|
||||
"npm run ui:contract",
|
||||
"npm run type-check",
|
||||
"npm run test:unit",
|
||||
"npm run build",
|
||||
"npm run desktop:build:app",
|
||||
"npm run release:env:check",
|
||||
];
|
||||
|
||||
for (const command of requiredCommands) {
|
||||
assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`);
|
||||
}
|
||||
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
|
||||
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
|
||||
};
|
||||
|
||||
await verifyTauriConfig();
|
||||
await verifyCapabilities();
|
||||
await verifyRustBoundary();
|
||||
await verifySourceSafety();
|
||||
await verifyNotificationBoundary();
|
||||
await verifyUpdaterBoundary();
|
||||
await verifyWorkflowGates();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const failures = [];
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const optionValue = (name) => {
|
||||
const index = args.indexOf(name);
|
||||
return index >= 0 ? args[index + 1] : undefined;
|
||||
};
|
||||
|
||||
const feedPath = resolve(
|
||||
frontendDir,
|
||||
optionValue("--feed") || process.env.DESKTOP_UPDATE_FEED || "src-tauri/target/release/bundle/latest.json",
|
||||
);
|
||||
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
|
||||
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const assertFileExists = async (path, description) => {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
fail(`${description} does not exist: ${path}`);
|
||||
}
|
||||
};
|
||||
|
||||
let feed;
|
||||
try {
|
||||
feed = JSON.parse(await readFile(feedPath, "utf8"));
|
||||
} catch (error) {
|
||||
fail(`Cannot read desktop update feed ${feedPath}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (feed) {
|
||||
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
|
||||
const platforms = feed.platforms || {};
|
||||
const darwinArm = platforms["darwin-aarch64"];
|
||||
const darwinIntel = platforms["darwin-x86_64"];
|
||||
|
||||
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
|
||||
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
|
||||
assert(Boolean(darwinArm), "latest.json must include darwin-aarch64.");
|
||||
assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64.");
|
||||
|
||||
const entries = [
|
||||
["darwin-aarch64", darwinArm],
|
||||
["darwin-x86_64", darwinIntel],
|
||||
];
|
||||
|
||||
for (const [platform, entry] of entries) {
|
||||
const rawUrl = entry?.url;
|
||||
const signature = entry?.signature;
|
||||
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
|
||||
assert(typeof rawUrl === "string" && rawUrl.length > 0, `${platform} must include an artifact URL.`);
|
||||
if (!rawUrl) continue;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
fail(`${platform} artifact URL is invalid: ${rawUrl}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
assert(url.protocol === "https:", `${platform} artifact URL must use HTTPS.`);
|
||||
assert(!/[?&]token=/i.test(url.search), `${platform} artifact URL must not contain token query parameters.`);
|
||||
assert(!url.pathname.endsWith("/latest.json"), `${platform} artifact URL must point to an immutable artifact, not latest.json.`);
|
||||
assert(url.pathname.includes(packageInfo.version), `${platform} artifact URL must include version ${packageInfo.version}.`);
|
||||
if (expectedBaseUrl) {
|
||||
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
|
||||
}
|
||||
|
||||
if (artifactDir) {
|
||||
const artifactPath = resolve(artifactDir, basename(url.pathname));
|
||||
await assertFileExists(artifactPath, `${platform} updater artifact`);
|
||||
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
|
||||
}
|
||||
}
|
||||
|
||||
if (darwinArm?.url && darwinIntel?.url) {
|
||||
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop update feed check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Desktop update feed check passed.");
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
|
||||
const failures = [];
|
||||
|
||||
const allowedChannels = new Set(["dev", "main", "release", "local"]);
|
||||
const fullShaPattern = /^[0-9a-f]{40}$/i;
|
||||
const semverTag = `v${packageInfo.version}`;
|
||||
|
||||
const env = process.env;
|
||||
const channel = env.VITE_BUILD_CHANNEL || "local";
|
||||
const commit = env.VITE_BUILD_COMMIT || "local";
|
||||
const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true";
|
||||
const isTagBuild = env.GITHUB_REF_TYPE === "tag";
|
||||
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const requireEnv = (name) => {
|
||||
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
|
||||
};
|
||||
|
||||
assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`);
|
||||
|
||||
if (isCi || isReleaseBuild) {
|
||||
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
|
||||
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
|
||||
}
|
||||
|
||||
if (env.GITHUB_SHA) {
|
||||
assert(
|
||||
commit === env.GITHUB_SHA || commit === "local",
|
||||
"VITE_BUILD_COMMIT must match GITHUB_SHA when GitHub Actions provides a source commit.",
|
||||
);
|
||||
}
|
||||
|
||||
if (isTagBuild) {
|
||||
assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || "<missing>"}.`);
|
||||
assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release.");
|
||||
}
|
||||
|
||||
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
|
||||
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
|
||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
|
||||
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
|
||||
requireEnv("APPLE_ID");
|
||||
requireEnv("APPLE_PASSWORD");
|
||||
requireEnv("APPLE_TEAM_ID");
|
||||
assert(
|
||||
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
|
||||
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Release build environment check passed.");
|
||||
}
|
||||
@@ -10,8 +10,12 @@
|
||||
import { initSessionManager } from "./session/sessionManager";
|
||||
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
|
||||
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
|
||||
import { applyDesktopThemePreference, isTauriRuntime } from "./runtime";
|
||||
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
applyDesktopThemePreference();
|
||||
}
|
||||
initSessionManager();
|
||||
initDesktopNotificationManager();
|
||||
initDesktopUpdateManager();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,23 +3,35 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||
const readDesktopLayoutSource = () => readFileSync(resolve(__dirname, "./DesktopLayout.vue"), "utf8");
|
||||
const readWebLayoutSource = () => readFileSync(resolve(__dirname, "./WebLayout.vue"), "utf8");
|
||||
const readNavigationSource = () => readFileSync(resolve(__dirname, "./layout/navigation.ts"), "utf8");
|
||||
|
||||
describe("desktop layout shell", () => {
|
||||
it("keeps desktop-only controls behind the Tauri runtime flag", () => {
|
||||
it("routes desktop and web shells through the runtime flag", () => {
|
||||
const source = readLayoutSource();
|
||||
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime()");
|
||||
expect(source).toContain('v-if="isDesktop"');
|
||||
expect(source).toContain("DesktopCommandPalette");
|
||||
expect(source).toContain("DesktopPreferences");
|
||||
expect(source).toContain("<DesktopLayout v-if=\"isDesktop\" />");
|
||||
expect(source).toContain("<WebLayout v-else />");
|
||||
});
|
||||
|
||||
it("uses route-only desktop preference storage", () => {
|
||||
const source = readLayoutSource();
|
||||
const source = readDesktopLayoutSource();
|
||||
|
||||
expect(source).toContain("readDesktopRecentRoutes");
|
||||
expect(source).toContain("readDesktopFavoriteRoutes");
|
||||
expect(source).toContain("recordDesktopRecentRoute");
|
||||
expect(source).toContain("currentDesktopRoutePreference");
|
||||
});
|
||||
|
||||
it("shares active route mapping between web and desktop shells", () => {
|
||||
const webLayout = readWebLayoutSource();
|
||||
const desktopLayout = readDesktopLayoutSource();
|
||||
const navigation = readNavigationSource();
|
||||
|
||||
expect(webLayout).toContain("getActiveLayoutPath(route.path)");
|
||||
expect(desktopLayout).toContain("getActiveLayoutPath(route.path)");
|
||||
expect(navigation).toContain("export const getActiveLayoutPath");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,298 @@
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
export type LayoutNavigationIcon =
|
||||
| "audit"
|
||||
| "calendar"
|
||||
| "check"
|
||||
| "coin"
|
||||
| "dashboard"
|
||||
| "document"
|
||||
| "flag"
|
||||
| "folder"
|
||||
| "key"
|
||||
| "monitor"
|
||||
| "notebook"
|
||||
| "project"
|
||||
| "settings"
|
||||
| "subject"
|
||||
| "users";
|
||||
|
||||
export type LayoutNavigationItem = {
|
||||
label: string;
|
||||
path: string;
|
||||
group: string;
|
||||
icon: LayoutNavigationIcon;
|
||||
keywords?: string[];
|
||||
children?: LayoutNavigationItem[];
|
||||
};
|
||||
|
||||
export const getActiveLayoutPath = (path: string) => {
|
||||
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||
if (path.startsWith("/project/")) return "/project/overview";
|
||||
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
|
||||
if (path.startsWith("/drug/shipments")) return "/drug/shipments";
|
||||
if (path.startsWith("/materials/equipment")) return "/materials/equipment";
|
||||
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
|
||||
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
|
||||
if (path.startsWith("/startup/meeting-auth") || path.startsWith("/startup/kickoff") || path.startsWith("/startup/training")) {
|
||||
return "/startup/meeting-auth";
|
||||
}
|
||||
if (path.startsWith("/subjects")) return "/subjects";
|
||||
if (path.startsWith("/risk-issues/sae")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/risk-issues/pd")) return "/risk-issues/pd";
|
||||
if (path.startsWith("/risk-issues/monitoring-visits")) return "/risk-issues/monitoring-visits";
|
||||
if (path.startsWith("/risk-issues")) return "/risk-issues/sae";
|
||||
if (path.startsWith("/etmf")) return "/etmf";
|
||||
if (path.startsWith("/knowledge/medical-consult")) return "/knowledge/medical-consult";
|
||||
if (path.startsWith("/knowledge/precautions")) return "/knowledge/precautions";
|
||||
if (path.startsWith("/knowledge/support-files")) return "/knowledge/support-files";
|
||||
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
||||
if (path.startsWith("/projects/")) return "/admin/projects";
|
||||
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
||||
if (path.startsWith("/admin/system-monitoring") || path.startsWith("/admin/permission-monitoring")) return "/admin/system-monitoring";
|
||||
if (path.startsWith("/admin/permissions/")) return path;
|
||||
return path;
|
||||
};
|
||||
|
||||
export const buildAdminNavigationItems = (options: {
|
||||
hasUser: boolean;
|
||||
isAdmin: boolean;
|
||||
canAccessAdminPermissions: boolean;
|
||||
}): LayoutNavigationItem[] => {
|
||||
if (!options.hasUser) return [];
|
||||
|
||||
const items: LayoutNavigationItem[] = [];
|
||||
if (options.isAdmin) {
|
||||
items.push({
|
||||
label: TEXT.menu.accountManagement,
|
||||
path: "/admin/users",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "users",
|
||||
keywords: ["user", "account"],
|
||||
});
|
||||
}
|
||||
items.push({
|
||||
label: TEXT.menu.projectManagement,
|
||||
path: "/admin/projects",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "project",
|
||||
keywords: ["project"],
|
||||
});
|
||||
if (options.isAdmin) {
|
||||
items.push(
|
||||
{
|
||||
label: TEXT.menu.auditLogs,
|
||||
path: "/admin/audit-logs",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "audit",
|
||||
keywords: ["audit"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.systemMonitoring,
|
||||
path: "/admin/system-monitoring",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "monitor",
|
||||
keywords: ["monitoring"],
|
||||
},
|
||||
{
|
||||
label: "邮件服务",
|
||||
path: "/admin/email-settings",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "settings",
|
||||
keywords: ["email"],
|
||||
},
|
||||
);
|
||||
}
|
||||
if (options.canAccessAdminPermissions) {
|
||||
items.push({
|
||||
label: TEXT.menu.permissionManagement,
|
||||
path: "/admin/permissions/system",
|
||||
group: TEXT.menu.admin,
|
||||
icon: "key",
|
||||
keywords: ["permission"],
|
||||
children: [
|
||||
{
|
||||
label: "系统级权限",
|
||||
path: "/admin/permissions/system",
|
||||
group: TEXT.menu.permissionManagement,
|
||||
icon: "key",
|
||||
keywords: ["permission", "system"],
|
||||
},
|
||||
{
|
||||
label: "项目权限配置",
|
||||
path: "/admin/permissions/project",
|
||||
group: TEXT.menu.permissionManagement,
|
||||
icon: "key",
|
||||
keywords: ["permission", "project"],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
export const buildProjectNavigationItems = (options: {
|
||||
hasCurrentStudy: boolean;
|
||||
hasAnyProjectModuleAccess: boolean;
|
||||
canAccessProjectPath: (path: string) => boolean;
|
||||
}): LayoutNavigationItem[] => {
|
||||
if (!options.hasCurrentStudy || !options.hasAnyProjectModuleAccess) return [];
|
||||
|
||||
const items: LayoutNavigationItem[] = [
|
||||
{
|
||||
label: TEXT.menu.projectOverview,
|
||||
path: "/project/overview",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "dashboard",
|
||||
keywords: ["overview"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.projectMilestones,
|
||||
path: "/project/milestones",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "calendar",
|
||||
keywords: ["milestone"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.feeContracts,
|
||||
path: "/fees/contracts",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "coin",
|
||||
keywords: ["fee", "contract"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.materialManagement,
|
||||
path: "/drug/shipments",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "folder",
|
||||
children: [
|
||||
{
|
||||
label: TEXT.menu.drugShipments,
|
||||
path: "/drug/shipments",
|
||||
group: TEXT.menu.materialManagement,
|
||||
icon: "folder",
|
||||
keywords: ["drug", "shipment"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.materialEquipment,
|
||||
path: "/materials/equipment",
|
||||
group: TEXT.menu.materialManagement,
|
||||
icon: "folder",
|
||||
keywords: ["material", "equipment"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.fileVersionManagement,
|
||||
path: "/file-versions",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "document",
|
||||
keywords: ["document", "file", "version"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.startupFeasibilityEthics,
|
||||
path: "/startup/feasibility-ethics",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "calendar",
|
||||
keywords: ["startup", "ethics"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.startupMeetingAuth,
|
||||
path: "/startup/meeting-auth",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "check",
|
||||
keywords: ["meeting", "training"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.subjects,
|
||||
path: "/subjects",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "subject",
|
||||
keywords: ["subject"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.riskIssues,
|
||||
path: "/risk-issues/sae",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "flag",
|
||||
children: [
|
||||
{
|
||||
label: TEXT.menu.riskIssueSae,
|
||||
path: "/risk-issues/sae",
|
||||
group: TEXT.menu.riskIssues,
|
||||
icon: "flag",
|
||||
keywords: ["sae", "risk"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.riskIssuePd,
|
||||
path: "/risk-issues/pd",
|
||||
group: TEXT.menu.riskIssues,
|
||||
icon: "flag",
|
||||
keywords: ["pd", "risk"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.riskIssueMonitoringVisits,
|
||||
path: "/risk-issues/monitoring-visits",
|
||||
group: TEXT.menu.riskIssues,
|
||||
icon: "flag",
|
||||
keywords: ["monitoring", "visit"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.etmf,
|
||||
path: "/etmf",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "folder",
|
||||
keywords: ["etmf"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.sharedLibrary,
|
||||
path: "/knowledge/medical-consult",
|
||||
group: TEXT.menu.currentProject,
|
||||
icon: "notebook",
|
||||
children: [
|
||||
{
|
||||
label: TEXT.menu.knowledgeMedicalConsult,
|
||||
path: "/knowledge/medical-consult",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "medical"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.knowledgeNotes,
|
||||
path: "/knowledge/precautions",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "note"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.knowledgeSupportFiles,
|
||||
path: "/knowledge/support-files",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "support"],
|
||||
},
|
||||
{
|
||||
label: TEXT.menu.knowledgeInstructionFiles,
|
||||
path: "/knowledge/instruction-files",
|
||||
group: TEXT.menu.sharedLibrary,
|
||||
icon: "notebook",
|
||||
keywords: ["knowledge", "instruction"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return items
|
||||
.map((item) => {
|
||||
if (!item.children) return options.canAccessProjectPath(item.path) ? item : null;
|
||||
const children = item.children.filter((child) => options.canAccessProjectPath(child.path));
|
||||
if (!children.length) return null;
|
||||
return { ...item, path: children[0].path, children };
|
||||
})
|
||||
.filter(Boolean) as LayoutNavigationItem[];
|
||||
};
|
||||
|
||||
export const flattenLayoutNavigationItems = (items: LayoutNavigationItem[]) =>
|
||||
items.flatMap((item) => [item, ...(item.children || [])]);
|
||||
@@ -1,10 +1,11 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import packageInfo from "../../package.json";
|
||||
import { clientRuntime } from "./clientRuntime";
|
||||
import { getAppMetadata } from "./appMetadata";
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("client runtime", () => {
|
||||
@@ -32,4 +33,24 @@ describe("client runtime", () => {
|
||||
expect(clientRuntime.capabilities().serverConfiguration).toBe(true);
|
||||
expect(clientRuntime.capabilities().secureSessionStorage).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts release build metadata only from the expected CI values", () => {
|
||||
vi.stubEnv("VITE_BUILD_CHANNEL", "release");
|
||||
vi.stubEnv("VITE_BUILD_COMMIT", "0123456789abcdef0123456789abcdef01234567");
|
||||
|
||||
expect(getAppMetadata()).toMatchObject({
|
||||
channel: "release",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose arbitrary build metadata strings", () => {
|
||||
vi.stubEnv("VITE_BUILD_CHANNEL", "feature/demo");
|
||||
vi.stubEnv("VITE_BUILD_COMMIT", "token=secret");
|
||||
|
||||
expect(getAppMetadata()).toMatchObject({
|
||||
channel: "local",
|
||||
commit: "local",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,13 +13,17 @@ export interface AppMetadata {
|
||||
}
|
||||
|
||||
const BUILD_CHANNELS = new Set<BuildChannel>(["dev", "main", "release", "local"]);
|
||||
const BUILD_COMMIT_PATTERN = /^[0-9a-f]{7,40}$/i;
|
||||
|
||||
const resolveBuildChannel = (value: string | undefined): BuildChannel =>
|
||||
value && BUILD_CHANNELS.has(value as BuildChannel) ? (value as BuildChannel) : "local";
|
||||
|
||||
const resolveBuildCommit = (value: string | undefined): string =>
|
||||
value && BUILD_COMMIT_PATTERN.test(value) ? value : "local";
|
||||
|
||||
export const getAppMetadata = (): AppMetadata => ({
|
||||
version: packageInfo.version,
|
||||
commit: import.meta.env.VITE_BUILD_COMMIT || "local",
|
||||
commit: resolveBuildCommit(import.meta.env.VITE_BUILD_COMMIT),
|
||||
channel: resolveBuildChannel(import.meta.env.VITE_BUILD_CHANNEL),
|
||||
clientType: isTauriRuntime() ? "desktop" : "web",
|
||||
platform: getRuntimePlatform(),
|
||||
|
||||
@@ -2,9 +2,13 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||
DESKTOP_RECENT_ROUTES_KEY,
|
||||
DESKTOP_THEME_KEY,
|
||||
applyDesktopThemePreference,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopRecentRoutes,
|
||||
readDesktopThemePreference,
|
||||
recordDesktopRecentRoute,
|
||||
setDesktopThemePreference,
|
||||
toggleDesktopFavoriteRoute,
|
||||
} from "./desktopUiPreferences";
|
||||
|
||||
@@ -24,6 +28,8 @@ const installLocalStorageMock = () => {
|
||||
describe("desktop UI preferences", () => {
|
||||
beforeEach(() => {
|
||||
installLocalStorageMock();
|
||||
document.documentElement.removeAttribute("data-ctms-theme");
|
||||
document.documentElement.style.colorScheme = "";
|
||||
});
|
||||
|
||||
it("stores only route metadata for recent desktop routes", () => {
|
||||
@@ -48,4 +54,18 @@ describe("desktop UI preferences", () => {
|
||||
expect(readDesktopFavoriteRoutes()[0].path).toBe("/file-versions");
|
||||
expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no");
|
||||
});
|
||||
|
||||
it("stores and applies only the desktop theme enum", () => {
|
||||
expect(readDesktopThemePreference()).toBe("light");
|
||||
|
||||
setDesktopThemePreference("dark");
|
||||
|
||||
expect(readDesktopThemePreference()).toBe("dark");
|
||||
expect(window.localStorage.getItem(DESKTOP_THEME_KEY)).toBe("dark");
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("dark");
|
||||
expect(document.documentElement.style.colorScheme).toBe("dark");
|
||||
|
||||
applyDesktopThemePreference("light");
|
||||
expect(document.documentElement.getAttribute("data-ctms-theme")).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes";
|
||||
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
|
||||
export const DESKTOP_THEME_KEY = "ctms_desktop_theme";
|
||||
export const DESKTOP_THEME_CHANGED_EVENT = "ctms:desktop-theme-changed";
|
||||
|
||||
export type DesktopThemePreference = "light" | "dark";
|
||||
|
||||
export interface DesktopRoutePreference {
|
||||
path: string;
|
||||
@@ -10,6 +14,8 @@ export interface DesktopRoutePreference {
|
||||
|
||||
const MAX_RECENT_ROUTES = 8;
|
||||
const MAX_FAVORITE_ROUTES = 12;
|
||||
const DEFAULT_DESKTOP_THEME: DesktopThemePreference = "light";
|
||||
const DESKTOP_THEME_ATTRIBUTE = "data-ctms-theme";
|
||||
|
||||
const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
|
||||
@@ -45,6 +51,35 @@ const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) =>
|
||||
window.localStorage.setItem(key, JSON.stringify(routes));
|
||||
};
|
||||
|
||||
const sanitizeDesktopThemePreference = (value: unknown): DesktopThemePreference =>
|
||||
value === "dark" ? "dark" : DEFAULT_DESKTOP_THEME;
|
||||
|
||||
export const readDesktopThemePreference = (): DesktopThemePreference => {
|
||||
if (!isStorageAvailable()) return DEFAULT_DESKTOP_THEME;
|
||||
return sanitizeDesktopThemePreference(window.localStorage.getItem(DESKTOP_THEME_KEY));
|
||||
};
|
||||
|
||||
export const applyDesktopThemePreference = (theme: DesktopThemePreference = readDesktopThemePreference()): DesktopThemePreference => {
|
||||
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.setAttribute(DESKTOP_THEME_ATTRIBUTE, nextTheme);
|
||||
document.documentElement.style.colorScheme = nextTheme;
|
||||
}
|
||||
return nextTheme;
|
||||
};
|
||||
|
||||
export const setDesktopThemePreference = (theme: DesktopThemePreference): DesktopThemePreference => {
|
||||
const nextTheme = sanitizeDesktopThemePreference(theme);
|
||||
if (isStorageAvailable()) {
|
||||
window.localStorage.setItem(DESKTOP_THEME_KEY, nextTheme);
|
||||
}
|
||||
applyDesktopThemePreference(nextTheme);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(DESKTOP_THEME_CHANGED_EVENT, { detail: nextTheme }));
|
||||
}
|
||||
return nextTheme;
|
||||
};
|
||||
|
||||
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
|
||||
|
||||
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
|
||||
|
||||
@@ -11,12 +11,18 @@ export {
|
||||
export {
|
||||
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||
DESKTOP_RECENT_ROUTES_KEY,
|
||||
DESKTOP_THEME_CHANGED_EVENT,
|
||||
DESKTOP_THEME_KEY,
|
||||
applyDesktopThemePreference,
|
||||
isDesktopFavoriteRoute,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopRecentRoutes,
|
||||
readDesktopThemePreference,
|
||||
recordDesktopRecentRoute,
|
||||
setDesktopThemePreference,
|
||||
toggleDesktopFavoriteRoute,
|
||||
type DesktopRoutePreference,
|
||||
type DesktopThemePreference,
|
||||
} from "./desktopUiPreferences";
|
||||
export {
|
||||
DESKTOP_MENU_COMMAND_EVENT,
|
||||
|
||||
@@ -70,6 +70,54 @@
|
||||
--el-text-color-regular: var(--ctms-text-regular);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] {
|
||||
--ctms-primary: #8fb7d4;
|
||||
--ctms-primary-hover: #a7c9df;
|
||||
--ctms-primary-active: #c3dced;
|
||||
--ctms-primary-light: #203145;
|
||||
--ctms-brand-900: #d8e7f4;
|
||||
--ctms-brand-700: #b4d0e5;
|
||||
|
||||
--ctms-success: #7dd3a8;
|
||||
--ctms-warning: #f1c271;
|
||||
--ctms-danger: #f28b8b;
|
||||
--ctms-info: #a6b4c5;
|
||||
|
||||
--ctms-text-main: #f8fafc;
|
||||
--ctms-text-regular: #dbe5f1;
|
||||
--ctms-text-secondary: #9aaabd;
|
||||
--ctms-text-disabled: #66788d;
|
||||
|
||||
--ctms-bg-base: #0f172a;
|
||||
--ctms-bg-card: #172033;
|
||||
--ctms-bg-muted: #111827;
|
||||
--ctms-neutral-100: #182337;
|
||||
--ctms-neutral-300: #334155;
|
||||
--ctms-border-color: #26364a;
|
||||
--ctms-border-color-hover: #3d5068;
|
||||
|
||||
--ctms-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
--ctms-shadow: 0 8px 20px rgba(0, 0, 0, 0.32);
|
||||
--ctms-shadow-md: 0 12px 26px rgba(0, 0, 0, 0.36);
|
||||
--ctms-shadow-lg: 0 20px 40px rgba(0, 0, 0, 0.42);
|
||||
|
||||
--el-color-primary: var(--ctms-primary);
|
||||
--el-color-primary-light-3: #567894;
|
||||
--el-color-primary-light-5: #3c5871;
|
||||
--el-color-primary-light-7: #283c50;
|
||||
--el-color-primary-light-8: #203145;
|
||||
--el-color-primary-dark-2: #c3dced;
|
||||
--el-border-color: var(--ctms-border-color);
|
||||
--el-text-color-primary: var(--ctms-text-main);
|
||||
--el-text-color-regular: var(--ctms-text-regular);
|
||||
--el-bg-color: var(--ctms-bg-card);
|
||||
--el-bg-color-overlay: #172033;
|
||||
--el-fill-color-blank: #172033;
|
||||
--el-fill-color-light: #111827;
|
||||
--el-fill-color-lighter: #0f172a;
|
||||
--el-mask-color: rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
/* 全局重置 */
|
||||
html,
|
||||
body,
|
||||
@@ -85,6 +133,90 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] body {
|
||||
background: var(--ctms-bg-base);
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-button--default {
|
||||
background-color: #172033;
|
||||
border-color: #334155;
|
||||
color: var(--ctms-text-regular);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-button--default:hover {
|
||||
border-color: #4b6580;
|
||||
background-color: #1f2d3d;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-card,
|
||||
:root[data-ctms-theme="dark"] .el-alert,
|
||||
:root[data-ctms-theme="dark"] .el-popper,
|
||||
:root[data-ctms-theme="dark"] .el-dropdown-menu,
|
||||
:root[data-ctms-theme="dark"] .el-popover.el-popper,
|
||||
:root[data-ctms-theme="dark"] .el-dialog {
|
||||
border-color: var(--ctms-border-color);
|
||||
background-color: var(--ctms-bg-card);
|
||||
color: var(--ctms-text-regular);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-card__header {
|
||||
border-bottom-color: var(--ctms-border-color);
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-input__wrapper,
|
||||
:root[data-ctms-theme="dark"] .el-select .el-input__wrapper {
|
||||
background-color: #172033;
|
||||
box-shadow: 0 0 0 1px #334155 inset;
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-input__inner {
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-input__inner::placeholder {
|
||||
color: var(--ctms-text-disabled);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-table {
|
||||
background-color: transparent;
|
||||
color: var(--ctms-text-regular);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-table th.el-table__cell {
|
||||
background-color: #111827;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-table tr,
|
||||
:root[data-ctms-theme="dark"] .el-table td.el-table__cell {
|
||||
background-color: #172033;
|
||||
border-bottom-color: var(--ctms-border-color);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-table--enable-row-hover .el-table__row:hover>td.el-table__cell {
|
||||
background-color: #1f2d3d;
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-dropdown-menu__item {
|
||||
color: var(--ctms-text-regular);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):focus,
|
||||
:root[data-ctms-theme="dark"] .el-dropdown-menu__item:not(.is-disabled):hover {
|
||||
background-color: #1f2d3d;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
:root[data-ctms-theme="dark"] .ctms-state,
|
||||
:root[data-ctms-theme="dark"] .ctms-table-card,
|
||||
:root[data-ctms-theme="dark"] .ctms-section-card {
|
||||
border-color: var(--ctms-border-color);
|
||||
background: var(--ctms-bg-card);
|
||||
}
|
||||
|
||||
/* Element Plus 全局覆盖 */
|
||||
|
||||
/* 按钮美化 */
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<header class="preferences-header">
|
||||
<div>
|
||||
<p class="preferences-kicker">CTMS Desktop</p>
|
||||
<h3>桌面偏好</h3>
|
||||
<h3>系统偏好</h3>
|
||||
</div>
|
||||
<el-button text :icon="Close" aria-label="关闭桌面偏好" @click="emit('close-request')" />
|
||||
<el-button text :icon="Close" aria-label="关闭系统偏好" @click="emit('close-request')" />
|
||||
</header>
|
||||
|
||||
<section class="preference-section">
|
||||
@@ -17,6 +17,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preference-section">
|
||||
<div class="section-title">外观</div>
|
||||
<div class="preference-row">
|
||||
<div>
|
||||
<div class="row-title">界面主题</div>
|
||||
<div class="row-desc">切换桌面工作台与系统弹窗的明暗配色</div>
|
||||
</div>
|
||||
<div class="theme-segmented" role="group" aria-label="界面主题">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="theme-option"
|
||||
:class="{ active: desktopTheme === option.value }"
|
||||
@click="setTheme(option.value)"
|
||||
>
|
||||
<el-icon><component :is="option.icon" /></el-icon>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preference-section">
|
||||
<div class="section-title">通知与更新</div>
|
||||
<div class="preference-row">
|
||||
@@ -63,7 +86,7 @@
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
import { Close, Moon, Sunny } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getDesktopNotificationSubscription,
|
||||
setDesktopNotificationSubscription,
|
||||
@@ -74,7 +97,10 @@ import {
|
||||
getDesktopServerUrl,
|
||||
getNotificationPermission,
|
||||
isDesktopUpdaterAvailable,
|
||||
readDesktopThemePreference,
|
||||
requestNotificationPermission,
|
||||
setDesktopThemePreference,
|
||||
type DesktopThemePreference,
|
||||
type NotificationPermissionState,
|
||||
} from "../runtime";
|
||||
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||
@@ -92,13 +118,19 @@ const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
||||
const desktopNotificationsEnabled = ref(false);
|
||||
const desktopNotificationLoading = ref(false);
|
||||
const desktopUpdateChecking = ref(false);
|
||||
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
|
||||
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
||||
const themeOptions = [
|
||||
{ value: "light" as const, label: "明亮", icon: Sunny },
|
||||
{ value: "dark" as const, label: "暗黑", icon: Moon },
|
||||
];
|
||||
|
||||
const clientMetadataRows = computed(() => [
|
||||
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
|
||||
{ label: "平台", value: clientMetadata.platform },
|
||||
{ label: "构建通道", value: clientMetadata.channel },
|
||||
{ label: "提交", value: clientMetadata.commit },
|
||||
{ label: "主题", value: desktopTheme.value === "dark" ? "暗黑" : "明亮" },
|
||||
{ label: "服务器", value: desktopServerUrl.value || "未配置" },
|
||||
{
|
||||
label: "能力",
|
||||
@@ -168,6 +200,10 @@ const checkDesktopUpdateNow = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const setTheme = (theme: DesktopThemePreference) => {
|
||||
desktopTheme.value = setDesktopThemePreference(theme);
|
||||
};
|
||||
|
||||
const copyClientMetadata = async () => {
|
||||
const text = clientMetadataRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
|
||||
await navigator.clipboard?.writeText(text);
|
||||
@@ -273,6 +309,45 @@ code {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-segmented {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, minmax(76px, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid #dbe6f2;
|
||||
border-radius: 9px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 76px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
background: #ffffff;
|
||||
color: #24496f;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.metadata-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -299,4 +374,56 @@ dd {
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .desktop-preferences {
|
||||
color: #e5edf7;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .preferences-kicker {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) h3,
|
||||
:global([data-ctms-theme="dark"]) .row-title,
|
||||
:global([data-ctms-theme="dark"]) dd,
|
||||
:global([data-ctms-theme="dark"]) code {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .section-title,
|
||||
:global([data-ctms-theme="dark"]) .server-label,
|
||||
:global([data-ctms-theme="dark"]) .row-desc,
|
||||
:global([data-ctms-theme="dark"]) dt {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .server-card,
|
||||
:global([data-ctms-theme="dark"]) .preference-row,
|
||||
:global([data-ctms-theme="dark"]) .metadata-panel {
|
||||
border-color: #26364a;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-segmented {
|
||||
border-color: #26364a;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-option {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-option:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-option.active {
|
||||
background: #243247;
|
||||
color: #bfdbfe;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -95,4 +95,16 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
||||
expect(source).toContain("max-width: calc(100vw - 40px);");
|
||||
});
|
||||
|
||||
it("uses only server-configured email domains on web and desktop", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain('fetchEmailDomains()');
|
||||
expect(source).toContain(':disabled="availableEmailDomains.length === 0"');
|
||||
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
|
||||
expect(source).toContain("configuredEmailDomains.value.includes(domain)");
|
||||
expect(source).not.toContain("preservedEmailDomain");
|
||||
expect(source).not.toContain("showDomainSelect");
|
||||
expect(source).not.toContain("email-domain-input");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,22 +159,14 @@
|
||||
<span class="email-at-sign">@</span>
|
||||
<div class="email-domain-wrapper">
|
||||
<select
|
||||
v-if="showDomainSelect"
|
||||
v-model="form.emailDomain"
|
||||
class="email-domain-field"
|
||||
aria-label="邮箱域名"
|
||||
:disabled="availableEmailDomains.length === 0"
|
||||
>
|
||||
<option v-for="domain in availableEmailDomains" :key="domain" :value="domain">{{ domain }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model.trim="form.emailDomain"
|
||||
type="text"
|
||||
class="email-domain-field email-domain-input"
|
||||
placeholder="邮箱域名"
|
||||
aria-label="邮箱域名"
|
||||
/>
|
||||
<svg v-if="showDomainSelect" class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
|
||||
<svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -201,12 +193,9 @@
|
||||
<span>登 录</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 注册与忘记密码(左右分立) -->
|
||||
<!-- 忘记密码与注册 -->
|
||||
<div class="forgot-register-row">
|
||||
<RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink>
|
||||
<RouterLink v-if="showDesktopServerSettings" to="/desktop/server-settings" class="server-settings-link">
|
||||
服务器设置
|
||||
</RouterLink>
|
||||
<RouterLink to="/register" class="register-link">新用户注册</RouterLink>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -282,7 +271,6 @@ const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false });
|
||||
const configuredEmailDomains = ref<string[]>([]);
|
||||
const preservedEmailDomain = ref("");
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
email: [
|
||||
@@ -305,13 +293,7 @@ const refreshDesktopServerUrl = () => {
|
||||
};
|
||||
|
||||
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
||||
const availableEmailDomains = computed(() => Array.from(new Set([
|
||||
...configuredEmailDomains.value,
|
||||
preservedEmailDomain.value,
|
||||
].filter(Boolean))));
|
||||
const showDomainSelect = computed(
|
||||
() => configuredEmailDomains.value.length > 0 || Boolean(preservedEmailDomain.value)
|
||||
);
|
||||
const availableEmailDomains = computed(() => configuredEmailDomains.value);
|
||||
|
||||
const syncEmailFromParts = () => {
|
||||
const local = form.emailLocal.trim().toLowerCase();
|
||||
@@ -325,14 +307,13 @@ const applyEmailValue = (email: string) => {
|
||||
const separator = normalized.lastIndexOf("@");
|
||||
if (separator > 0) {
|
||||
form.emailLocal = normalized.slice(0, separator);
|
||||
form.emailDomain = normalizeDomain(normalized.slice(separator + 1));
|
||||
preservedEmailDomain.value = configuredEmailDomains.value.includes(form.emailDomain)
|
||||
? ""
|
||||
: form.emailDomain;
|
||||
const domain = normalizeDomain(normalized.slice(separator + 1));
|
||||
form.emailDomain = configuredEmailDomains.value.includes(domain)
|
||||
? domain
|
||||
: configuredEmailDomains.value[0] || "";
|
||||
} else {
|
||||
form.emailLocal = normalized;
|
||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||
preservedEmailDomain.value = "";
|
||||
}
|
||||
syncEmailFromParts();
|
||||
};
|
||||
@@ -343,8 +324,12 @@ const loadEmailDomains = async () => {
|
||||
configuredEmailDomains.value = Array.from(new Set(
|
||||
data.items.map(normalizeDomain).filter(Boolean)
|
||||
));
|
||||
if (!configuredEmailDomains.value.includes(form.emailDomain)) {
|
||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||
}
|
||||
} catch {
|
||||
configuredEmailDomains.value = [];
|
||||
form.emailDomain = "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -980,8 +965,7 @@ const onSubmit = async () => {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.email-local-field::placeholder,
|
||||
.email-domain-input::placeholder {
|
||||
.email-local-field::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@@ -1013,10 +997,9 @@ const onSubmit = async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.email-domain-input {
|
||||
width: 150px;
|
||||
padding-right: 4px;
|
||||
cursor: text;
|
||||
.email-domain-field:disabled {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.email-domain-chevron {
|
||||
@@ -1101,7 +1084,7 @@ const onSubmit = async () => {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.forgot-link, .register-link, .server-settings-link {
|
||||
.forgot-link, .register-link {
|
||||
font-size: 13px;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
@@ -1109,7 +1092,7 @@ const onSubmit = async () => {
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.forgot-link:hover, .register-link:hover, .server-settings-link:hover {
|
||||
.forgot-link:hover, .register-link:hover {
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -449,12 +449,14 @@ const loadEmailDomains = async () => {
|
||||
try {
|
||||
const { data } = await fetchEmailDomains();
|
||||
emailDomains.value = normalizeDomains(data.items);
|
||||
if (!form.emailDomain && emailDomains.value.length > 0) {
|
||||
form.emailDomain = emailDomains.value[0];
|
||||
syncEmailFromParts();
|
||||
if (!emailDomains.value.includes(form.emailDomain)) {
|
||||
form.emailDomain = emailDomains.value[0] || "";
|
||||
}
|
||||
syncEmailFromParts();
|
||||
} catch {
|
||||
emailDomains.value = [];
|
||||
form.emailDomain = "";
|
||||
syncEmailFromParts();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ import { resolve } from "node:path";
|
||||
|
||||
const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "utf8");
|
||||
const readRouter = () => readFileSync(resolve(__dirname, "../../router/index.ts"), "utf8");
|
||||
const readLayout = () => readFileSync(resolve(__dirname, "../../components/Layout.vue"), "utf8");
|
||||
const readLayout = () => [
|
||||
"../../components/WebLayout.vue",
|
||||
"../../components/DesktopLayout.vue",
|
||||
"../../components/layout/navigation.ts",
|
||||
].map(path => readFileSync(resolve(__dirname, path), "utf8")).join("\n");
|
||||
|
||||
describe("project management access", () => {
|
||||
it("shows project management to all signed-in users while keeping system operations admin-only", () => {
|
||||
|
||||
@@ -2,8 +2,9 @@ import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET || "http://backend:8000";
|
||||
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT || "");
|
||||
const defaultDevApiProxyTarget = hmrClientPort ? "http://backend:8000" : "http://127.0.0.1:8000";
|
||||
const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET || defaultDevApiProxyTarget;
|
||||
const tauriDevHost = process.env.TAURI_DEV_HOST;
|
||||
const tauriPlatform = process.env.TAURI_ENV_PLATFORM;
|
||||
const tauriDebug = process.env.TAURI_ENV_DEBUG === "true";
|
||||
|
||||
Reference in New Issue
Block a user