完善桌面端界面、发布检查与邮箱域名同步
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-01 10:49:31 +08:00
parent c923f887a0
commit 9cac75e85c
32 changed files with 5392 additions and 2684 deletions
+2
View File
@@ -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.");
}
+4
View File
@@ -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
+17 -5
View File
@@ -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 || [])]);
+22 -1
View File
@@ -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",
});
});
});
+5 -1
View File
@@ -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);
+6
View File
@@ -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,
+132
View File
@@ -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 全局覆盖 */
/* 按钮美化 */
+130 -3
View File
@@ -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>
+12
View File
@@ -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");
});
});
+18 -35
View File
@@ -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;
}
+5 -3
View File
@@ -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();
}
};
+5 -1
View File
@@ -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 -1
View File
@@ -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";