diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 94b1cca7..f6877bc9 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -239,6 +239,14 @@ point and prevents each route from inventing a different title/action layout. `审批待办` in the sidebar by default. - PC leave navigation uses `请假记录` unless the current task explicitly opens OA emergency leave submission. +- HR/attendance pages must encode WeCom ownership in the UI surface: employee + leave and mobile leave are record-first/read-only, and OA emergency forms are + hidden unless `leave.manage`; attendance correction forms are hidden unless + `attendance.manage`. Labels must say `OA 应急` / `提交应急审批` / + `提交应急修正` instead of generic `提交`. +- When a page repeats the same title in the page header and a content section, + Playwright assertions should target the page header (`h1` or the header test + id) rather than `getByRole("heading", { name })`, which becomes ambiguous. - `个人设置` stays in the topbar account menu. Notifications stay in the topbar bell and notification center. - Finance labels should use work names such as `付款`, `应付账款` and `票据`; diff --git a/.trellis/spec/hyb-oa/wecom-boundary.md b/.trellis/spec/hyb-oa/wecom-boundary.md index 7c02c0aa..79dd2288 100644 --- a/.trellis/spec/hyb-oa/wecom-boundary.md +++ b/.trellis/spec/hyb-oa/wecom-boundary.md @@ -17,3 +17,30 @@ operating audit. manual backend/real-device gates where specified. - Messages to Enterprise WeChat must be dehydrated and link back to OA; do not send sensitive attachments or business documents into native WeCom approvals. + +## HR / Attendance UI Contracts + +- `请假记录` is the PC leave entry label and page title. Ordinary employees must + see records, balances and a WeCom-owned explanation, not an OA leave creation + form. +- OA leave creation, edit, submit and withdraw controls are emergency-only UI. + They require `leave.manage` and must be labeled with `OA 应急` / `应急草稿` / + `提交应急审批`; do not expose them based only on `leave.create`. +- Mobile leave defaults to records for ordinary employees. Hide the form tab and + row actions unless the user has `leave.manage`; keep the first card explicit: + `企业微信主办` and `只读`. +- Attendance correction forms are emergency-only UI. They require + `attendance.manage` and use `OA 应急修正` / `提交应急修正`. Ordinary employees + see a WeCom handling explanation for missed punch, outing and overtime. +- WeCom approval archive pages are read-only history and reconciliation. They + may show status/action history labels from WeCom data, but must not offer OA + approve/reject/return/transfer actions. + +## Tests Required + +- Mocked frontend Playwright coverage should assert that ordinary employees do + not see OA leave/correction create actions, while admin/HR roles retain + explicitly labeled emergency entry points. +- Full-stack Playwright coverage that writes local data still requires a running + local backend/API environment; a Vite-only run is not a valid failure signal + for those real-chain tests. diff --git a/.trellis/tasks/05-30-wecom-hr-boundary-pages/task.json b/.trellis/tasks/05-30-wecom-hr-boundary-pages/task.json index 794f9f02..5765c47a 100644 --- a/.trellis/tasks/05-30-wecom-hr-boundary-pages/task.json +++ b/.trellis/tasks/05-30-wecom-hr-boundary-pages/task.json @@ -3,7 +3,7 @@ "name": "wecom-hr-boundary-pages", "title": "企微主办 HR 考勤页面收敛", "description": "收敛请假记录、考勤、企微审批历史和移动请假页面:明确企微主办,OA 只做镜像、历史、异常和应急。", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": "frontend-page-redesign", "package": null, diff --git a/client/src/components/onboarding/WelcomeDialog.tsx b/client/src/components/onboarding/WelcomeDialog.tsx index 87f0f094..385d2fb6 100644 --- a/client/src/components/onboarding/WelcomeDialog.tsx +++ b/client/src/components/onboarding/WelcomeDialog.tsx @@ -31,9 +31,9 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [ }, { icon: "event_available", - label: "请假", - description: "提交请假申请或查看自己的请假记录", - to: "/leave-requests?action=create", + label: "请假记录", + description: "日常请假走企业微信,OA 查看记录和状态", + to: "/leave-requests", }, ]; diff --git a/client/src/pages/AttendancePage.tsx b/client/src/pages/AttendancePage.tsx index bcec3203..53b442bf 100644 --- a/client/src/pages/AttendancePage.tsx +++ b/client/src/pages/AttendancePage.tsx @@ -116,6 +116,14 @@ function correctionStatusLabel(status: AttendanceCorrectionStatus) { return "已撤回"; } +function syncRunStatusLabel(status: string) { + if (status === "running") return "同步中"; + if (status === "success") return "已完成"; + if (status === "skipped") return "已跳过"; + if (status === "failed") return "失败"; + return "待确认"; +} + function dayStatusLabel(status: AttendanceDailyStatus) { if (status === "normal") return "正常"; if (status === "exception") return "异常"; @@ -158,6 +166,7 @@ export function AttendancePage() { const { me, permissions } = useAuth(); const canSync = hasPermission(permissions, "attendance.sync"); const canViewAll = hasPermission(permissions, "attendance.view_all"); + const canUseOaEmergencyCorrection = hasPermission(permissions, "attendance.manage"); const [activeTab, setActiveTab] = useState("summary"); const [month, setMonth] = useState(currentMonthInput()); const [form, setForm] = useState(() => buildDefaultForm()); @@ -193,9 +202,9 @@ export function AttendancePage() { endAt: form.kind !== "missed_punch" ? toIsoOrUndefined(form.endAt) : undefined, locationSummary: form.kind === "out" ? form.locationSummary.trim() || undefined : undefined, compensationPolicy: form.kind === "overtime" ? form.compensationPolicy.trim() || undefined : undefined, - }), + }), onSuccess: async () => { - toast.success("考勤修正已提交审批"); + toast.success("考勤应急修正已提交"); setForm(buildDefaultForm()); await Promise.all([ queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }), @@ -247,7 +256,7 @@ export function AttendancePage() { if (featureDisabled) { return ( - + 日常打卡、请假和假期余额由企业微信承载;OA 只保留汇总、解释和审计入口。 @@ -262,9 +271,9 @@ export function AttendancePage() { return ( - + - 日常打卡仍在企业微信完成;OA 不写回企业微信,只展示汇总、修正审批和同步审计。 + 日常打卡和考勤类申请仍在企业微信完成。OA 不写回企业微信;只有 HR 或管理员确认应急接管时,才在 OA 记录应急修正。
@@ -349,7 +358,8 @@ export function AttendancePage() { - + {canUseOaEmergencyCorrection ? ( +
@@ -448,12 +458,19 @@ export function AttendancePage() { onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !form.reason.trim()} > - 提交审批 + 提交应急修正
+ ) : ( + +
+ 请在企业微信提交补卡、外出和加班。OA 当前只展示已同步的解释、历史记录和异常摘要;需要应急接管时由 HR 或管理员处理。 +
+
+ )} - + {correctionsQuery.isLoading ? ( ) : correctionsQuery.isError ? ( @@ -567,7 +584,7 @@ export function AttendancePage() { {(syncRunsQuery.data?.items ?? []).map((item) => ( {item.startDate ?? "-"} ~ {item.endDate ?? "-"} - {item.status} + {syncRunStatusLabel(item.status)} {item.employeeCount} / {item.recordCount} {item.errorCode ?? "-"} {formatDateTime(item.createdAt)} diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx index 1634f8eb..10af25e1 100644 --- a/client/src/pages/LeaveRequestPage.tsx +++ b/client/src/pages/LeaveRequestPage.tsx @@ -23,7 +23,7 @@ import { ConfirmDialog } from "../components/common/ConfirmDialog"; import { Pagination } from "../components/common/Pagination"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; -import { ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader } from "../components/patterns/ListWorkspace"; +import { ListWorkspace, PrimaryTable, StatStrip, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace"; import { MetricCard } from "../components/patterns/MetricCard"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; @@ -206,6 +206,7 @@ export function LeaveRequestPage() { const canViewAll = hasLeaveViewAll || hasLeaveManage; const canViewTeamCalendar = hasLeaveViewAll; const useDepartmentCalendarScope = hasLeaveViewAll && !hasLeaveManage && Boolean(me?.departmentId); + const canUseOaEmergencyLeave = hasLeaveManage; const [statusFilter, setStatusFilter] = useState(""); const [scope, setScope] = useState<"own" | "all">("own"); @@ -255,6 +256,7 @@ export function LeaveRequestPage() { const leaveTypesQuery = useQuery({ queryKey: ["leave", "types"], + enabled: canUseOaEmergencyLeave, queryFn: () => fetchLeaveTypes({ enabled: true }), }); @@ -416,7 +418,7 @@ export function LeaveRequestPage() { queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), ]); resetForm(); - toast.success("请假草稿已创建"); + toast.success("OA 应急请假草稿已创建"); }, onError: (error) => { const message = resolveError(error); @@ -443,7 +445,7 @@ export function LeaveRequestPage() { queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), ]); resetForm(); - toast.success("请假单已保存,可继续提交"); + toast.success("OA 应急请假单已保存"); }, onError: (error) => { const message = resolveError(error); @@ -460,7 +462,7 @@ export function LeaveRequestPage() { queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }), queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), ]); - toast.success("请假申请已提交"); + toast.success("OA 应急请假单已提交"); }, onError: (error) => { toast.error(resolveError(error)); @@ -475,7 +477,7 @@ export function LeaveRequestPage() { queryClient.invalidateQueries({ queryKey: ["leave", "calendar"] }), queryClient.invalidateQueries({ queryKey: ["leave", "balances"] }), ]); - toast.success("请假申请已撤回"); + toast.success("OA 应急请假单已撤回"); setWithdrawTarget(null); }, onError: (error) => { @@ -486,15 +488,15 @@ export function LeaveRequestPage() { const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE)); const calendarIsTruncated = (calendarRequestsQuery.data?.total ?? 0) > (calendarRequestsQuery.data?.items.length ?? 0); const isSavingForm = createMutation.isPending || updateMutation.isPending; - const formTitle = editingLeaveRequest ? "编辑请假单" : "新建请假草稿"; + const formTitle = editingLeaveRequest ? "编辑 OA 应急请假单" : "OA 应急请假草稿"; const formDescription = editingLeaveRequest - ? "修改原单内容并保留同一请假单 ID,保存后仍可继续提交。" - : "先填写时间段与假种,再补充附件和说明。"; + ? "仅用于管理员确认企业微信不可用后的应急接管;保存后仍需按应急审批留痕。" + : "日常请假在企业微信发起。这里只保留 HR/管理员应急接管时的 OA 草稿入口。"; const currentAttachmentLabel = attachmentFile?.name ?? (editingLeaveRequest?.attachment?.originalName ?? null); const leaveRecordsPanel = ( - +
最近一次筛选会保留在当前页面状态中。
@@ -560,9 +562,9 @@ export function LeaveRequestPage() { {(requestsQuery.data?.items ?? []).length ? ( requestsQuery.data?.items.map((item) => { const isOwner = item.ownerId === me?.id; - const canEdit = isOwner && ["draft", "rejected"].includes(item.status); - const canSubmit = isOwner && ["draft", "rejected"].includes(item.status); - const canWithdraw = isOwner && item.status === "reviewing"; + const canEdit = canUseOaEmergencyLeave && isOwner && ["draft", "rejected"].includes(item.status); + const canSubmit = canUseOaEmergencyLeave && isOwner && ["draft", "rejected"].includes(item.status); + const canWithdraw = canUseOaEmergencyLeave && isOwner && item.status === "reviewing"; return ( @@ -596,39 +598,43 @@ export function LeaveRequestPage() { {item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"} -
- - - -
+ {canUseOaEmergencyLeave ? ( +
+ + + +
+ ) : ( + 企业微信处理 + )}
); @@ -780,23 +786,31 @@ export function LeaveRequestPage() { return ( + + 日常请假请在企业微信完成。OA 不重复发起常规请假,只展示同步/历史状态;只有 HR 或管理员确认应急接管时才使用 OA 应急草稿。 + 0 ? "warning" : "neutral"} /> - + {canUseOaEmergencyLeave ? ( + + ) : ( + + )}
+ {canUseOaEmergencyLeave ? (
{formError ? ( @@ -813,7 +827,7 @@ export function LeaveRequestPage() { {isNonWorkingPreview ? (

- 所选日期范围均为非工作日,无需提交请假 + 所选日期范围均为非工作日,无需发起请假

) : null} @@ -957,14 +971,21 @@ export function LeaveRequestPage() { ? updateMutation.isPending ? "保存中..." : "保存修改" - : createMutation.isPending - ? "创建中..." - : "创建草稿"} + : createMutation.isPending + ? "创建中..." + : "创建应急草稿"}
+ ) : ( + +
+ 请在企业微信完成请假、补卡、外出和加班。OA 当前用于查看记录、额度摘要、团队排班和同步异常;如企业微信不可用,由 HR 或管理员开启应急接管后再使用 OA。 +
+
+ )}
@@ -1050,7 +1071,7 @@ export function LeaveRequestPage() { setWithdrawTarget(null); } }} - title="确认撤回请假申请?" + title="确认撤回 OA 应急请假单?" description={withdrawTarget ? `将撤回:${withdrawTarget.label}` : undefined} confirmLabel="确认撤回" onConfirm={async () => { diff --git a/client/src/pages/MobileLeavePage.tsx b/client/src/pages/MobileLeavePage.tsx index 0b8b2445..bab5a649 100644 --- a/client/src/pages/MobileLeavePage.tsx +++ b/client/src/pages/MobileLeavePage.tsx @@ -147,7 +147,7 @@ function LeaveRecordCard({ ) : null} {canSubmit ? ( ) : null}
@@ -162,7 +162,7 @@ export function MobileLeavePage() { const { leaveRequestId = "" } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const { permissions } = useAuth(); - const canCreateLeave = hasPermission(permissions, "leave.create"); + const canUseOaEmergencyLeave = hasPermission(permissions, "leave.manage"); const [view, setView] = useState(() => resolveLeaveView(searchParams.get("view"))); const [statusFilter, setStatusFilter] = useState(() => { const queryStatus = searchParams.get("status"); @@ -177,13 +177,14 @@ export function MobileLeavePage() { const [reason, setReason] = useState(""); const [attachmentFile, setAttachmentFile] = useState(null); const attachmentInputRef = useRef(null); + const effectiveView = canUseOaEmergencyLeave ? view : "records"; const currentMobileSource = useMemo(() => { const params = new URLSearchParams(location.search); - params.set("view", view); + params.set("view", effectiveView); params.set("status", statusFilter); const query = params.toString(); return `${location.pathname}${query ? `?${query}` : ""}`; - }, [location.pathname, location.search, statusFilter, view]); + }, [effectiveView, location.pathname, location.search, statusFilter]); useEffect(() => { setView(resolveLeaveView(searchParams.get("view"))); @@ -251,7 +252,7 @@ export function MobileLeavePage() { file: attachmentFile, }), onSuccess: async () => { - toast.success("请假草稿已创建"); + toast.success("OA 应急请假草稿已创建"); resetForm(); await invalidateLeaveQueries(); }, @@ -276,7 +277,7 @@ export function MobileLeavePage() { }); }, onSuccess: async () => { - toast.success("请假单已保存"); + toast.success("OA 应急请假单已保存"); resetForm(); await invalidateLeaveQueries(); }, @@ -288,7 +289,7 @@ export function MobileLeavePage() { const submitMutation = useMutation({ mutationFn: (item: LeaveRequestItem) => submitLeaveRequest(item.id), onSuccess: async () => { - toast.success("请假单已提交"); + toast.success("OA 应急请假单已提交"); await invalidateLeaveQueries(); }, onError: (error) => { @@ -314,6 +315,9 @@ export function MobileLeavePage() { const isSaving = createMutation.isPending || updateMutation.isPending || submitMutation.isPending; const startEditing = (item: LeaveRequestItem) => { + if (!canUseOaEmergencyLeave) { + return; + } setView("form"); const nextParams = new URLSearchParams(searchParams); nextParams.set("view", "form"); @@ -332,6 +336,9 @@ export function MobileLeavePage() { function handleViewChange(value: string) { const nextView = resolveLeaveView(value); + if (nextView === "form" && !canUseOaEmergencyLeave) { + return; + } setView(nextView); const nextParams = new URLSearchParams(searchParams); nextParams.set("view", nextView); @@ -382,18 +389,30 @@ export function MobileLeavePage() { return ( - + + +
+
+
+
企业微信主办
+

+ 日常请假、补卡、外出和加班在企业微信完成;OA 这里只查看记录、额度和历史状态。 +

+
+ 只读 +
+
- - + + 请假记录 - 新建请假 + {canUseOaEmergencyLeave ? OA 应急 : null}
- {view === "form" ? ( + {effectiveView === "form" ? ( <>
@@ -414,7 +433,7 @@ export function MobileLeavePage() {
-

{editingItem ? "编辑请假" : "新建请假"}

+

{editingItem ? "编辑 OA 应急请假" : "新建 OA 应急请假"}

{editingItem ? (
@@ -546,7 +565,7 @@ export function MobileLeavePage() { ) : null} - {view === "records" ? ( + {effectiveView === "records" ? (

请假记录

@@ -578,6 +597,7 @@ export function MobileLeavePage() { key={item.id} onEdit={startEditing} onSubmit={(nextItem) => submitMutation.mutate(nextItem)} + showActions={canUseOaEmergencyLeave} /> ))}
diff --git a/client/tests/e2e/leave-flow.spec.ts b/client/tests/e2e/leave-flow.spec.ts index 30e17c0b..4e7a08e7 100644 --- a/client/tests/e2e/leave-flow.spec.ts +++ b/client/tests/e2e/leave-flow.spec.ts @@ -1,206 +1,324 @@ import { test, expect } from "./helpers/fixtures"; -import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth"; +import type { Page, Route } from "@playwright/test"; + +import { ADMIN, EMPLOYEE, login, logout, type E2EUser } from "./helpers/auth"; import { openNavLink } from "./helpers/navigation"; -import { TEST_IDS } from "./helpers/testIds"; -import type { Locator, Page } from "@playwright/test"; -function formatDate(offsetDays: number) { - const value = new Date(); - value.setUTCDate(value.getUTCDate() + offsetDays); - return value.toISOString().slice(0, 10); +test.use({ serviceWorkers: "block" }); + +function envelope(data: unknown) { + return { + requestId: "mock-leave-boundary", + data, + }; } -function uniqueFutureOffset(minOffsetDays: number) { - return minOffsetDays + (Math.floor(Date.now() / 1000) % 2000); +async function fulfillJson(route: Route, data: unknown, status = 200, headers: Record = {}) { + await route.fulfill({ + status, + contentType: "application/json", + headers, + body: JSON.stringify(envelope(data)), + }); } +function buildMe(user: E2EUser) { + const isAdmin = user.identifier === ADMIN.identifier; + return { + id: isAdmin ? "employee-admin" : "employee-user", + employeeNo: isAdmin ? "A001" : "E001", + name: user.name, + phone: "13800000000", + email: user.identifier, + departmentId: "department-leave", + directManagerId: null, + status: "active", + notificationPushEnabled: true, + roleCodes: isAdmin ? ["admin"] : ["employee"], + profileCompleteness: { + hasEmergencyContact: true, + hasBankAccount: true, + hasAvatar: false, + hasIdNumber: false, + score: 50, + }, + wecomBinding: null, + }; +} -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), +function leaveRequestFor(user: E2EUser) { + const me = buildMe(user); + return { + id: "leave-boundary-1", + ownerId: me.id, + departmentId: me.departmentId, + status: "draft", + startDate: "2026-05-31", + endDate: "2026-05-31", + startSession: "am", + endSession: "pm", + requestedDays: 1, + reason: "企业微信同步记录", + attachment: null, + leaveType: { + id: "leave-type-annual", + code: "annual", + name: "年假", + requiresAttachment: false, + attachmentMimeTypes: [], + }, + owner: { + id: me.id, + employeeNo: me.employeeNo, + name: me.name, + }, + pendingWorkflow: null, + approvalHistory: [], + createdAt: "2026-05-30T09:00:00.000Z", + updatedAt: "2026-05-30T09:30:00.000Z", + }; +} + +async function mockLeaveBoundaryApis(page: Page) { + let activeUser = EMPLOYEE; + + await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const path = url.pathname; + + if (path === "/api/auth/capabilities") { + await fulfillJson(route, { + emailDelivery: { ready: true, statusReasons: [] }, + emailLoginCode: { enabled: true }, + passwordReset: { enabled: true }, + }); + return; + } + + if (path === "/api/auth/sso/providers") { + await fulfillJson(route, []); + return; + } + + if (path === "/api/auth/wecom/config") { + await fulfillJson(route, { + enabled: true, + entryEnabled: true, + oauthEnabled: true, + corpId: "ww-leave-boundary", + agentId: "1000002", + loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal", + bindEnabled: true, + defaultReturnTo: "/portal", + status: "ready", + statusReasons: [], + }); + return; + } + + if (path === "/api/auth/login") { + const payload = request.postDataJSON() as { identifier?: string }; + activeUser = payload.identifier === ADMIN.identifier ? ADMIN : EMPLOYEE; + await fulfillJson( + route, + { accessToken: `mock-token-${activeUser.name}`, expiresIn: 900, mustChangePassword: false }, + 200, + { "set-cookie": "has_session=1; Path=/; SameSite=Strict" }, ); - return optionValues[0] ?? ""; - }) - .not.toBe(""); + return; + } - 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), - ); + if (path === "/api/auth/refresh") { + await fulfillJson(route, { accessToken: `mock-token-${activeUser.name}`, expiresIn: 900 }); + return; + } - await locator.selectOption(firstValue); + if (path === "/api/auth/logout") { + await fulfillJson(route, {}, 200, { "set-cookie": "has_session=; Path=/; Max-Age=0" }); + return; + } + + if (path === "/api/me") { + await fulfillJson(route, buildMe(activeUser)); + return; + } + + if (path === "/api/system/navigation") { + await fulfillJson(route, { + workspace: { + id: "workspace-leave-boundary", + persisted: true, + profile: "small_team_internal", + orgMode: "single_company", + primaryEntryMode: "wecom", + notificationStrategy: "in_app", + devicePriority: "desktop", + moduleVisibility: {}, + createdAt: null, + updatedAt: null, + updatedBy: null, + }, + sections: { + main: [ + { key: "portal", href: "/portal", label: "工作台", icon: "home_app_logo", placement: "main", permissionCodes: [], featureFlagKey: null }, + { key: "expense", href: "/reimbursements", label: "报销", icon: "request_quote", placement: "main", permissionCodes: [], featureFlagKey: null }, + { key: "leave", href: "/leave-requests", label: "请假记录", icon: "event_available", placement: "main", permissionCodes: [], featureFlagKey: null }, + ], + admin: [], + backup: [], + }, + recommendedRolePresets: [], + }); + return; + } + + if (path === "/api/portal/home") { + await fulfillJson(route, { + workspace: { id: "workspace-leave-boundary", persisted: true, profile: "small_team_internal" }, + greeting: { name: activeUser.name }, + cards: [], + pendingTasks: { count: 0, href: "/workflow/tasks" }, + myApplications: [{ domain: "leave", count: 1, href: "/leave-requests" }], + riskSummary: null, + announcements: [], + quickLinks: [], + todayPresence: { date: "2026-05-30", status: "office", label: "在岗", note: null, href: "/team" }, + teamPresence: null, + weeklyReport: null, + weeklySummary: null, + todoSummary: null, + projectSummary: null, + peopleLifecycle: null, + adoptionSummary: null, + recentActivities: [], + teamStatusBoard: [], + }); + return; + } + + if (path === "/api/attendance/me/today") { + await fulfillJson(route, { + workDate: "2026-05-30", + needClockIn: false, + exemptionReason: "wecom_owned", + item: null, + }); + return; + } + + if (path === "/api/notifications") { + await fulfillJson(route, { + page: 1, + pageSize: 10, + total: 0, + unreadOnly: false, + unreadCount: 0, + items: [], + }); + return; + } + + if (path === "/api/leave-types") { + await fulfillJson(route, { + items: [ + { + id: "leave-type-annual", + code: "annual", + name: "年假", + description: null, + enabled: true, + requiresAttachment: false, + attachmentMimeTypes: [], + sortOrder: 1, + policy: { + id: "leave-policy-annual", + annualQuotaDays: 15, + enabled: true, + description: null, + }, + createdAt: "2026-05-30T00:00:00.000Z", + updatedAt: "2026-05-30T00:00:00.000Z", + }, + ], + }); + return; + } + + if (path === "/api/leave-balances") { + await fulfillJson(route, { + employeeId: buildMe(activeUser).id, + items: [ + { + leaveType: { + id: "leave-type-annual", + code: "annual", + name: "年假", + description: null, + enabled: true, + requiresAttachment: false, + attachmentMimeTypes: [], + sortOrder: 1, + policy: null, + createdAt: null, + updatedAt: null, + }, + policy: { + id: "leave-policy-annual", + annualQuotaDays: 15, + enabled: true, + description: null, + }, + granted: 15, + adjusted: 0, + reserved: 1, + used: 2, + available: 12, + periodStart: "2026-01-01", + periodEnd: "2026-12-31", + }, + ], + }); + return; + } + + if (path === "/api/leave-requests") { + await fulfillJson(route, { + page: 1, + pageSize: 10, + total: 1, + scope: "own", + items: [leaveRequestFor(activeUser)], + }); + return; + } + + await fulfillJson(route, {}); + }); } -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(), - ); +test("E2E 请假边界:员工只看企微主办记录,不显示 OA 日常提交入口", async ({ page }) => { + await mockLeaveBoundaryApis(page); + await login(page, EMPLOYEE); + await (await openNavLink(page, "请假记录")).click(); - 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(); + await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); + await expect(page.getByText("企业微信主办请假、补卡、外出和加班")).toBeVisible(); + await expect(page.getByRole("heading", { name: "日常请假入口" })).toBeVisible(); + await expect(page.locator("#leave-type")).toHaveCount(0); + await expect(page.getByRole("button", { name: "创建应急草稿" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "提交应急审批" })).toHaveCount(0); - 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 = uniqueFutureOffset(420); - const startDate = formatDate(baseOffset); - const updatedEndDate = formatDate(baseOffset + 1); - - try { - await login(page, EMPLOYEE); - await (await openNavLink(page, "请假记录")).click(); - await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); - - const leaveId = await createLeaveDraft(page, startDate, startDate, `Playwright 编辑请假 ${Date.now()}`); - await page.goto("/leave-requests"); - - const draftRow = page.getByRole("row").filter({ hasText: startDate }).first(); - await expect(draftRow).toContainText("草稿", { timeout: 30_000 }); - await expect(draftRow).toContainText("草稿可继续编辑", { timeout: 30_000 }); - - const updateResponsePromise = page.waitForResponse((response) => { - return response.request().method() === "PATCH" && new URL(response.url()).pathname === `/api/leave-requests/${leaveId}` && response.ok(); - }); - - await draftRow.getByRole("button", { name: "编辑" }).click(); - await expect(page.getByText("编辑请假单")).toBeVisible(); - await page.locator("#leave-end-date").fill(updatedEndDate); - await page.locator("#leave-reason").fill(`更新后的请假说明 ${Date.now()}`); - await page.getByRole("button", { name: "保存修改" }).click(); - - const updatePayload = (await (await updateResponsePromise).json()) as { data?: { id?: string } }; - expect(updatePayload.data?.id).toBe(leaveId); - - await page.goto("/leave-requests"); - const updatedRow = page.getByRole("row").filter({ hasText: updatedEndDate }).first(); - await expect(updatedRow).toContainText("草稿", { timeout: 30_000 }); - - await submitLeave(employeeClient.api, leaveId); - await page.goto("/leave-requests"); - await expect(updatedRow).toContainText("审批中", { timeout: 30_000 }); - await expect(updatedRow).toContainText("当前节点", { timeout: 30_000 }); - } finally { - await employeeClient.dispose(); - } + await logout(page, EMPLOYEE.name); }); -test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => { - const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); - const baseOffset = uniqueFutureOffset(2600); - const firstDate = formatDate(baseOffset); - const secondDate = formatDate(baseOffset + 7); +test("E2E 请假边界:管理员保留 OA 应急草稿入口", async ({ page }) => { + await mockLeaveBoundaryApis(page); + await login(page, ADMIN); + await page.goto("/leave-requests"); - 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(); - await expect(page.locator(`a[href="/workflow/tasks/${secondSubmit.currentTaskId}"]`).first()).toHaveCount(1, { timeout: 30_000 }); - await page.goto(`/workflow/tasks/${secondSubmit.currentTaskId}`); - await workflowDetail.assertLoaded(); - await workflowDetail.approve(); - await expect(page.getByRole("main")).toContainText("已完成", { timeout: 30_000 }); - await expect(page.getByRole("main")).toContainText("业务状态:已通过", { timeout: 30_000 }); - - await logout(page, MANAGER.name); - - await login(page, EMPLOYEE); - await page.goto("/leave-requests"); - const approvedRow = page.getByRole("row").filter({ hasText: secondDate }).first(); - await expect(approvedRow).toContainText("已通过", { timeout: 30_000 }); - await expect(approvedRow).toContainText("已通过,系统会按请假结果更新额度和团队状态", { timeout: 30_000 }); - await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 }); - } finally { - await employeeClient.dispose(); - } -}); - -test("E2E 请假退回说明:审批退回后员工可看到处理意见", async ({ page, workflowDetail }) => { - const employeeClient = await createAuthenticatedApiClient(EMPLOYEE); - const baseOffset = uniqueFutureOffset(5200); - const leaveDate = formatDate(baseOffset); - const returnComment = `请补充外出交接安排 ${Date.now()}`; - - try { - await login(page, EMPLOYEE); - await (await openNavLink(page, "请假记录")).click(); - await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); - - const leaveId = await createLeaveDraft(page, leaveDate, leaveDate, `Playwright 退回请假 ${Date.now()}`); - const submitResult = await submitLeave(employeeClient.api, leaveId); - - await logout(page, EMPLOYEE.name); - - await login(page, MANAGER); - await page.goto(`/workflow/tasks/${submitResult.currentTaskId}`); - await workflowDetail.assertLoaded(); - await workflowDetail.returnTask(returnComment); - await expect(page.getByRole("main")).toContainText("已完成", { timeout: 30_000 }); - await expect(page.getByRole("main")).toContainText("业务状态:已终止", { timeout: 30_000 }); - - await logout(page, MANAGER.name); - - await login(page, EMPLOYEE); - await page.goto("/leave-requests"); - const returnedRow = page.getByRole("row").filter({ hasText: leaveDate }).first(); - await expect(returnedRow).toContainText("草稿", { timeout: 30_000 }); - await expect(returnedRow).toContainText("审批已退回", { timeout: 30_000 }); - await expect(returnedRow).toContainText(returnComment, { timeout: 30_000 }); - } finally { - await employeeClient.dispose(); - } + await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "OA 应急请假草稿" })).toBeVisible(); + await expect(page.locator("#leave-type")).toBeVisible(); + await expect(page.getByRole("button", { name: "创建应急草稿" })).toBeVisible(); }); diff --git a/client/tests/e2e/leave-team-calendar.spec.ts b/client/tests/e2e/leave-team-calendar.spec.ts index f46b4e39..117f24aa 100644 --- a/client/tests/e2e/leave-team-calendar.spec.ts +++ b/client/tests/e2e/leave-team-calendar.spec.ts @@ -206,7 +206,7 @@ test("E2E 请假团队日历:HR 可见全员周/月视图并显示截断提示 await login(page, LEAVE_CALENDAR_HR); await page.goto("/leave-requests"); - await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); + await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); await expect(page.getByTestId("leave-view-calendar")).toBeVisible(); const weekResponse = page.waitForResponse((response) => { @@ -244,7 +244,7 @@ test("E2E 请假团队日历:经理只拉本部门数据且导航会触发新 await login(page, MANAGER); await page.goto("/leave-requests"); - await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); + await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); await expect(page.getByTestId("leave-view-calendar")).toBeVisible(); const firstResponse = page.waitForResponse((response) => { @@ -319,7 +319,10 @@ test("E2E 请假团队日历:员工视角不显示团队日历 Tab", async ({ await login(page, EMPLOYEE); await page.goto("/leave-requests"); - await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible(); + await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "日常请假入口" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "OA 应急请假草稿" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "创建应急草稿" })).toHaveCount(0); await expect(page.getByTestId("leave-view-calendar")).toHaveCount(0); await expect(page.getByTestId("leave-page-view-tabs")).toHaveCount(0); }); diff --git a/client/tests/e2e/r101-mobile-wip-closeout.spec.ts b/client/tests/e2e/r101-mobile-wip-closeout.spec.ts index ccfa5fe0..1b5f05e4 100644 --- a/client/tests/e2e/r101-mobile-wip-closeout.spec.ts +++ b/client/tests/e2e/r101-mobile-wip-closeout.spec.ts @@ -397,20 +397,18 @@ test.describe("R101 mobile WIP closeout", () => { await attachScreenshot(page, testInfo, "r101-mobile-reimbursements.png"); }); - test("移动请假页使用表单和卡片记录", async ({ page }, testInfo) => { + test("移动请假页默认只看记录并提示企微主办", async ({ page }, testInfo) => { await page.goto("/mobile/leave"); - await expect(page.getByRole("heading", { name: "请假", exact: true })).toBeVisible(); + await expect(page.locator("h1", { hasText: "请假记录" })).toBeVisible(); + await expect(page.getByText("企业微信主办")).toBeVisible(); await expect(page.getByTestId("mobile-leave-records")).toBeVisible(); await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible(); - await page.getByTestId("mobile-leave-view-form").click(); - await expect(page.getByTestId("mobile-leave-summary")).toBeVisible(); - await expect(page.getByTestId("mobile-leave-form")).toBeVisible(); - await expect(page.getByRole("button", { name: "选择附件" })).toBeVisible(); - await page.getByTestId("mobile-leave-view-records").click(); + await expect(page.getByTestId("mobile-leave-view-form")).toHaveCount(0); + await expect(page.getByTestId("mobile-leave-form")).toHaveCount(0); await expect(page.getByTestId("mobile-leave-record-card")).toBeVisible(); - await expect(page.getByRole("button", { name: "编辑" })).toBeVisible(); - await expect(page.getByRole("button", { name: "提交" })).toBeVisible(); + await expect(page.getByRole("button", { name: "编辑" })).toHaveCount(0); + await expect(page.getByRole("button", { name: "提交应急审批" })).toHaveCount(0); await assertNoMobileDesignRegression(page); await attachScreenshot(page, testInfo, "r101-mobile-leave.png"); }); diff --git a/client/tests/e2e/r75-automation-gap-fill.spec.ts b/client/tests/e2e/r75-automation-gap-fill.spec.ts index 2fc4829a..ffc1ece2 100644 --- a/client/tests/e2e/r75-automation-gap-fill.spec.ts +++ b/client/tests/e2e/r75-automation-gap-fill.spec.ts @@ -223,17 +223,22 @@ test.describe("R75 自动化补盲", () => { await login(page, EMPLOYEE); await page.goto("/attendance"); await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible(); - await expect(page.getByText("企业微信采集打卡")).toBeVisible(); + await expect(page.getByText("企业微信主办打卡")).toBeVisible(); await expect(page.getByRole("button", { name: /点击打上班卡|点击下班|重新打下班卡/ })).toHaveCount(0); await expect(page.getByText("本月打卡记录")).toHaveCount(0); + await page.getByRole("tab", { name: "修正申请" }).click(); + await expect(page.getByRole("heading", { name: "企微处理入口" })).toBeVisible(); + await expect(page.getByRole("button", { name: "提交应急修正" })).toHaveCount(0); await logout(page, EMPLOYEE.name); await login(page, ADMIN); await page.goto("/attendance"); await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible(); - await expect(page.getByText("OA 统一处理请假、补卡、外出、加班和月度解释")).toBeVisible(); + await expect(page.getByText("OA 展示月度摘要、异常解释、同步状态和审计记录")).toBeVisible(); await expect(page.getByRole("tab", { name: "修正申请" })).toBeVisible(); + await page.getByRole("tab", { name: "修正申请" }).click(); + await expect(page.getByRole("heading", { name: "OA 应急修正" })).toBeVisible(); await expect(page.getByRole("tab", { name: "团队考勤" })).toHaveCount(0); await expect(page.getByRole("button", { name: "保存补卡" })).toHaveCount(0); } finally { diff --git a/docs/产品设计规则.md b/docs/产品设计规则.md index 362fb057..a194d649 100644 --- a/docs/产品设计规则.md +++ b/docs/产品设计规则.md @@ -127,6 +127,8 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台 - PC 侧边栏是小团队任务入口,不是全量模块目录;一级入口必须按角色、权限、频次和真实办事价值收敛。已启用且有权限的 OA 主办业务台账,例如 `合同`、`采购`、`资产`、`项目`,不默认藏到 `更多`,否则会给常用业务增加二次点击负担。 - 审批人、负责人和财务看到 `待我处理`;普通员工默认不显示 `待办` 或 `审批待办` 入口;通知只保留顶部铃铛或通知中心,不进入侧边栏。 - PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。 +- 移动请假默认是记录查看和状态确认;普通员工不显示 OA 新建请假表单、编辑和提交动作。HR/管理员应急接管时才显示 `OA 应急` 表单和 `提交应急审批`。 +- 考勤页普通员工只看月度摘要、异常解释、同步/留痕状态和企微处理说明;`OA 应急修正` 只给 HR/管理员处理企微不可用时的应急接管。 - 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和外部系统入口进入管理页后分组承接。 - 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`ERP 集成`、`功能开关` 等内部平台词不作为默认一级菜单文案。 - `更多` 只承载低频、冻结、阶段性或偶尔查看的入口,不使用说明型副标题、教学卡片或历史模块解释文案;不得把常用业务台账作为默认二级入口。