ERP 轨道加载中...
桥接轨道加载中...
) : erpTrack ? (
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx
index d2fb156c..df721875 100644
--- a/client/src/pages/ReimbursementDetailPage.tsx
+++ b/client/src/pages/ReimbursementDetailPage.tsx
@@ -252,7 +252,7 @@ export function ReimbursementDetailPage() {
]);
setActionError("");
const suffix = result.requeued ? "已重入队" : result.duplicated ? "已复用现有队列" : "已入队";
- toast.success(`ERP 导出${suffix}`);
+ toast.success(`财务桥接出队${suffix}`);
},
onError: (error) => {
if (isApiError(error)) {
@@ -268,8 +268,8 @@ export function ReimbursementDetailPage() {
return;
}
- setActionError("ERP 导出失败,请稍后重试");
- toast.error("ERP 导出失败,请稍后重试");
+ setActionError("财务桥接出队失败,请稍后重试");
+ toast.error("财务桥接出队失败,请稍后重试");
},
});
@@ -461,11 +461,11 @@ export function ReimbursementDetailPage() {
-
ERP 导出
-
已付款报销单会按标准凭证切片通过 Outbox 异步发送到 ERP。
+
财务桥接
+
已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。
@@ -478,7 +478,7 @@ export function ReimbursementDetailPage() {
erpPreviewQuery.error.details,
erpPreviewQuery.error.message,
)}`
- : "加载 ERP 导出预览失败"}
+ : "加载财务桥接预览失败"}
) : null}
diff --git a/client/src/pages/SystemSettingsPage.tsx b/client/src/pages/SystemSettingsPage.tsx
index bacb91ae..d36138c5 100644
--- a/client/src/pages/SystemSettingsPage.tsx
+++ b/client/src/pages/SystemSettingsPage.tsx
@@ -7,7 +7,7 @@ const ITEMS = [
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
- { to: "/system/integrations/erp", title: "ERP 集成", description: "查看备用集成能力与出入口。" },
+ { to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
];
export function SystemSettingsPage() {
diff --git a/client/tests/e2e/announcements-presence.spec.ts b/client/tests/e2e/announcements-presence.spec.ts
new file mode 100644
index 00000000..6be125f0
--- /dev/null
+++ b/client/tests/e2e/announcements-presence.spec.ts
@@ -0,0 +1,56 @@
+import { expect, test } from "@playwright/test";
+
+import { EMPLOYEE, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
+import { TEST_IDS } from "./helpers/testIds";
+
+const ADMIN = {
+ identifier: "admin@hyinda.com",
+ password: "AdminPassword1234",
+ name: "Admin",
+};
+
+test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇总", async ({ page }) => {
+ const adminClient = await createAuthenticatedApiClient(ADMIN);
+ const suffix = Date.now();
+ const announcementTitle = `Playwright 公告 ${suffix}`;
+ const presenceNote = `下午远程处理发布回归 ${suffix}`;
+
+ try {
+ await apiPostData<{ item: { id: string } }>(adminClient.api, "/api/announcements", {
+ title: announcementTitle,
+ summary: "用于验证公告可见性与 Portal 第二阅读层展示。",
+ enabled: true,
+ priority: 1,
+ });
+
+ await login(page, EMPLOYEE);
+
+ await page.goto("/announcements");
+ await expect(page.getByRole("heading", { name: "公告" })).toBeVisible();
+ await expect(page.getByText(announcementTitle)).toBeVisible();
+
+ await page.goto("/team");
+ await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
+ await page.locator("select.select-std").first().selectOption("remote");
+ await page.getByPlaceholder("可选备注,如下午外出办事").fill(presenceNote);
+ await page.getByRole("button", { name: "保存状态" }).click();
+ await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 });
+ await page.goto("/portal");
+ await expect(page.getByText(announcementTitle)).toBeVisible();
+ await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible();
+
+ await logout(page, EMPLOYEE.name);
+
+ await login(page, ADMIN);
+ await page.goto("/team");
+ await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible();
+ await page.getByRole("button", { name: "不在岗" }).click();
+ await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible();
+
+ await page.goto("/portal");
+ await expect(page.getByText(announcementTitle)).toBeVisible();
+ await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible();
+ } finally {
+ await adminClient.dispose();
+ }
+});
diff --git a/client/tests/e2e/helpers/auth.ts b/client/tests/e2e/helpers/auth.ts
index 625b7c3a..b7e64101 100644
--- a/client/tests/e2e/helpers/auth.ts
+++ b/client/tests/e2e/helpers/auth.ts
@@ -1,5 +1,7 @@
import { expect, request as playwrightRequest, type APIRequestContext, type Page } from "@playwright/test";
+import { TEST_IDS } from "./testIds";
+
export const E2E_BASE_URL = process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000";
export type E2EUser = {
@@ -74,15 +76,22 @@ async function readJson
(response: { ok(): boolean; status(): number; text():
export async function login(page: Page, user: E2EUser) {
await page.goto("/login");
- await page.getByTestId("login-identifier").fill(user.identifier);
- await page.getByTestId("login-password").fill(user.password);
- await page.getByTestId("login-submit").click();
+ await page.getByTestId(TEST_IDS.login.identifier).fill(user.identifier);
+ await page.getByTestId(TEST_IDS.login.password).fill(user.password);
+ await page.getByTestId(TEST_IDS.login.submit).click();
await expect(page).toHaveURL(/\/portal/);
}
-export async function logout(page: Page, userDisplayName: string) {
+export async function logout(page: Page, userDisplayName?: string) {
try {
- await page.getByRole("button", { name: userDisplayName, exact: true }).first().click({ force: true });
+ const profileTrigger = page.getByTestId(TEST_IDS.topbar.profileTrigger).first();
+ if (await profileTrigger.isVisible().catch(() => false)) {
+ await profileTrigger.click({ force: true });
+ } else if (userDisplayName) {
+ await page.getByRole("button", { name: userDisplayName, exact: true }).first().click({ force: true });
+ } else {
+ throw new Error("profile trigger not visible");
+ }
await expect(page.getByRole("menuitem", { name: "退出登录" })).toBeVisible({ timeout: 5_000 });
await page.getByRole("menuitem", { name: "退出登录" }).click();
} catch {
diff --git a/client/tests/e2e/helpers/navigation.ts b/client/tests/e2e/helpers/navigation.ts
index 122c08cf..fd3a4d6d 100644
--- a/client/tests/e2e/helpers/navigation.ts
+++ b/client/tests/e2e/helpers/navigation.ts
@@ -1,5 +1,7 @@
import { expect, type Locator, type Page } from "@playwright/test";
+import { TEST_IDS } from "./testIds";
+
export async function openBackupModulesIfNeeded(page: Page) {
const summary = page.locator("summary").filter({ hasText: "备用模块" }).first();
if (await summary.count()) {
@@ -22,3 +24,18 @@ export async function openNavLink(page: Page, name: string | RegExp): Promise false)) {
+ await profileTrigger.click();
+ } else if (userDisplayName) {
+ await page.getByRole("button", { name: userDisplayName, exact: true }).first().click();
+ } else {
+ throw new Error("无法定位个人设置入口:缺少 topbar profile trigger");
+ }
+ await expect(page.getByRole("menuitem", { name: "个人设置" })).toBeVisible();
+ await page.getByRole("menuitem", { name: "个人设置" }).click();
+ await expect(page).toHaveURL(/\/settings\/profile/);
+}
diff --git a/client/tests/e2e/helpers/testIds.ts b/client/tests/e2e/helpers/testIds.ts
new file mode 100644
index 00000000..bf147d74
--- /dev/null
+++ b/client/tests/e2e/helpers/testIds.ts
@@ -0,0 +1,20 @@
+export const TEST_IDS = {
+ login: {
+ identifier: "login-identifier",
+ password: "login-password",
+ submit: "login-submit",
+ },
+ topbar: {
+ profileTrigger: "topbar-profile-trigger",
+ },
+ workflow: {
+ tasksTable: "workflow-tasks-table",
+ taskRow: "workflow-task-row",
+ detailLink: "workflow-task-detail-link",
+ summary: "workflow-task-summary",
+ approve: "workflow-task-approve",
+ },
+ audit: {
+ export: "audit-export",
+ },
+} as const;
diff --git a/client/tests/e2e/leave-flow.spec.ts b/client/tests/e2e/leave-flow.spec.ts
new file mode 100644
index 00000000..7af77283
--- /dev/null
+++ b/client/tests/e2e/leave-flow.spec.ts
@@ -0,0 +1,119 @@
+import { expect, test, type Locator, type Page } from "@playwright/test";
+
+import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
+import { openNavLink } from "./helpers/navigation";
+import { TEST_IDS } from "./helpers/testIds";
+
+function formatDate(offsetDays: number) {
+ const value = new Date();
+ value.setUTCDate(value.getUTCDate() + offsetDays);
+ return value.toISOString().slice(0, 10);
+}
+
+
+async function selectFirstEnabledOption(locator: Locator) {
+ await expect
+ .poll(async () => {
+ const optionValues = await locator.locator("option").evaluateAll((options) =>
+ options
+ .map((option) => ({
+ value: (option as HTMLOptionElement).value,
+ disabled: (option as HTMLOptionElement).disabled,
+ }))
+ .filter((option) => option.value && !option.disabled)
+ .map((option) => option.value),
+ );
+ return optionValues[0] ?? "";
+ })
+ .not.toBe("");
+
+ const [firstValue] = await locator.locator("option").evaluateAll((options) =>
+ options
+ .map((option) => ({
+ value: (option as HTMLOptionElement).value,
+ disabled: (option as HTMLOptionElement).disabled,
+ }))
+ .filter((option) => option.value && !option.disabled)
+ .map((option) => option.value),
+ );
+
+ await locator.selectOption(firstValue);
+}
+
+async function createLeaveDraft(page: Page, startDate: string, endDate: string, reason: string) {
+ const createResponsePromise = page.waitForResponse(
+ (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/leave-requests" && response.ok(),
+ );
+
+ await selectFirstEnabledOption(page.locator("#leave-type"));
+ await page.locator("#leave-start-date").fill(startDate);
+ await page.locator("#leave-end-date").fill(endDate);
+ await page.locator("#leave-start-session").selectOption("am");
+ await page.locator("#leave-end-session").selectOption("pm");
+ await page.locator("#leave-reason").fill(reason);
+ await page.getByRole("button", { name: "创建草稿" }).click();
+
+ const createPayload = (await (await createResponsePromise).json()) as { data?: { id?: string } };
+ const leaveRequestId = createPayload.data?.id;
+ if (!leaveRequestId) {
+ throw new Error(`创建请假草稿后未返回 id: ${JSON.stringify(createPayload)}`);
+ }
+ return leaveRequestId;
+}
+
+async function submitLeave(api: Awaited>["api"], leaveRequestId: string) {
+ return apiPostData<{ leaveRequestId: string; currentTaskId: string }>(api, `/api/leave-requests/${leaveRequestId}/submit`, {});
+}
+
+async function withdrawLeave(api: Awaited>["api"], leaveRequestId: string) {
+ return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
+}
+
+test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page }) => {
+ const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
+ const baseOffset = 30 + Number(String(Date.now()).slice(-2));
+ const firstDate = formatDate(baseOffset);
+ const secondDate = formatDate(baseOffset + 7);
+
+ try {
+ await login(page, EMPLOYEE);
+ await (await openNavLink(page, "请假")).click();
+ await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
+
+ const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
+ await submitLeave(employeeClient.api, firstLeaveId);
+ await page.goto("/leave-requests");
+ const firstRow = page.getByRole("row").filter({ hasText: firstDate }).first();
+ await expect(firstRow).toContainText("审批中", { timeout: 30_000 });
+
+ await withdrawLeave(employeeClient.api, firstLeaveId);
+ await page.goto("/leave-requests");
+ await expect(firstRow).toContainText("草稿", { timeout: 30_000 });
+
+ const secondLeaveId = await createLeaveDraft(page, secondDate, secondDate, `Playwright 审批请假 ${Date.now()}`);
+ const secondSubmit = await submitLeave(employeeClient.api, secondLeaveId);
+ await page.goto("/leave-requests");
+ const secondRow = page.getByRole("row").filter({ hasText: secondDate }).first();
+ await expect(secondRow).toContainText("审批中", { timeout: 30_000 });
+
+ await logout(page, EMPLOYEE.name);
+
+ await login(page, MANAGER);
+ await (await openNavLink(page, "待我处理")).click();
+ await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
+ const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
+ await expect(taskRow).toBeVisible({ timeout: 30_000 });
+ await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
+ await page.getByTestId(TEST_IDS.workflow.approve).click();
+ await expect(page.getByTestId(TEST_IDS.workflow.summary)).toContainText(/状态:(completed|已完成)/);
+
+ await logout(page, MANAGER.name);
+
+ await login(page, EMPLOYEE);
+ await page.goto("/leave-requests");
+ await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 });
+ await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 });
+ } finally {
+ await employeeClient.dispose();
+ }
+});
diff --git a/client/tests/e2e/settings-governance.spec.ts b/client/tests/e2e/settings-governance.spec.ts
new file mode 100644
index 00000000..a6aa08d8
--- /dev/null
+++ b/client/tests/e2e/settings-governance.spec.ts
@@ -0,0 +1,37 @@
+import { expect, test } from "@playwright/test";
+
+import { login } from "./helpers/auth";
+import { assertBackupModulesCollapsed } from "./helpers/smokeAssertions";
+import { TEST_IDS } from "./helpers/testIds";
+
+const ADMIN = {
+ identifier: "admin@hyinda.com",
+ password: "AdminPassword1234",
+ name: "Admin",
+};
+
+test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page }) => {
+ await login(page, ADMIN);
+ await page.goto("/portal");
+ await assertBackupModulesCollapsed(page);
+
+ await page.goto("/system/workspace");
+ await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
+ await expect(page.getByText("产品模式")).toBeVisible();
+
+ await page.goto("/system/settings");
+ await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "工作区设置", exact: true }).first()).toBeVisible();
+
+ await page.goto("/system/feature-flags");
+ await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
+ await expect(page.getByTestId("feature-flags-table")).toBeVisible();
+
+ await page.goto("/system/workflow-definitions");
+ await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
+ await expect(page.getByText("版本列表")).toBeVisible();
+
+ await page.goto("/audit-logs");
+ await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
+ await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
+});
diff --git a/client/tests/e2e/wecom-entry.spec.ts b/client/tests/e2e/wecom-entry.spec.ts
index 97609412..d78e4a6f 100644
--- a/client/tests/e2e/wecom-entry.spec.ts
+++ b/client/tests/e2e/wecom-entry.spec.ts
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import { EMPLOYEE, login } from "./helpers/auth";
+import { openProfileSettings } from "./helpers/navigation";
function mockWecomConfigPayload() {
return {
@@ -44,7 +45,7 @@ test("E2E 企微入口:普通浏览器下企微按钮为次级入口", async (
await expect(page).toHaveURL(/\/api\/auth\/wecom\/login\?returnTo=/);
await login(page, EMPLOYEE);
- await page.goto("/settings/profile");
+ await openProfileSettings(page, EMPLOYEE.name);
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
await expect(page.getByText("接入状态", { exact: true })).toBeVisible();
});
diff --git a/deploy/trialrun/checklist.md b/deploy/trialrun/checklist.md
index 80cc82d9..cd40f444 100644
--- a/deploy/trialrun/checklist.md
+++ b/deploy/trialrun/checklist.md
@@ -13,7 +13,7 @@
- [ ] 已将 OA Nginx 配置放到 `/etc/nginx/sites-available/hyb-oa`
- [ ] 已上传 OA 域名证书到 `/etc/nginx/ssl//`
- [ ] 已执行 `nginx -t && systemctl reload nginx`
-- [ ] 已从本地执行 `bash deploy/trialrun/deploy.sh`
+- [ ] 已从本地执行 `bash scripts/qa/run-release-gate.sh all` 或至少执行 `bash deploy/trialrun/deploy.sh`
- [ ] 已通过 `https:///ready` 或首页登录完成最小探活
- [ ] 首次上线先执行系统初始化(migration + seed),不要直接导入旧库整库备份
- [ ] 若要导数据,仅准备最小主数据:部门、员工、角色、在用资产、活跃项目、合同元数据
@@ -23,6 +23,8 @@
- [ ] 确认当前版本已打 tag:`git tag -a trialrun-YYYYMMDD-NN -m "trialrun release"`
- [ ] 本地门禁:`./scripts/check-minimal.sh`
- [ ] 部署契约校验:`node deploy/trialrun/validate-env-contract.js`
+- [ ] 网页 smoke 门禁:`bash scripts/qa/run-web-gates.sh smoke-local`
+- [ ] 网页 full 门禁(建议发版前执行):`bash scripts/qa/run-web-gates.sh full-local`
- [ ] 全量回归(建议):`cd server && RUN_AUDIT_REGRESSION=1 RUN_OCR_REGRESSION=1 ./scripts/regression-all.sh`
- [ ] 备份(DB + 文件存储):
- [ ] `set -a && source deploy/trialrun/.env && set +a`
@@ -38,17 +40,18 @@
- [ ] `curl -fsS http://127.0.0.1:3000/ready`
- [ ] Runtime smoke:
- [ ] `docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-trialrun-runtime.js`
-- [ ] 登录验证(管理员账号):
- - [ ] 访问 `http://127.0.0.1:3000`
- - [ ] 登录成功,菜单权限正确
+- [ ] external deps smoke:
+ - [ ] `docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/.env exec -T server node src/scripts/smoke-external-deps.js`
+- [ ] 登录验证已由 live smoke 与 release gate 覆盖;仅首次接入或第三方后台配置变更时补做人工确认
## C. 发布后(Post-check)
-- [ ] 关键链路手工验证(建议至少一次):
- - [ ] 员工:新建报销 → 上传票据 → 绑定 → 提交
- - [ ] 经理:待办审批(approve)
- - [ ] 财务:付款(含凭证字段)
+- [ ] 公网 live smoke(只读,不做写操作):`bash scripts/qa/run-web-gates.sh smoke-live`
+- [ ] 若本地已配置 `LIVE_SMOKE_*`,确认 `bash deploy/trialrun/deploy.sh` 输出中包含 live smoke 结果
+- [ ] 已确认 release gate / live smoke 结果为通过;关键链路不再作为每次发布的手工必做项
- [ ] 查看 server 日志(无异常错误风暴):
- [ ] `docker logs --tail=200 hyb-oa-trialrun-server`
+- [ ] 已确认 smoke 账号为独立账号(`smoke-admin` / `smoke-employee`),且不参与真实业务
+- [ ] 已确认 live smoke 仅检查 `/ready`、登录、Portal、项目、公告、个人设置和企微配置状态
## D. 回滚(Rollback)
> 原则:如果涉及迁移,最快回滚通常是 “恢复备份 + 回到上个 tag”。
@@ -120,3 +123,18 @@
- [ ] 已验证:Portal 首屏第一排卡片顺序符合员工 / 管理员视角设计
- [ ] 已验证:备用模块默认折叠,不干扰主导航
- [ ] 已验证:企业微信通知仍保持脱敏,不包含涉密正文与金额明细
+
+
+## R29 前端设计系统与工作台体验检查
+- [ ] 主导航已稳定收敛为 `Portal / 团队 / 请假 / 报销 / 资产 / 周报 / 待办 / 公告 / 项目`
+- [ ] 管理入口已单独分区,`备用模块` 默认折叠
+- [ ] 登录页在普通浏览器与企业微信 WebView 场景下都只保留一个主 CTA
+- [ ] 登录页未再展示测试账号暗示,错误提示贴近操作区
+- [ ] `Portal` 员工第一屏已优先显示:待办 / 今日状态 / 周报 / 项目
+- [ ] `Portal` 管理第一屏已优先显示:审批 / 团队状态 / 周报缺口 / 需协助员工
+- [ ] `个人设置` 不再直出 `部门 ID / 上级 ID` 等内部标识
+- [ ] `topbar` 仅在企业微信 WebView 场景显示“当前通过企业微信进入”轻提示
+- [ ] 已执行 `cd client && npm run typecheck`
+- [ ] 已执行 `cd client && npm run build`
+- [ ] 已执行 `bash scripts/qa/run-web-gates.sh smoke-local`
+- [ ] 发版前建议执行 `bash scripts/qa/run-web-gates.sh full-local`
diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh
index e3bd05ba..d222d981 100755
--- a/deploy/trialrun/deploy.sh
+++ b/deploy/trialrun/deploy.sh
@@ -15,6 +15,7 @@ COMPOSE_FILE="deploy/trialrun/docker-compose.yml"
LOCAL_ENV_FILE="deploy/trialrun/.env"
ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js"
+EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js"
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
@@ -42,6 +43,11 @@ if [[ ! -f "${ROOT_DIR}/${RUNTIME_SMOKE_SCRIPT}" ]]; then
exit 1
fi
+if [[ ! -f "${ROOT_DIR}/${EXTERNAL_DEPS_SMOKE_SCRIPT}" ]]; then
+ echo "[ERROR] 缺少 ${EXTERNAL_DEPS_SMOKE_SCRIPT}" >&2
+ exit 1
+fi
+
if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then
echo "[ERROR] SYNC_ENV=1 但本地不存在 ${LOCAL_ENV_FILE}" >&2
exit 1
@@ -144,19 +150,42 @@ if [[ "$ready_ok" != "1" ]]; then
exit 1
fi
-echo ">>> 4.5 执行 runtime smoke"
+echo ">>> 4.5 执行 external deps smoke"
+if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-external-deps.js; then
+ echo "[ERROR] external deps smoke 失败,输出最近 server 日志" >&2
+ "${compose_cmd[@]}" logs --tail=200 server || true
+ exit 1
+fi
+
+echo ">>> 4.6 执行 runtime smoke"
if ! "${compose_cmd[@]}" exec -T server node src/scripts/smoke-trialrun-runtime.js; then
echo "[ERROR] runtime smoke 失败,输出最近 server 日志" >&2
"${compose_cmd[@]}" logs --tail=200 server || true
exit 1
fi
-echo ">>> 4.6 当前服务状态"
+echo ">>> 4.7 当前服务状态"
"${compose_cmd[@]}" ps
REMOTE
-echo ">>> 5. 部署完成"
+LIVE_SMOKE_READY=1
+for required_key in LIVE_SMOKE_ADMIN_IDENTIFIER LIVE_SMOKE_ADMIN_PASSWORD LIVE_SMOKE_EMPLOYEE_IDENTIFIER LIVE_SMOKE_EMPLOYEE_PASSWORD; do
+ if [[ -z "${!required_key:-}" ]]; then
+ LIVE_SMOKE_READY=0
+ break
+ fi
+done
+
+if [[ "$LIVE_SMOKE_READY" == "1" ]]; then
+ echo ">>> 5. 执行部署后 live smoke"
+ bash "${ROOT_DIR}/scripts/qa/run-web-gates.sh" smoke-live
+else
+ echo ">>> 5. 跳过部署后 live smoke(未提供完整 LIVE_SMOKE_* 凭据)"
+fi
+
+echo ">>> 6. 部署完成"
echo " 远端目录: ${REMOTE_DIR}"
echo " 环境文件: ${REMOTE_ENV_FILE}"
-echo " 已自动执行:契约校验 + /ready + runtime smoke"
+echo " 已自动执行:契约校验 + /ready + external deps smoke + runtime smoke"
+echo " 若本地已配置 LIVE_SMOKE_*,部署后会自动补跑网页 live smoke"
echo " 请确认 Nginx 已把 OA 域名反代到 127.0.0.1:3000"
diff --git a/deploy/trialrun/runbook.md b/deploy/trialrun/runbook.md
index 93484aea..e7e7c728 100644
--- a/deploy/trialrun/runbook.md
+++ b/deploy/trialrun/runbook.md
@@ -11,7 +11,7 @@
1. 服务器以 root 执行 `bash deploy/trialrun/setup-server.sh`
2. 手工创建 `deploy/trialrun/.env`
3. 手工放置 OA 的 Nginx 站点配置和证书
- 4. 本地执行 `bash deploy/trialrun/deploy.sh`
+ 4. 本地执行 `bash scripts/qa/run-release-gate.sh all`(或至少执行 `bash deploy/trialrun/deploy.sh`)
## 0. 前置条件
- Docker + Docker Compose(v2)
@@ -88,7 +88,9 @@ docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
```
说明:
-- `deploy/trialrun/deploy.sh` 现已自动执行 `validate-env-contract.js`、`/ready` 探活与 `smoke-trialrun-runtime.js`。
+- `deploy/trialrun/deploy.sh` 现已自动执行 `validate-env-contract.js`、`/ready` 探活、`smoke-external-deps.js` 与 `smoke-trialrun-runtime.js`。
+- 若本地存在完整 `LIVE_SMOKE_*` 环境变量,部署脚本会继续执行 `client npm run test:e2e:live`,用于公网只读 smoke。
+- 推荐直接执行 `bash scripts/qa/run-release-gate.sh all`,让 static / local smoke / full-local / deploy / live smoke 形成单一放行链。
- 若再出现“数据库开了但功能被环境硬关”的情况,部署会直接失败,不再等到手工点页面才发现。
浏览器访问:
@@ -141,6 +143,17 @@ curl -fsS http://127.0.0.1:3000/ready
建议版本化方式:
- 每次发布打 tag,例如 `trialrun-2026-02-08-01`
+### 2.1 网页自动化门禁(R28)
+发布前后统一使用三层网页自动化:
+- `bash scripts/qa/run-web-gates.sh smoke-local`:本地/PR 快速门禁,只覆盖登录、Portal、导航、企微入口 UI。
+- `bash scripts/qa/run-web-gates.sh full-local`:本地/main/夜间全量门禁,串联 server 回归、client build 与全部 Playwright E2E。
+- `bash scripts/qa/run-web-gates.sh smoke-live`:发布后公网只读 smoke,只检查 `/ready`、登录、Portal、团队、请假、项目、公告、个人设置、管理页只读入口与企微配置状态。
+
+约束:
+- live smoke 必须使用独立账号:`smoke-admin`、`smoke-employee`。
+- live smoke 禁止新建报销、提交审批、付款、导入、资产流转、项目写操作。
+- 若本地已配置完整 `LIVE_SMOKE_*`,`bash deploy/trialrun/deploy.sh` 会在远端部署完成后自动补跑 live smoke。
+
步骤:
1) 先做备份(见 E14 脚本)
2) 拉取代码:`git pull` / `git checkout `
@@ -316,3 +329,23 @@ docker compose -f deploy/trialrun/docker-compose.yml --env-file deploy/trialrun/
- 客户沟通影响项目时 `24h` 内回填
- 私聊可继续发生,但项目状态不能只留在聊天里
5. 进入真实 4 周试点前,至少保留一次通过记录:后端测试、前端 build、E2E 全绿截图或日志。
+
+
+## 8. R29 前端设计系统与工作台体验验收
+### 8.1 视觉与交互核对顺序
+1. 登录页:先确认当前场景只有一个主 CTA,并验证错误提示、企微回退文案可理解。
+2. Portal:确认员工与管理员两套第一屏都先回答“今天要做什么”。
+3. 导航:确认主导航 9 项稳定、管理入口独立、备用模块默认折叠。
+4. 个人设置:确认只展示员工可理解的信息,企微绑定卡状态清晰。
+
+### 8.2 自动化建议
+- 本地快速验收:`bash scripts/qa/run-web-gates.sh smoke-local`
+- 发版前完整验收:`bash scripts/qa/run-web-gates.sh full-local`
+- 公网只读验收:`bash scripts/qa/run-web-gates.sh smoke-live`
+
+### 8.3 设计 QA 口径
+- 一个场景只允许一个主 CTA。
+- 首页先动作,后报表;公告与最近访问降到第二阅读层。
+- 空状态必须给下一步建议,不只显示“暂无数据”。
+- 移动 / WebView 首屏避免横向滚动和过宽表格。
+- 状态色只表达语义,不把品牌色滥用成所有强调色。
diff --git a/docs/admin-handbook.md b/docs/admin-handbook.md
index 7f11cecd..5d748c73 100644
--- a/docs/admin-handbook.md
+++ b/docs/admin-handbook.md
@@ -152,9 +152,29 @@
4. 将 `OPENAPI_DOCS_ENABLED` 保持为 `false`,不要在公网生产暴露文档入口。
5. 定期检查审计日志中的登录失败、Refresh 来源不匹配与涉密资料查看记录。
-## 17. R23 试点收口纪律
+## 17. 网页自动化门禁(R28/R30)
+- 推荐发布时直接执行:`bash scripts/qa/run-release-gate.sh all`。
+- 发布前至少执行一次:`bash scripts/qa/run-web-gates.sh smoke-local`。
+- 发版前或主分支合并前建议执行:`bash scripts/qa/run-web-gates.sh full-local`。
+- 发布到 `https://oa.hyinda.com` 后,使用独立 smoke 账号执行:`bash scripts/qa/run-web-gates.sh smoke-live`。
+- live smoke 只做只读检查,不允许提交审批、付款、导入或任何真实业务写操作。
+- 如果本机已配置 `LIVE_SMOKE_*`,`bash deploy/trialrun/deploy.sh` 完成远端部署后会自动补跑 live smoke。
+- 首次接入企微、SMTP 或证书变更可人工复核;日常发布不再要求手点页面验收。
+
+## 18. R23 试点收口纪律
- 客户沟通如果影响项目计划、交付、报价、风险或下一步动作,必须在 `24h` 内回填到 `项目 -> 客户沟通`。
- 回填只写结果摘要、下一步动作和下次跟进日期,不粘贴完整聊天记录,不上传私人微信截图。
- 项目经理每周至少检查一次 `项目 -> 项目收口` 面板,确认红黄灯来源是否已经处理。
- 若某项收口任务选择 `已豁免`,必须填写理由;豁免不代表永久忽略,只代表本周软约束放行。
- 试点期每周五复盘一次“仍靠聊天/表格补流程”的案例,并决定是补回填纪律还是补最小功能。
+
+
+## 19. R29 设计与工作台治理口径
+- 首页不再做“模块超市”,而是先告诉员工今天该做什么。
+- 管理员不要把备用模块重新平铺到主导航,避免系统再次变成模块拼盘。
+- 员工入口默认认知应保持为:`企业微信进入 -> Portal 查看今日动作 -> 进入具体模块处理`。
+- 如果未来新增页面,请优先复用统一骨架:
+ - 首页:`WorkbenchHero + MetricCard + SectionPanel`
+ - 列表页:`PageHeader + FilterBar + 主列表`
+ - 详情页:`概览 -> 执行 -> 留痕 -> 归档/合规`
+- 若某页面出现两个以上竞争级主按钮,应视为设计回退并在上线前修正。
diff --git a/docs/employee-quickstart.md b/docs/employee-quickstart.md
index 3a1ba5b8..460ac6eb 100644
--- a/docs/employee-quickstart.md
+++ b/docs/employee-quickstart.md
@@ -47,12 +47,12 @@
- 有通知先看站内消息,IM 只是补充提醒
## 9. 从企业微信进入
-- 如果公司启用了企业微信工作台入口,优先从企业微信打开 OA。
-- 企业微信只是入口和提醒,真正填写和修改仍在 OA 页面内完成。
+- 如果公司启用了企业微信工作台入口,优先从企业微信打开海音达工作台。
+- 企业微信只是入口和提醒,真正填写和修改仍在海音达工作台页面内完成。
- 如果企业微信免登失败,直接回到账号密码登录即可。
- 如果你第一次从企业微信进入但看到“尚未绑定”,请不要反复重试,先用账号密码登录,再到 `个人设置 -> 企业微信绑定` 完成绑定。
- 如果看到“登录已失效 / 已超时”,重新从企业微信工作台进入即可。
-- 日常建议:在企业微信里打开 OA,看 `Portal` 和通知;复杂填写、项目更新、报销与周报仍在 OA 页面里完成。
+- 日常建议:在企业微信里打开海音达工作台,看 `Portal` 和通知;复杂填写、项目更新、报销与周报仍在工作台页面里完成。
## 10. 项目怎么用
- 如果你是项目成员,优先在 `项目` 页面查看自己的里程碑、任务、风险和交付记录。
@@ -60,17 +60,17 @@
- 周报里可以按项目分别写本周进展、风险和下一步。
## 11. 涉密内容注意事项
-- 不要把涉密合同正文、涉密附件、敏感报价和成本细节上传到 OA 或企业微信通知里。
+- 不要把涉密合同正文、涉密附件、敏感报价和成本细节上传到海音达工作台或企业微信通知里。
- 如果系统里只要求你填合同编号、归档位置、离线档案号,这是正常设计,不是少功能。
- 涉密资料正文继续按公司要求放在内网文件盘、NAS 或离线档案中。
## 12. 客户沟通回填规则(R23 试点期)
-- 如果你通过私人微信、电话、会议、企业微信或现场沟通拿到了会影响项目推进的结果,请在 `24h` 内回填到 OA。
+- 如果你通过私人微信、电话、会议、企业微信或现场沟通拿到了会影响项目推进的结果,请在 `24h` 内回填到海音达工作台。
- 回填内容只写:沟通日期、渠道、结果摘要、下一步动作、下次跟进日期。
-- 不要把完整聊天记录、截图、敏感报价或涉密正文复制到 OA。
+- 不要把完整聊天记录、截图、敏感报价或涉密正文复制到海音达工作台。
- 项目是否推进、谁来跟进、下次何时联系,不能只留在聊天里。
## 13. 项目收口怎么配合
- 如果你负责交付、报销、差旅、风险或交接,请关注项目里的“项目收口”面板。
- 面板里看到待处理项并不代表不能继续推进,而是提醒你尽快补齐留痕。
-- 如果管理员要求你补交付记录、合同归档位置或交接说明,请优先在 OA 里补齐,不要只在群里回复“已处理”。
+- 如果管理员要求你补交付记录、合同归档位置或交接说明,请优先在海音达工作台里补齐,不要只在群里回复“已处理”。
diff --git a/issues/2026-03-23_09-07-13-r30-enterprise-workbench-zero-manual-release.csv b/issues/2026-03-23_09-07-13-r30-enterprise-workbench-zero-manual-release.csv
new file mode 100644
index 00000000..bc95023a
--- /dev/null
+++ b/issues/2026-03-23_09-07-13-r30-enterprise-workbench-zero-manual-release.csv
@@ -0,0 +1,12 @@
+ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
+R30-0,总执行合同建档与范围冻结,为零人工发布 全域自动化回归与海音达工作台收口建立总执行合同并冻结三波推进范围,Issue CSV 校验通过 且强阻断 三波推进 工作台命名与外部依赖半自动闭环范围冻结,python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-03-23_09-07-13-r30-enterprise-workbench-zero-manual-release.csv,manual,DONE,DONE,DONE,issues/2026-03-23_09-07-13-r30-enterprise-workbench-zero-manual-release.csv,[],none
+R30-1,零人工发布门禁总编排,新增统一 release gate 脚本并把静态校验 server 回归 web gate deploy runtime smoke live smoke 串成唯一放行链,存在 scripts/qa/run-release-gate.sh 且 deploy trialrun 接入 env contract runtime smoke live smoke 失败即非零并输出阶段结果,bash scripts/qa/run-release-gate.sh --help && git diff --check,manual,DONE,DONE,DONE,scripts/qa/run-release-gate.sh|deploy/trialrun/deploy.sh|deploy/trialrun/validate-env-contract.js|.github/workflows/ci-release-gate.yml,"[""R30-0""]",以现有 deploy 和 web gates 为基础增量收口
+R30-2,测试数据与fixture稳定化,固化 smoke 账号 本地全量 seed feature flags 与 Playwright helper fixture 约定以降低本地和 CI 差异,smoke 账号约定固定 full-local 使用干净库和标准 seed 且高频用例 helper page object 契约明确,cd client && npm run test:e2e:smoke && bash scripts/qa/run-web-gates.sh full-local,manual,DOING,TODO,TODO,client/tests/e2e/helpers/*|client/tests/e2e/helpers/pageObjects/*|client/tests/e2e/helpers/fixtures/*|scripts/qa/run-web-gates.sh|server/prisma/seed*.js,"[""R30-0"",""R30-1""]",优先稳定 smoke-admin smoke-employee 与标准测试实体
+R30-3,flake治理与失败产物收口,治理 procurement 等已观测 flaky 用例并统一保留 HTML report screenshot video trace 与 server log 尾部,PR smoke 与 full-local 不存在已知 release blocker flake 且失败产物可从统一目录定位,bash scripts/qa/run-web-gates.sh full-local,manual,DONE,DONE,DONE,client/tests/e2e/procurement-flow.spec.ts|client/playwright.config.ts|scripts/qa/collect-failure-artifacts.sh|scripts/qa/run-web-gates.sh,"[""R30-1"",""R30-2""]",不以长期增加 retry 掩盖问题
+R30-4,高优先级业务E2E补齐,补齐请假 公告 团队状态 周报待办联动与项目工作台扩展覆盖,新增 leave announcements presence weekly todo 等 spec 并纳入 full-local 门禁,cd client && npm run test:e2e:full,manual,DONE,DONE,DONE,client/tests/e2e/leave-flow.spec.ts|client/tests/e2e/announcements-presence.spec.ts|client/tests/e2e/weekly-todo-flow.spec.ts|client/tests/e2e/project-workbench.spec.ts|client/tests/e2e/main-flow.spec.ts,"[""R30-2"",""R30-3""]",新增用例需声明写数据与只读边界
+R30-5,管理域与低副作用smoke补齐,补齐工作区设置 功能开关 系统设置 流程定义 审计日志等管理页只读 smoke 与权限入口检查,管理员关键治理页可自动打开且主导航 管理导航 备用模块分组行为有自动化证明,cd client && npm run test:e2e:full && E2E_MODE=live npm run test:e2e:live,manual,DONE,DONE,DONE,client/tests/e2e/settings-governance.spec.ts|client/tests/e2e/workflow-admin.spec.ts|client/tests/e2e/admin-shell.spec.ts|client/tests/e2e/live-smoke.spec.ts,"[""R30-2"",""R30-3""]",live smoke 仅做只读与低副作用检查
+R30-6,外部依赖半自动闭环,新增企业微信 SMTP OCR 与桥接能力的配置契约与只读探活脚本并接入部署后 smoke,存在 external deps smoke 且 deploy runtime live 可输出依赖状态与失败原因,node server/src/scripts/smoke-external-deps.js && git diff --check,manual,DONE,DONE,DONE,server/src/scripts/smoke-external-deps.js|server/src/scripts/smoke-trialrun-runtime.js|deploy/trialrun/deploy.sh|client/tests/e2e/live-smoke.spec.ts,"[""R30-1"",""R30-2""]",真实 OAuth 发件 OCR 网络调用不进入主阻断链
+R30-7,工作台产品定位与文案统一,把员工侧入口 首页 导航 手册与运行文档统一收敛为海音达工作台并弱化 OA ERP 技术心智,登录页 Portal Profile 顶栏 文档默认采用海音达工作台 且 ERP 相关文案收敛为外部系统桥接或财务桥接,cd client && npm run typecheck && npm run build && git diff --check,manual,DONE,DONE,DONE,client/src/pages/LoginPage.tsx|client/src/pages/ProfilePage.tsx|client/src/components/navigation/*|client/src/pages/SystemSettingsPage.tsx|client/src/pages/FeatureFlagsPage.tsx|client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|docs/admin-handbook.md,"[""R30-0""]",不修改仓库名 域名 容器名
+R30-8,剩余详情页工作台化,完成报销详情与审批详情等高频详情页的统一概览 执行 留痕 归档骨架并补 data-testid 契约,ReimbursementDetail 与 WorkflowTaskDetail 形成统一工作台模式 且关键 E2E 与视觉 smoke 可稳定运行,cd client && npm run typecheck && npm run test:e2e:full,manual,TODO,TODO,TODO,client/src/pages/ReimbursementDetailPage.tsx|client/src/pages/WorkflowTaskDetailPage.tsx|client/src/components/patterns/*|client/tests/e2e/*.spec.ts,"[""R29-6"",""R30-4""]",先定义 testid 再迁移结构避免打碎自动化
+R30-9,观测告警与发布runbook收口,统一 QA 报告目录 Job Summary runbook checklist 与失败归因模板并去掉发版后手工点站步骤,发布失败可快速定位静态 server regression e2e runtime live external config 任一层且手册仅保留第三方后台人工前置动作,git diff --check && bash scripts/qa/run-release-gate.sh --help,manual,DONE,DONE,DONE,scripts/qa/*|.github/workflows/*.yml|deploy/trialrun/checklist.md|deploy/trialrun/runbook.md|docs/admin-handbook.md,"[""R30-1"",""R30-3"",""R30-6"",""R30-7""]",保留只针对第三方后台配置的人工前置清单
+R30-10,总体验收与试点放行,执行连续多轮 full-local runtime smoke live smoke 与文案检查并给出试点放行结论,PR smoke main full-local live smoke 连续三次稳定且前台已统一为海音达工作台后才标记放行,bash scripts/qa/run-release-gate.sh && bash scripts/qa/run-web-gates.sh smoke-live,manual,DOING,TODO,TODO,issues/2026-03-23_09-07-13-r30-enterprise-workbench-zero-manual-release.csv|scripts/qa/*|client/tests/e2e/*|deploy/trialrun/*,"[""R30-4"",""R30-5"",""R30-6"",""R30-7"",""R30-8"",""R30-9""]",需满足三次连续绿灯后再宣布试点可用
diff --git a/scripts/qa/collect-failure-artifacts.sh b/scripts/qa/collect-failure-artifacts.sh
new file mode 100755
index 00000000..284aeef7
--- /dev/null
+++ b/scripts/qa/collect-failure-artifacts.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+DEST_DIR="${1:-${ROOT_DIR}/artifacts/qa/latest}"
+
+mkdir -p "$DEST_DIR"
+
+copy_path() {
+ local source_path="$1"
+ local target_name="${2:-$(basename "$source_path")}"
+ if [[ -e "$source_path" ]]; then
+ rm -rf "$DEST_DIR/$target_name"
+ cp -R "$source_path" "$DEST_DIR/$target_name"
+ fi
+}
+
+copy_tail() {
+ local source_path="$1"
+ local target_name="$2"
+ if [[ -f "$source_path" ]]; then
+ tail -n 200 "$source_path" > "$DEST_DIR/$target_name"
+ fi
+}
+
+copy_path "$ROOT_DIR/client/playwright-report" "playwright-report"
+copy_path "$ROOT_DIR/client/test-results" "test-results"
+copy_path "/tmp/hyb-oa-regression/report.json" "regression-report.json"
+copy_path "/tmp/hyb-oa-web-gates-server.log" "hyb-oa-web-gates-server.log"
+copy_path "/tmp/hyb-oa-server.log" "hyb-oa-server.log"
+copy_tail "/tmp/hyb-oa-web-gates-server.log" "hyb-oa-web-gates-server.tail.log"
+copy_tail "/tmp/hyb-oa-server.log" "hyb-oa-server.tail.log"
+
+git -C "$ROOT_DIR" status --short > "$DEST_DIR/git-status.txt" || true
+
+echo "$DEST_DIR"
diff --git a/scripts/qa/run-release-gate.sh b/scripts/qa/run-release-gate.sh
new file mode 100755
index 00000000..e73dc5b7
--- /dev/null
+++ b/scripts/qa/run-release-gate.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+ARTIFACT_ROOT="${RELEASE_GATE_ARTIFACT_DIR:-${ROOT_DIR}/artifacts/release-gate/$(date +%Y%m%d-%H%M%S)}"
+MODE="${1:-all}"
+CURRENT_STAGE=""
+
+mkdir -p "$ARTIFACT_ROOT"
+
+usage() {
+ cat <
+
+说明:
+ static 运行部署契约、server Prisma 校验、client typecheck/build
+ smoke-local 运行本地网页 smoke 门禁
+ full-local 运行本地全量门禁(server regression + client full E2E)
+ deploy 调用 deploy/trialrun/deploy.sh,内部会串联 /ready、external deps、runtime smoke 与 live smoke
+ all 依次执行 static -> smoke-local -> full-local -> deploy
+ ci-release 依次执行 static -> full-local -> deploy
+
+可选环境变量:
+ RELEASE_GATE_ARTIFACT_DIR 失败产物输出目录
+ QA_* 透传给 scripts/qa/run-web-gates.sh
+ LIVE_SMOKE_* 透传给 deploy.sh 与 live smoke
+ DEPLOY_SERVER / REMOTE_DIR / REMOTE_ENV_FILE / SYNC_ENV
+USAGE
+}
+
+collect_failure_artifacts() {
+ local stage_name="${CURRENT_STAGE:-unknown}"
+ local target_dir="${ARTIFACT_ROOT}/${stage_name}"
+ mkdir -p "$target_dir"
+ bash "$ROOT_DIR/scripts/qa/collect-failure-artifacts.sh" "$target_dir" >/dev/null 2>&1 || true
+ echo "[release-gate] 已收集失败产物:$target_dir" >&2
+}
+
+handle_error() {
+ local exit_code="$?"
+ echo "[release-gate][FAIL] 阶段 ${CURRENT_STAGE:-unknown} 失败(exit=${exit_code})" >&2
+ collect_failure_artifacts
+ exit "$exit_code"
+}
+trap handle_error ERR
+
+run_stage() {
+ local stage_name="$1"
+ shift
+ CURRENT_STAGE="$stage_name"
+ echo ">>> [release-gate] ${stage_name}"
+ "$@"
+}
+
+stage_static() {
+ node "$ROOT_DIR/deploy/trialrun/validate-env-contract.js"
+ (
+ cd "$ROOT_DIR/server"
+ npm run prisma:validate
+ )
+ (
+ cd "$ROOT_DIR/client"
+ npm run typecheck
+ npm run build
+ )
+}
+
+stage_smoke_local() {
+ bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" smoke-local
+}
+
+stage_full_local() {
+ bash "$ROOT_DIR/scripts/qa/run-web-gates.sh" full-local
+}
+
+stage_deploy() {
+ bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
+}
+
+case "$MODE" in
+ help|-h|--help)
+ usage
+ ;;
+ static)
+ run_stage static stage_static
+ ;;
+ smoke-local)
+ run_stage smoke-local stage_smoke_local
+ ;;
+ full-local)
+ run_stage full-local stage_full_local
+ ;;
+ deploy)
+ run_stage deploy stage_deploy
+ ;;
+ ci-release)
+ run_stage static stage_static
+ run_stage full-local stage_full_local
+ run_stage deploy stage_deploy
+ ;;
+ all)
+ run_stage static stage_static
+ run_stage smoke-local stage_smoke_local
+ run_stage full-local stage_full_local
+ run_stage deploy stage_deploy
+ ;;
+ *)
+ usage >&2
+ exit 1
+ ;;
+esac
+
+echo "[release-gate][PASS] 模式 ${MODE} 通过"
+echo "[release-gate] 产物目录:${ARTIFACT_ROOT}"
diff --git a/scripts/qa/run-web-gates.sh b/scripts/qa/run-web-gates.sh
new file mode 100755
index 00000000..2c0a1e0a
--- /dev/null
+++ b/scripts/qa/run-web-gates.sh
@@ -0,0 +1,374 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+SERVER_DIR="$ROOT_DIR/server"
+CLIENT_DIR="$ROOT_DIR/client"
+
+MODE="${1:-}"
+QA_HOST="${QA_HOST:-127.0.0.1}"
+QA_PORT_INPUT="${QA_PORT:-}"
+QA_PORT="${QA_PORT_INPUT:-3000}"
+QA_DB_HOST="${QA_DB_HOST:-127.0.0.1}"
+QA_DB_PORT="${QA_DB_PORT:-5432}"
+QA_DB_USER="${QA_DB_USER:-hyboa_user}"
+QA_DB_PASSWORD="${QA_DB_PASSWORD:-hyboa_password}"
+QA_DB_ADMIN_DB="${QA_DB_ADMIN_DB:-postgres}"
+QA_DB_PREFIX="${QA_DB_PREFIX:-hyb_oa_webgate}"
+QA_KEEP_DB="${QA_KEEP_DB:-0}"
+QA_SERVER_LOG="${QA_SERVER_LOG:-/tmp/hyb-oa-web-gates-server.log}"
+QA_SERVER_PID_FILE="${QA_SERVER_PID_FILE:-/tmp/hyb-oa-web-gates-server.pid}"
+QA_READY_TIMEOUT_SECONDS="${QA_READY_TIMEOUT_SECONDS:-90}"
+QA_BASE_URL=""
+QA_DB_NAME="${QA_DB_NAME:-${QA_DB_PREFIX}_$(date +%Y%m%d%H%M%S)_$RANDOM}"
+DATABASE_URL="postgresql://${QA_DB_USER}:${QA_DB_PASSWORD}@${QA_DB_HOST}:${QA_DB_PORT}/${QA_DB_NAME}?schema=public"
+TEST_FIELD_ENCRYPTION_KEY_DEFAULT="${FIELD_ENCRYPTION_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}"
+
+qa_require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || {
+ echo "[ERROR] 缺少命令: $1" >&2
+ exit 1
+ }
+}
+
+qa_usage() {
+ cat <
+
+可选环境变量:
+ QA_PORT / QA_HOST
+ QA_DB_HOST / QA_DB_PORT / QA_DB_USER / QA_DB_PASSWORD / QA_DB_ADMIN_DB
+ QA_DB_PREFIX / QA_DB_NAME / QA_KEEP_DB
+ LIVE_SMOKE_BASE_URL / LIVE_SMOKE_ADMIN_* / LIVE_SMOKE_EMPLOYEE_* / LIVE_EXPECT_WECOM_STATUS
+USAGE
+}
+
+qa_port_in_use() {
+ python3 - "$QA_HOST" "$QA_PORT" <<'PY'
+import socket, sys
+host = sys.argv[1]
+port = int(sys.argv[2])
+with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(0.5)
+ try:
+ sock.connect((host, port))
+ except OSError:
+ sys.exit(1)
+ sys.exit(0)
+PY
+}
+
+qa_refresh_base_url() {
+ QA_BASE_URL="http://${QA_HOST}:${QA_PORT}"
+}
+
+qa_find_available_port() {
+ python3 - "$QA_HOST" "$QA_PORT" <<'PY'
+import socket, sys
+host = sys.argv[1]
+start = int(sys.argv[2])
+for port in range(start, start + 200):
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind((host, port))
+ except OSError:
+ continue
+ print(port)
+ sys.exit(0)
+raise SystemExit(1)
+PY
+}
+
+qa_select_port() {
+ if qa_port_in_use; then
+ if [[ -n "$QA_PORT_INPUT" ]]; then
+ echo "[ERROR] 端口 ${QA_PORT} 已被占用,请改用 QA_PORT= 后重试。" >&2
+ exit 1
+ fi
+
+ local next_port
+ next_port="$(qa_find_available_port)"
+ if [[ -z "$next_port" ]]; then
+ echo "[ERROR] 无法为网页门禁分配空闲端口,请手工设置 QA_PORT。" >&2
+ exit 1
+ fi
+
+ echo "[INFO] 默认端口 ${QA_PORT} 已被占用,自动切换到 ${next_port}"
+ QA_PORT="$next_port"
+ fi
+
+ qa_refresh_base_url
+}
+
+qa_wait_port_free() {
+ local timeout="${1:-15}"
+ local elapsed=0
+ while [[ "$elapsed" -lt "$timeout" ]]; do
+ if ! qa_port_in_use; then
+ return 0
+ fi
+ sleep 1
+ elapsed=$((elapsed + 1))
+ done
+ return 1
+}
+
+qa_wait_ready() {
+ local timeout="$1"
+ local elapsed=0
+ while [[ "$elapsed" -lt "$timeout" ]]; do
+ if curl -fsS "$QA_BASE_URL/ready" >/dev/null 2>&1; then
+ return 0
+ fi
+ sleep 2
+ elapsed=$((elapsed + 2))
+ done
+ echo "[ERROR] /ready 在 ${timeout}s 内未通过:$QA_BASE_URL/ready" >&2
+ return 1
+}
+
+qa_psql() {
+ PGPASSWORD="$QA_DB_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" -d "$QA_DB_ADMIN_DB" "$@"
+}
+
+qa_createdb() {
+ PGPASSWORD="$QA_DB_PASSWORD" createdb -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME"
+}
+
+qa_dropdb() {
+ if [[ "$QA_KEEP_DB" == "1" ]]; then
+ echo "[INFO] QA_KEEP_DB=1,保留临时数据库:$QA_DB_NAME"
+ return 0
+ fi
+
+ qa_psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${QA_DB_NAME}' AND pid <> pg_backend_pid();" >/dev/null || true
+ PGPASSWORD="$QA_DB_PASSWORD" dropdb --if-exists -h "$QA_DB_HOST" -p "$QA_DB_PORT" -U "$QA_DB_USER" "$QA_DB_NAME" >/dev/null || true
+}
+
+qa_cleanup_server() {
+ if [[ -f "$QA_SERVER_PID_FILE" ]]; then
+ local pid
+ pid="$(cat "$QA_SERVER_PID_FILE")"
+ if [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1; then
+ kill "$pid" >/dev/null 2>&1 || true
+ for _ in $(seq 1 15); do
+ if ! kill -0 "$pid" >/dev/null 2>&1; then
+ break
+ fi
+ sleep 1
+ done
+ if kill -0 "$pid" >/dev/null 2>&1; then
+ kill -9 "$pid" >/dev/null 2>&1 || true
+ fi
+ wait "$pid" >/dev/null 2>&1 || true
+ fi
+ rm -f "$QA_SERVER_PID_FILE"
+ fi
+
+ if qa_wait_port_free 15; then
+ return 0
+ fi
+
+ echo "[WARN] 端口 ${QA_PORT} 在 server 关闭后仍未释放,后续启动可能失败。" >&2
+}
+
+qa_cleanup() {
+ qa_cleanup_server
+ if [[ -n "${QA_DB_NAME:-}" && "$MODE" != "smoke-live" ]]; then
+ qa_dropdb
+ fi
+}
+
+qa_export_app_env() {
+ export NODE_ENV=test
+ export HOST="$QA_HOST"
+ export PORT="$QA_PORT"
+ export APP_ORIGIN="$QA_BASE_URL"
+ export E2E_BASE_URL="$QA_BASE_URL"
+ export BASE_URL="$QA_BASE_URL"
+ export DATABASE_URL="$DATABASE_URL"
+ export JWT_SECRET="${JWT_SECRET:-qa_web_gates_secret_key}"
+ export OCR_PROVIDER="${OCR_PROVIDER:-mock}"
+ export OCR_MAX_ATTEMPTS="${OCR_MAX_ATTEMPTS:-3}"
+ export OCR_POLL_CRON="${OCR_POLL_CRON:-*/5 * * * * *}"
+ export DELEGATION_REVOKE_CRON="${DELEGATION_REVOKE_CRON:-0 * * * *}"
+ export SERVE_CLIENT_DIST="true"
+ export CLIENT_DIST_DIR="../client/dist"
+ export PORTAL_HOME_ENABLED="true"
+ export GLOBAL_SEARCH_ENABLED="true"
+ export ANNOUNCEMENTS_ENABLED="true"
+ export PRESENCE_BOARD_ENABLED="true"
+ export WEEKLY_REPORT_ENABLED="true"
+ export EMPLOYEE_LIFECYCLE_ENABLED="true"
+ export TODO_BOARD_ENABLED="true"
+ export PROJECT_DOMAIN_ENABLED="true"
+ export PROJECT_LINKING_ENABLED="true"
+ export PROJECT_COMMUNICATION_LOG_ENABLED="true"
+ export PROJECT_CLOSURE_CHECKLIST_ENABLED="true"
+ export CONFIDENTIAL_DOC_REFS_ENABLED="true"
+ export TRAVEL_SYNC_ENABLED="true"
+ export IM_NOTIFIER_ENABLED="true"
+ export FIELD_ENCRYPTION_KEY="$TEST_FIELD_ENCRYPTION_KEY_DEFAULT"
+}
+
+qa_prepare_db() {
+ echo ">>> 创建临时数据库: $QA_DB_NAME"
+ qa_createdb
+
+ echo ">>> 执行 Prisma migrate"
+ (
+ cd "$SERVER_DIR"
+ npm run prisma:migrate:deploy
+ )
+
+ echo ">>> 写入开发种子数据"
+ (
+ cd "$SERVER_DIR"
+ npm run seed:dev
+ )
+}
+
+qa_start_server() {
+ if ! qa_wait_port_free 15; then
+ echo "[ERROR] 端口 ${QA_PORT} 已被占用,请改用 QA_PORT= 后重试。" >&2
+ exit 1
+ fi
+
+ rm -f "$QA_SERVER_LOG" "$QA_SERVER_PID_FILE"
+
+ echo ">>> 启动本地 server: $QA_BASE_URL"
+ (
+ cd "$SERVER_DIR"
+ nohup node src/index.js > "$QA_SERVER_LOG" 2>&1 &
+ echo $! > "$QA_SERVER_PID_FILE"
+ )
+
+ qa_wait_ready "$QA_READY_TIMEOUT_SECONDS" || {
+ echo "[ERROR] server 未就绪,最近日志如下:" >&2
+ tail -n 200 "$QA_SERVER_LOG" >&2 || true
+ exit 1
+ }
+}
+
+qa_build_client() {
+ echo ">>> 构建 client dist"
+ (
+ cd "$CLIENT_DIR"
+ npm run build
+ )
+}
+
+qa_run_smoke_local() {
+ qa_select_port
+ export JOBS_ENABLED="${JOBS_ENABLED:-false}"
+ qa_export_app_env
+ qa_prepare_db
+ qa_build_client
+ qa_start_server
+
+ echo ">>> 执行 Playwright smoke 门禁"
+ (
+ cd "$CLIENT_DIR"
+ rm -rf test-results playwright-report
+ E2E_MODE=local npm run test:e2e:smoke
+ )
+}
+
+qa_run_full_local() {
+ qa_select_port
+ export JOBS_ENABLED="${JOBS_ENABLED:-false}"
+ qa_export_app_env
+ qa_prepare_db
+
+ echo ">>> 执行 server Prisma 校验"
+ (
+ cd "$SERVER_DIR"
+ npm run prisma:validate
+ )
+
+ echo ">>> 执行 server 单元测试"
+ (
+ cd "$SERVER_DIR"
+ npm test
+ )
+
+ echo ">>> 执行 client typecheck"
+ (
+ cd "$CLIENT_DIR"
+ npm run typecheck
+ )
+
+ qa_build_client
+ qa_start_server
+
+ echo ">>> 执行 workflow-actions 回归"
+ (
+ cd "$SERVER_DIR"
+ ./scripts/regression-workflow-actions.sh
+ )
+
+ echo ">>> 执行 server 全量回归"
+ (
+ cd "$SERVER_DIR"
+ ./scripts/regression-all.sh
+ )
+
+ echo ">>> 切换 server 到 Playwright E2E 模式(启用后台 job)"
+ qa_cleanup_server
+ export JOBS_ENABLED="${JOBS_ENABLED_FOR_E2E:-true}"
+ qa_start_server
+
+ echo ">>> 执行 Playwright full E2E"
+ (
+ cd "$CLIENT_DIR"
+ rm -rf test-results playwright-report
+ E2E_MODE=local npm run test:e2e:full
+ )
+}
+
+qa_run_live_smoke() {
+ local live_base_url="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
+ echo ">>> 探活 live /ready: ${live_base_url}/ready"
+ curl -fsS "${live_base_url}/ready" >/dev/null
+
+ echo ">>> 执行 live smoke Playwright"
+ (
+ cd "$CLIENT_DIR"
+ rm -rf test-results playwright-report
+ E2E_MODE=live npm run test:e2e:live
+ )
+}
+
+case "$MODE" in
+ smoke-local)
+ qa_require_cmd curl
+ qa_require_cmd psql
+ qa_require_cmd createdb
+ qa_require_cmd dropdb
+ qa_require_cmd node
+ qa_require_cmd npm
+ trap qa_cleanup EXIT
+ qa_run_smoke_local
+ ;;
+ full-local)
+ qa_require_cmd curl
+ qa_require_cmd psql
+ qa_require_cmd createdb
+ qa_require_cmd dropdb
+ qa_require_cmd node
+ qa_require_cmd npm
+ trap qa_cleanup EXIT
+ qa_run_full_local
+ ;;
+ smoke-live)
+ qa_require_cmd curl
+ qa_require_cmd node
+ qa_require_cmd npm
+ qa_run_live_smoke
+ ;;
+ *)
+ qa_usage >&2
+ exit 1
+ ;;
+esac
diff --git a/server/src/scripts/smoke-external-deps.js b/server/src/scripts/smoke-external-deps.js
new file mode 100644
index 00000000..ce95b3aa
--- /dev/null
+++ b/server/src/scripts/smoke-external-deps.js
@@ -0,0 +1,141 @@
+require('dotenv/config');
+
+const { getConfig } = require('../config');
+
+function assertCondition(condition, message) {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+function formatList(items) {
+ return items.filter(Boolean).join(', ');
+}
+
+function evaluateWecom(config) {
+ if (!config.wecomEnabled) {
+ return { state: 'disabled', detail: '企业微信入口未启用', checks: [] };
+ }
+
+ const missing = [];
+ if (!String(config.wecomCorpId ?? '').trim()) missing.push('WECOM_CORP_ID');
+ if (!String(config.wecomAgentId ?? '').trim()) missing.push('WECOM_AGENT_ID');
+ if (!String(config.wecomAgentSecret ?? '').trim()) missing.push('WECOM_AGENT_SECRET');
+ if (config.wecomOauthEnabled && !String(config.wecomRedirectUri ?? '').trim()) missing.push('WECOM_REDIRECT_URI');
+ if (config.wecomEntryEnabled && !String(config.wecomAllowedReturnHosts ?? '').trim()) missing.push('WECOM_ALLOWED_RETURN_HOSTS');
+
+ assertCondition(missing.length === 0, `企业微信配置不完整:${formatList(missing)}`);
+
+ if (!config.wecomEntryEnabled || !config.wecomOauthEnabled) {
+ return {
+ state: 'degraded',
+ detail: `企业微信基础配置已启用,但入口=${config.wecomEntryEnabled ? 'on' : 'off'} oauth=${config.wecomOauthEnabled ? 'on' : 'off'}`,
+ checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`],
+ };
+ }
+
+ return {
+ state: 'ready',
+ detail: '企业微信入口与免登配置齐全',
+ checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`, `redirectUri=${config.wecomRedirectUri}`],
+ };
+}
+
+function evaluateEmail(config) {
+ if (!config.emailEnabled) {
+ return { state: 'disabled', detail: '企业邮箱 SMTP 未启用', checks: [] };
+ }
+
+ const missing = [];
+ if (!String(config.emailFrom ?? '').trim()) missing.push('EMAIL_FROM');
+ if (!String(config.smtpHost ?? '').trim()) missing.push('SMTP_HOST');
+ if (!Number.isFinite(Number(config.smtpPort)) || Number(config.smtpPort) <= 0) missing.push('SMTP_PORT');
+ if (String(config.smtpUser ?? '').trim() && !String(config.smtpPass ?? '').trim()) missing.push('SMTP_PASS');
+
+ assertCondition(missing.length === 0, `SMTP 配置不完整:${formatList(missing)}`);
+
+ return {
+ state: 'ready',
+ detail: 'SMTP 基础配置齐全(仅做配置契约校验,不发真实邮件)',
+ checks: [`from=${config.emailFrom}`, `host=${config.smtpHost}`, `port=${config.smtpPort}`, `secure=${config.smtpSecure}`],
+ };
+}
+
+function evaluateOcr(config) {
+ const provider = String(config.ocrProvider ?? 'off').trim();
+ if (!provider || provider === 'off') {
+ return { state: 'disabled', detail: 'OCR provider 已关闭', checks: [] };
+ }
+
+ if (provider === 'mock') {
+ return { state: 'mock', detail: 'OCR 使用 mock provider', checks: [`provider=${provider}`] };
+ }
+
+ if (provider === 'tencent') {
+ const missing = [];
+ if (!String(config.tencentSecretId ?? '').trim()) missing.push('TENCENT_SECRET_ID');
+ if (!String(config.tencentSecretKey ?? '').trim()) missing.push('TENCENT_SECRET_KEY');
+ assertCondition(missing.length === 0, `腾讯 OCR 配置不完整:${formatList(missing)}`);
+ return {
+ state: 'ready',
+ detail: '腾讯 OCR 配置齐全',
+ checks: [`provider=${provider}`, `region=${config.tencentOcrRegion}`],
+ };
+ }
+
+ return {
+ state: 'custom',
+ detail: `OCR provider=${provider},请确认对应适配器契约`,
+ checks: [`provider=${provider}`],
+ };
+}
+
+function evaluateBridge(config) {
+ if (!config.imNotifierEnabled) {
+ return { state: 'disabled', detail: '消息回流桥接未启用', checks: [] };
+ }
+
+ const missing = [];
+ if (!String(config.appOrigin ?? '').trim()) missing.push('APP_ORIGIN');
+ if (!String(config.fieldEncryptionKey ?? '').trim()) missing.push('FIELD_ENCRYPTION_KEY');
+ assertCondition(missing.length === 0, `消息桥接配置不完整:${formatList(missing)}`);
+
+ return {
+ state: 'ready',
+ detail: '消息回流桥接配置齐全',
+ checks: [`appOrigin=${config.appOrigin}`],
+ };
+}
+
+function printSection(name, section) {
+ const tag = section.state === 'ready' || section.state === 'mock' || section.state === 'disabled' || section.state === 'custom'
+ ? 'PASS'
+ : section.state === 'degraded'
+ ? 'WARN'
+ : 'INFO';
+ console.log(`[${tag}] ${name}: ${section.detail}`);
+ for (const line of section.checks ?? []) {
+ console.log(` - ${line}`);
+ }
+}
+
+function main() {
+ const config = getConfig();
+ console.log('[R30] 开始 external deps smoke');
+
+ const sections = {
+ wecom: evaluateWecom(config),
+ email: evaluateEmail(config),
+ ocr: evaluateOcr(config),
+ bridge: evaluateBridge(config),
+ };
+
+ printSection('企业微信', sections.wecom);
+ printSection('SMTP 邮件', sections.email);
+ printSection('OCR', sections.ocr);
+ printSection('消息桥接', sections.bridge);
+
+ console.log('[R30][PASS] external deps smoke 通过');
+}
+
+main();