发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-01 10:53:24 +08:00
parent b283cf1e5c
commit b491b6a146
132 changed files with 17337 additions and 2375 deletions
+122
View File
@@ -0,0 +1,122 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const paths = {
packageJson: new URL("../package.json", import.meta.url),
packageLock: new URL("../package-lock.json", import.meta.url),
tauriConfig: new URL("../src-tauri/tauri.conf.json", import.meta.url),
cargoToml: new URL("../src-tauri/Cargo.toml", import.meta.url),
cargoLock: new URL("../src-tauri/Cargo.lock", import.meta.url),
};
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
const writeJson = async (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
const readCargoPackageVersion = async () => {
const cargo = await readFile(paths.cargoToml, "utf8");
const packageSection = cargo.match(/\[package\]([\s\S]*?)(?=\n\[|$)/)?.[1];
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) throw new Error("Cannot find [package].version in src-tauri/Cargo.toml");
return version;
};
const readCargoLockPackageVersion = async () => {
const cargoLock = await readFile(paths.cargoLock, "utf8");
const packageSection = cargoLock
.match(/\[\[package\]\]([\s\S]*?)(?=\n\[\[package\]\]|$)/g)
?.find((section) => /^name\s*=\s*"ctms-desktop"$/m.test(section));
const version = packageSection?.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (!version) throw new Error("Cannot find ctms-desktop version in src-tauri/Cargo.lock");
return version;
};
const readVersions = async () => {
const [packageJson, packageLock, tauriConfig, cargoVersion, cargoLockVersion] = await Promise.all([
readJson(paths.packageJson),
readJson(paths.packageLock),
readJson(paths.tauriConfig),
readCargoPackageVersion(),
readCargoLockPackageVersion(),
]);
return {
"package.json": packageJson.version,
"package-lock.json": packageLock.version,
"package-lock.json root": packageLock.packages?.[""]?.version,
"tauri.conf.json": tauriConfig.version,
"Cargo.toml": cargoVersion,
"Cargo.lock": cargoLockVersion,
};
};
const assertVersionsMatch = async () => {
const versions = await readVersions();
const uniqueVersions = new Set(Object.values(versions));
if (uniqueVersions.size !== 1 || uniqueVersions.has(undefined)) {
const details = Object.entries(versions)
.map(([file, version]) => ` ${file}: ${version ?? "<missing>"}`)
.join("\n");
throw new Error(`Client versions are not synchronized:\n${details}`);
}
const [version] = uniqueVersions;
console.log(`Client version ${version} is synchronized.`);
};
const setVersion = async (version) => {
if (!SEMVER_PATTERN.test(version)) {
throw new Error(`Invalid semantic version: ${version}`);
}
const [packageJson, packageLock, tauriConfig, tauriConfigSource, cargo, cargoLock] = await Promise.all([
readJson(paths.packageJson),
readJson(paths.packageLock),
readJson(paths.tauriConfig),
readFile(paths.tauriConfig, "utf8"),
readFile(paths.cargoToml, "utf8"),
readFile(paths.cargoLock, "utf8"),
]);
packageJson.version = version;
packageLock.version = version;
packageLock.packages[""].version = version;
const tauriVersionPattern = /("version"\s*:\s*")[^"]+(")/;
const packageSectionPattern = /(\[package\][\s\S]*?^version\s*=\s*")[^"]+(")/m;
const lockPackagePattern =
/(\[\[package\]\]\nname\s*=\s*"ctms-desktop"\nversion\s*=\s*")[^"]+(")/m;
if (typeof tauriConfig.version !== "string" || !tauriVersionPattern.test(tauriConfigSource)) {
throw new Error("Cannot update version in src-tauri/tauri.conf.json");
}
if (!packageSectionPattern.test(cargo)) {
throw new Error("Cannot update [package].version in src-tauri/Cargo.toml");
}
if (!lockPackagePattern.test(cargoLock)) {
throw new Error("Cannot update ctms-desktop version in src-tauri/Cargo.lock");
}
await Promise.all([
writeJson(paths.packageJson, packageJson),
writeJson(paths.packageLock, packageLock),
writeFile(paths.tauriConfig, tauriConfigSource.replace(tauriVersionPattern, `$1${version}$2`)),
writeFile(paths.cargoToml, cargo.replace(packageSectionPattern, `$1${version}$2`)),
writeFile(paths.cargoLock, cargoLock.replace(lockPackagePattern, `$1${version}$2`)),
]);
console.log(`Updated CTMS Web and Desktop client version to ${version} in ${frontendDir}`);
await assertVersionsMatch();
};
const [, , command = "--check", value] = process.argv;
try {
if (command === "--check") {
await assertVersionsMatch();
} else if (command === "--set" && value) {
await setVersion(value);
} else {
throw new Error("Usage: node scripts/client-version.mjs [--check | --set <semver>]");
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
+218
View File
@@ -0,0 +1,218 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const rootDir = resolve(frontendDir, "..");
const sourceDir = resolve(frontendDir, "src");
const tauriDir = resolve(frontendDir, "src-tauri");
const failures = [];
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
return (
await Promise.all(
entries.map(async (entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? walk(path) : path;
}),
)
).flat();
};
const permissionIdentifier = (permission) =>
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
const assertPathScope = (permission, expectedPrefix, description) => {
const allow = Array.isArray(permission.allow) ? permission.allow : [];
assert(allow.length > 0, `${description} must define an explicit allow list.`);
for (const item of allow) {
const path = item?.path;
assert(
typeof path === "string" && path.startsWith(expectedPrefix),
`${description} may only allow paths under ${expectedPrefix}; found ${path ?? "<missing>"}.`,
);
}
};
const verifyTauriConfig = async () => {
const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json"));
const targets = tauriConfig.bundle?.targets;
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
const csp = tauriConfig.app?.security?.csp || "";
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
const mainWindow = tauriConfig.app?.windows?.find((window) => window.label === "main") || tauriConfig.app?.windows?.[0];
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
assert(
tauriConfig.bundle?.createUpdaterArtifacts === true,
"Tauri must create updater artifacts for signed desktop release builds.",
);
assert(
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
"Tauri updater public key must be configured.",
);
assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self.");
assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src.");
assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval.");
assert(
!cspTokens.some((token) => token === "*" || token.includes("://*")),
"Tauri CSP must not use wildcard sources.",
);
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
assert(mainWindow?.minWidth === 1180, "Main desktop window must keep the minimum width at 1180.");
assert(mainWindow?.minHeight === 760, "Main desktop window must keep the minimum height at 760.");
};
const verifyCapabilities = async () => {
const capabilitiesDir = resolve(tauriDir, "capabilities");
const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json"));
assert(files.length > 0, "At least one Tauri capability file must exist.");
const bannedPermissions = new Set([
"shell:default",
"shell:allow-open",
"shell:allow-execute",
"fs:default",
"fs:allow-read-dir",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
]);
for (const file of files) {
const capability = await readJson(resolve(capabilitiesDir, file));
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
for (const identifier of identifiers) {
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
}
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
if (fsScope && typeof fsScope !== "string") {
assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`);
}
const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path");
assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`);
if (openerScope && typeof openerScope !== "string") {
assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`);
}
}
};
const verifyRustBoundary = async () => {
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
for (const token of forbiddenRust) {
assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`);
}
const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init");
const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init");
assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered.");
assert(
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
"Single-instance plugin must be registered before other desktop plugins.",
);
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
const allowedCommands = [
"credentials::credential_get",
"credentials::credential_set",
"credentials::credential_delete",
"updates::desktop_update_check",
"updates::desktop_update_install",
];
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
const missing = allowedCommands.filter((command) => !commands.includes(command));
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
};
const verifySourceSafety = async () => {
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
const files = [
...(await walk(sourceDir)),
...(await walk(resolve(tauriDir, "src"))),
].filter((path) => sourceExtensions.has(extname(path)));
for (const path of files) {
const source = await readFile(path, "utf8");
const file = relative(rootDir, path);
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
assert(
!/console\.(log|debug|info|warn|error)\s*\([^)]*token/i.test(source),
`${file}: token-related values must not be written to console logs.`,
);
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
}
if (source.includes("sendNotification") && file !== "frontend/src/runtime/notifications.ts") {
fail(`${file}: system notifications must be routed through frontend/src/runtime/notifications.ts.`);
}
}
};
const verifyNotificationBoundary = async () => {
const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8");
assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic.");
assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic.");
assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content.");
};
const verifyUpdaterBoundary = async () => {
const source = await readFile(resolve(tauriDir, "src/updates.rs"), "utf8");
assert(source.includes('join("desktop-updates/stable/latest.json")'), "Desktop updater must derive the fixed stable latest.json path.");
assert(source.includes("desktop updates require HTTPS outside localhost"), "Desktop updater must reject non-local HTTP update feeds.");
assert(source.includes("server origin must not include credentials"), "Desktop updater must reject server origins that include credentials.");
};
const verifyWorkflowGates = async () => {
const workflow = await readFile(resolve(rootDir, ".github/workflows/client-quality-gates.yml"), "utf8");
const requiredCommands = [
"npm run version:check",
"npm run runtime:check",
"npm run desktop:release:check",
"npm run ui:contract",
"npm run type-check",
"npm run test:unit",
"npm run build",
"npm run desktop:build:app",
"npm run release:env:check",
];
for (const command of requiredCommands) {
assert(workflow.includes(command), `Client quality gates workflow must run ${command}.`);
}
assert(workflow.includes("VITE_BUILD_CHANNEL"), "Client quality gates workflow must inject VITE_BUILD_CHANNEL.");
assert(workflow.includes("VITE_BUILD_COMMIT"), "Client quality gates workflow must inject VITE_BUILD_COMMIT.");
};
await verifyTauriConfig();
await verifyCapabilities();
await verifyRustBoundary();
await verifySourceSafety();
await verifyNotificationBoundary();
await verifyUpdaterBoundary();
await verifyWorkflowGates();
if (failures.length > 0) {
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop release gate passed.");
}
@@ -0,0 +1,98 @@
import { access, readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const args = process.argv.slice(2);
const optionValue = (name) => {
const index = args.indexOf(name);
return index >= 0 ? args[index + 1] : undefined;
};
const feedPath = resolve(
frontendDir,
optionValue("--feed") || process.env.DESKTOP_UPDATE_FEED || "src-tauri/target/release/bundle/latest.json",
);
const artifactDir = optionValue("--artifacts-dir") || process.env.DESKTOP_UPDATE_ARTIFACTS_DIR;
const expectedBaseUrl = optionValue("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL;
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const assertFileExists = async (path, description) => {
try {
await access(path);
} catch {
fail(`${description} does not exist: ${path}`);
}
};
let feed;
try {
feed = JSON.parse(await readFile(feedPath, "utf8"));
} catch (error) {
fail(`Cannot read desktop update feed ${feedPath}: ${error.message}`);
}
if (feed) {
const normalizedFeedVersion = String(feed.version || "").replace(/^v/, "");
const platforms = feed.platforms || {};
const darwinArm = platforms["darwin-aarch64"];
const darwinIntel = platforms["darwin-x86_64"];
assert(normalizedFeedVersion === packageInfo.version, `latest.json version must match package version ${packageInfo.version}.`);
assert(Boolean(feed.pub_date || feed.pubDate), "latest.json must include a publication date.");
assert(Boolean(darwinArm), "latest.json must include darwin-aarch64.");
assert(Boolean(darwinIntel), "latest.json must include darwin-x86_64.");
const entries = [
["darwin-aarch64", darwinArm],
["darwin-x86_64", darwinIntel],
];
for (const [platform, entry] of entries) {
const rawUrl = entry?.url;
const signature = entry?.signature;
assert(typeof signature === "string" && signature.length > 80, `${platform} must include an updater signature.`);
assert(typeof rawUrl === "string" && rawUrl.length > 0, `${platform} must include an artifact URL.`);
if (!rawUrl) continue;
let url;
try {
url = new URL(rawUrl);
} catch {
fail(`${platform} artifact URL is invalid: ${rawUrl}`);
continue;
}
assert(url.protocol === "https:", `${platform} artifact URL must use HTTPS.`);
assert(!/[?&]token=/i.test(url.search), `${platform} artifact URL must not contain token query parameters.`);
assert(!url.pathname.endsWith("/latest.json"), `${platform} artifact URL must point to an immutable artifact, not latest.json.`);
assert(url.pathname.includes(packageInfo.version), `${platform} artifact URL must include version ${packageInfo.version}.`);
if (expectedBaseUrl) {
assert(rawUrl.startsWith(expectedBaseUrl), `${platform} artifact URL must start with ${expectedBaseUrl}.`);
}
if (artifactDir) {
const artifactPath = resolve(artifactDir, basename(url.pathname));
await assertFileExists(artifactPath, `${platform} updater artifact`);
await assertFileExists(`${artifactPath}.sig`, `${platform} updater artifact signature`);
}
}
if (darwinArm?.url && darwinIntel?.url) {
assert(darwinArm.url === darwinIntel.url, "Universal macOS latest.json must point both darwin architectures at the same artifact.");
}
}
if (failures.length > 0) {
console.error(`Desktop update feed check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Desktop update feed check passed.");
}
@@ -0,0 +1,66 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8"));
const failures = [];
const allowedChannels = new Set(["dev", "main", "release", "local"]);
const fullShaPattern = /^[0-9a-f]{40}$/i;
const semverTag = `v${packageInfo.version}`;
const env = process.env;
const channel = env.VITE_BUILD_CHANNEL || "local";
const commit = env.VITE_BUILD_COMMIT || "local";
const isCi = env.CI === "true" || env.GITHUB_ACTIONS === "true";
const isTagBuild = env.GITHUB_REF_TYPE === "tag";
const isReleaseBuild = env.RELEASE_BUILD === "true" || isTagBuild || channel === "release";
const fail = (message) => failures.push(message);
const assert = (condition, message) => {
if (!condition) fail(message);
};
const requireEnv = (name) => {
assert(Boolean(env[name]), `${name} must be configured for signed desktop release builds.`);
};
assert(allowedChannels.has(channel), `VITE_BUILD_CHANNEL must be one of ${[...allowedChannels].join(", ")}.`);
if (isCi || isReleaseBuild) {
assert(channel !== "local", "CI and release builds must inject VITE_BUILD_CHANNEL.");
assert(fullShaPattern.test(commit), "CI and release builds must inject a full 40-character VITE_BUILD_COMMIT.");
}
if (env.GITHUB_SHA) {
assert(
commit === env.GITHUB_SHA || commit === "local",
"VITE_BUILD_COMMIT must match GITHUB_SHA when GitHub Actions provides a source commit.",
);
}
if (isTagBuild) {
assert(env.GITHUB_REF_NAME === semverTag, `Release tag must be ${semverTag}; found ${env.GITHUB_REF_NAME || "<missing>"}.`);
assert(channel === "release", "Release tag builds must set VITE_BUILD_CHANNEL=release.");
}
if (env.REQUIRE_DESKTOP_SIGNING === "true") {
assert(process.platform === "darwin", "Signed macOS desktop release builds must run on macOS.");
requireEnv("TAURI_SIGNING_PRIVATE_KEY");
requireEnv("TAURI_SIGNING_PRIVATE_KEY_PASSWORD");
requireEnv("APPLE_ID");
requireEnv("APPLE_PASSWORD");
requireEnv("APPLE_TEAM_ID");
assert(
Boolean(env.APPLE_CERTIFICATE || env.APPLE_SIGNING_IDENTITY),
"APPLE_CERTIFICATE or APPLE_SIGNING_IDENTITY must be configured for macOS signing.",
);
}
if (failures.length > 0) {
console.error(`Release build environment check failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Release build environment check passed.");
}
@@ -0,0 +1,41 @@
import { readdir, readFile } from "node:fs/promises";
import { extname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
const sourceDir = resolve(frontendDir, "src");
const runtimeDir = resolve(sourceDir, "runtime");
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx"]);
const violations = [];
const walk = async (directory) => {
const entries = await readdir(directory, { withFileTypes: true });
return (
await Promise.all(
entries.map(async (entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? walk(path) : path;
}),
)
).flat();
};
for (const path of await walk(sourceDir)) {
if (!sourceExtensions.has(extname(path)) || path.startsWith(`${runtimeDir}/`)) continue;
const source = await readFile(path, "utf8");
const file = relative(frontendDir, path);
if (source.includes("@tauri-apps/") || source.includes("__TAURI")) {
violations.push(`${file}: direct Tauri access is only allowed inside src/runtime`);
}
if (/from\s+["'][^"']*\/runtime\/[^"']+["']/.test(source)) {
violations.push(`${file}: import platform behavior through src/runtime/index.ts`);
}
}
if (violations.length > 0) {
console.error(`Runtime boundary violations:\n${violations.map((item) => ` ${item}`).join("\n")}`);
process.exitCode = 1;
} else {
console.log("Runtime boundary is respected.");
}
+55 -22
View File
@@ -32,38 +32,71 @@ const missing = [
];
const pageContractChecks = [
"src/views/ia/ProjectMilestones.vue",
"src/views/ia/SubjectManagement.vue",
"src/views/ia/RiskIssueSae.vue",
"src/views/ia/RiskIssuePd.vue",
"src/views/ia/RiskIssueMonitoringVisits.vue"
{
file: "src/views/ia/ProjectMilestones.vue",
groups: [
["ctms-page-shell", "page"],
["unified-action-bar", "table-card-toolbar"],
["ctms-table-card", "table-card"]
]
},
{
file: "src/views/ia/SubjectManagement.vue",
groups: [
["ctms-page-shell", "page"],
["unified-action-bar", "table-card-toolbar"],
["ctms-table-card", "table-card"],
["subject-table"]
]
},
{
file: "src/views/ia/RiskIssueSae.vue",
groups: [
["ctms-page-shell", "page"],
["unified-action-bar", "table-card-toolbar"],
["ctms-table-card", "table-card"],
["risk-table"]
]
},
{
file: "src/views/ia/RiskIssuePd.vue",
groups: [
["ctms-page-shell", "page"],
["unified-action-bar", "table-card-toolbar"],
["ctms-table-card", "table-card"],
["risk-table"]
]
},
{
file: "src/views/ia/RiskIssueMonitoringVisits.vue",
groups: [
["ctms-page-shell", "page"],
["unified-action-bar", "monitoring-toolbar", "template-toolbar", "toolbar"],
["ctms-table-card", "table-card", "monitoring-table", "issue-table-panel"],
["issue-table"]
]
}
];
const requiredPageClasses = [
"ctms-page-shell",
"unified-action-bar",
"ctms-table-card"
];
for (const file of pageContractChecks) {
for (const { file, groups } of pageContractChecks) {
const content = readFileSync(file, "utf8");
for (const className of requiredPageClasses) {
if (!content.includes(className)) {
missing.push(`${file}:${className}`);
for (const group of groups) {
if (!group.some((className) => content.includes(className))) {
missing.push(`${file}:${group.join("|")}`);
}
}
}
const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8");
const requiredOverviewClasses = [
"ctms-page-shell",
"kpi",
"unified-section"
const requiredOverviewGroups = [
["ctms-page-shell", "page"],
["kpi", "overview-card"],
["unified-section", "overview-container"]
];
for (const className of requiredOverviewClasses) {
if (!overview.includes(className)) {
missing.push(`src/views/ia/ProjectOverview.vue:${className}`);
for (const group of requiredOverviewGroups) {
if (!group.some((className) => overview.includes(className))) {
missing.push(`src/views/ia/ProjectOverview.vue:${group.join("|")}`);
}
}