收敛企微主办 HR 考勤页面(05-30-wecom-hr-boundary-pages)
这个提交包含在:
@@ -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 `票据`;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -31,9 +31,9 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [
|
||||
},
|
||||
{
|
||||
icon: "event_available",
|
||||
label: "请假",
|
||||
description: "提交请假申请或查看自己的请假记录",
|
||||
to: "/leave-requests?action=create",
|
||||
label: "请假记录",
|
||||
description: "日常请假走企业微信,OA 查看记录和状态",
|
||||
to: "/leave-requests",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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<AttendanceTab>("summary");
|
||||
const [month, setMonth] = useState(currentMonthInput());
|
||||
const [form, setForm] = useState<CorrectionFormState>(() => 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 (
|
||||
<PageScaffoldLike>
|
||||
<PageHeader title="考勤" subtitle="日常打卡在企业微信,审批和解释在 OA。" />
|
||||
<PageHeader title="考勤" subtitle="企业微信主办打卡、补卡、外出和加班;OA 只保留汇总、解释和审计。" />
|
||||
<WorkspaceNotice title="企业微信主办" tone="info">
|
||||
日常打卡、请假和假期余额由企业微信承载;OA 只保留汇总、解释和审计入口。
|
||||
</WorkspaceNotice>
|
||||
@@ -262,9 +271,9 @@ export function AttendancePage() {
|
||||
|
||||
return (
|
||||
<PageScaffoldLike>
|
||||
<PageHeader title="考勤" subtitle="企业微信采集打卡;OA 统一处理请假、补卡、外出、加班和月度解释。" />
|
||||
<PageHeader title="考勤" subtitle="企业微信主办打卡、补卡、外出和加班;OA 展示月度摘要、异常解释、同步状态和审计记录。" />
|
||||
<WorkspaceNotice title="企业微信主办" tone="info">
|
||||
日常打卡仍在企业微信完成;OA 不写回企业微信,只展示汇总、修正审批和同步审计。
|
||||
日常打卡和考勤类申请仍在企业微信完成。OA 不写回企业微信;只有 HR 或管理员确认应急接管时,才在 OA 记录应急修正。
|
||||
</WorkspaceNotice>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
@@ -349,7 +358,8 @@ export function AttendancePage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="corrections" className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
||||
<SectionPanel title="提交修正" description="补卡、外出、加班都在 OA 发起审批;不会写回企业微信。">
|
||||
{canUseOaEmergencyCorrection ? (
|
||||
<SectionPanel title="OA 应急修正" description="仅在企业微信不可用且 HR/管理员确认应急接管时使用;不会写回企业微信。">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
@@ -448,12 +458,19 @@ export function AttendancePage() {
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={createMutation.isPending || !form.reason.trim()}
|
||||
>
|
||||
提交审批
|
||||
提交应急修正
|
||||
</Button>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : (
|
||||
<SectionPanel title="企微处理入口" description="普通员工不在 OA 里重复发起补卡、外出或加班。">
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm leading-6 text-emerald-800">
|
||||
请在企业微信提交补卡、外出和加班。OA 当前只展示已同步的解释、历史记录和异常摘要;需要应急接管时由 HR 或管理员处理。
|
||||
</div>
|
||||
</SectionPanel>
|
||||
)}
|
||||
|
||||
<SectionPanel title="修正记录" description="审批通过后进入月度考勤解释。">
|
||||
<SectionPanel title="修正记录" description="展示已留存的解释和应急修正记录,作为月度考勤摘要依据。">
|
||||
{correctionsQuery.isLoading ? (
|
||||
<TableSkeleton rows={5} />
|
||||
) : correctionsQuery.isError ? (
|
||||
@@ -567,7 +584,7 @@ export function AttendancePage() {
|
||||
{(syncRunsQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.startDate ?? "-"} ~ {item.endDate ?? "-"}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>{syncRunStatusLabel(item.status)}</TableCell>
|
||||
<TableCell>{item.employeeCount} / {item.recordCount}</TableCell>
|
||||
<TableCell>{item.errorCode ?? "-"}</TableCell>
|
||||
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
||||
|
||||
@@ -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<LeaveRequestStatus | "">("");
|
||||
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 = (
|
||||
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
|
||||
<SectionPanel title="请假记录" description="查看企业微信主办或历史 OA 留存的请假状态;日常提交仍从企业微信进入。">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-zinc-500">最近一次筛选会保留在当前页面状态中。</div>
|
||||
@@ -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 (
|
||||
<TableRow key={item.id}>
|
||||
@@ -596,39 +598,43 @@ export function LeaveRequestPage() {
|
||||
</TableCell>
|
||||
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canEdit || isSavingForm}
|
||||
onClick={() => startEditing(item)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!canSubmit || submitMutation.isPending}
|
||||
onClick={() => submitMutation.mutate(item.id)}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||
onClick={() =>
|
||||
setWithdrawTarget({
|
||||
id: item.id,
|
||||
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
{canUseOaEmergencyLeave ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canEdit || isSavingForm}
|
||||
onClick={() => startEditing(item)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!canSubmit || submitMutation.isPending}
|
||||
onClick={() => submitMutation.mutate(item.id)}
|
||||
>
|
||||
提交应急审批
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||
onClick={() =>
|
||||
setWithdrawTarget({
|
||||
id: item.id,
|
||||
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-zinc-400">企业微信处理</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -780,23 +786,31 @@ export function LeaveRequestPage() {
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
title="请假申请"
|
||||
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
|
||||
title="请假记录"
|
||||
subtitle="企业微信主办请假、补卡、外出和加班;OA 只做记录、额度摘要、团队排班参考和应急留痕。"
|
||||
/>
|
||||
<WorkspaceNotice title="企业微信主办" tone="info">
|
||||
日常请假请在企业微信完成。OA 不重复发起常规请假,只展示同步/历史状态;只有 HR 或管理员确认应急接管时才使用 OA 应急草稿。
|
||||
</WorkspaceNotice>
|
||||
|
||||
<StatStrip columns={4}>
|
||||
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
|
||||
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
|
||||
<MetricCard
|
||||
title="本次预估"
|
||||
value={effectiveRequestedDaysPreview ?? "-"}
|
||||
note="创建草稿前可先预览天数"
|
||||
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
|
||||
/>
|
||||
{canUseOaEmergencyLeave ? (
|
||||
<MetricCard
|
||||
title="应急预估"
|
||||
value={effectiveRequestedDaysPreview ?? "-"}
|
||||
note="仅 OA 应急草稿使用"
|
||||
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
|
||||
/>
|
||||
) : (
|
||||
<MetricCard title="主办系统" value="企微" note="OA 只读留存和异常说明" tone="neutral" />
|
||||
)}
|
||||
</StatStrip>
|
||||
|
||||
<div className="page-shell-grid">
|
||||
{canUseOaEmergencyLeave ? (
|
||||
<SectionPanel title={formTitle} description={formDescription}>
|
||||
<div className="space-y-4">
|
||||
{formError ? (
|
||||
@@ -813,7 +827,7 @@ export function LeaveRequestPage() {
|
||||
|
||||
{isNonWorkingPreview ? (
|
||||
<p className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-700">
|
||||
所选日期范围均为非工作日,无需提交请假
|
||||
所选日期范围均为非工作日,无需发起请假
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -957,14 +971,21 @@ export function LeaveRequestPage() {
|
||||
? updateMutation.isPending
|
||||
? "保存中..."
|
||||
: "保存修改"
|
||||
: createMutation.isPending
|
||||
? "创建中..."
|
||||
: "创建草稿"}
|
||||
: createMutation.isPending
|
||||
? "创建中..."
|
||||
: "创建应急草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : (
|
||||
<SectionPanel title="日常请假入口" description="普通员工不在 OA 里重复提交请假。">
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm leading-6 text-emerald-800">
|
||||
请在企业微信完成请假、补卡、外出和加班。OA 当前用于查看记录、额度摘要、团队排班和同步异常;如企业微信不可用,由 HR 或管理员开启应急接管后再使用 OA。
|
||||
</div>
|
||||
</SectionPanel>
|
||||
)}
|
||||
|
||||
<SectionPanel title="额度概览" description="假种可用额度、冻结与已使用情况按自然周期汇总。">
|
||||
<div className="space-y-4">
|
||||
@@ -1050,7 +1071,7 @@ export function LeaveRequestPage() {
|
||||
setWithdrawTarget(null);
|
||||
}
|
||||
}}
|
||||
title="确认撤回请假申请?"
|
||||
title="确认撤回 OA 应急请假单?"
|
||||
description={withdrawTarget ? `将撤回:${withdrawTarget.label}` : undefined}
|
||||
confirmLabel="确认撤回"
|
||||
onConfirm={async () => {
|
||||
|
||||
@@ -147,7 +147,7 @@ function LeaveRecordCard({
|
||||
) : null}
|
||||
{canSubmit ? (
|
||||
<Button size="sm" type="button" disabled={isSaving} onClick={() => onSubmit(item)}>
|
||||
提交
|
||||
提交应急审批
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -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<LeaveView>(() => resolveLeaveView(searchParams.get("view")));
|
||||
const [statusFilter, setStatusFilter] = useState<LeaveStatusFilter>(() => {
|
||||
const queryStatus = searchParams.get("status");
|
||||
@@ -177,13 +177,14 @@ export function MobileLeavePage() {
|
||||
const [reason, setReason] = useState("");
|
||||
const [attachmentFile, setAttachmentFile] = useState<File | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(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 (
|
||||
<PageScaffold className="space-y-3 pb-4">
|
||||
<PageHeader title="请假" />
|
||||
<PageHeader title="请假记录" />
|
||||
|
||||
<section className="mobile-card mobile-focus-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-zinc-900">企业微信主办</div>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-600">
|
||||
日常请假、补卡、外出和加班在企业微信完成;OA 这里只查看记录、额度和历史状态。
|
||||
</p>
|
||||
</div>
|
||||
<span className="badge-success shrink-0">只读</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card">
|
||||
<Tabs value={view} onValueChange={handleViewChange}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2">
|
||||
<Tabs value={effectiveView} onValueChange={handleViewChange}>
|
||||
<TabsList className={`grid h-auto w-full ${canUseOaEmergencyLeave ? "grid-cols-2" : "grid-cols-1"}`}>
|
||||
<TabsTrigger value="records" data-testid="mobile-leave-view-records">请假记录</TabsTrigger>
|
||||
<TabsTrigger value="form" data-testid="mobile-leave-view-form">新建请假</TabsTrigger>
|
||||
{canUseOaEmergencyLeave ? <TabsTrigger value="form" data-testid="mobile-leave-view-form">OA 应急</TabsTrigger> : null}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</section>
|
||||
|
||||
{view === "form" ? (
|
||||
{effectiveView === "form" ? (
|
||||
<>
|
||||
<section className="mobile-card" data-testid="mobile-leave-summary">
|
||||
<div className="mobile-metric-grid text-sm">
|
||||
@@ -414,7 +433,7 @@ export function MobileLeavePage() {
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-leave-form">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑请假" : "新建请假"}</h3>
|
||||
<h3 className="text-base font-semibold text-zinc-900">{editingItem ? "编辑 OA 应急请假" : "新建 OA 应急请假"}</h3>
|
||||
{editingItem ? (
|
||||
<Button variant="secondary" size="sm" type="button" onClick={resetForm}>
|
||||
取消
|
||||
@@ -499,7 +518,7 @@ export function MobileLeavePage() {
|
||||
<Button
|
||||
className="min-h-12 w-full"
|
||||
type="button"
|
||||
disabled={!canCreateLeave || isSaving || !leaveTypeId || !startDate || !endDate}
|
||||
disabled={!canUseOaEmergencyLeave || isSaving || !leaveTypeId || !startDate || !endDate}
|
||||
onClick={() => {
|
||||
if (editingItem) {
|
||||
updateMutation.mutate();
|
||||
@@ -509,7 +528,7 @@ export function MobileLeavePage() {
|
||||
}}
|
||||
data-testid="mobile-leave-save"
|
||||
>
|
||||
{editingItem ? "保存修改" : "创建草稿"}
|
||||
{editingItem ? "保存修改" : "创建应急草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -546,7 +565,7 @@ export function MobileLeavePage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{view === "records" ? (
|
||||
{effectiveView === "records" ? (
|
||||
<section className="mobile-card" data-testid="mobile-leave-records">
|
||||
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
|
||||
<Tabs value={statusFilter} onValueChange={handleStatusChange}>
|
||||
@@ -578,6 +597,7 @@ export function MobileLeavePage() {
|
||||
key={item.id}
|
||||
onEdit={startEditing}
|
||||
onSubmit={(nextItem) => submitMutation.mutate(nextItem)}
|
||||
showActions={canUseOaEmergencyLeave}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+305
-187
@@ -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<string, string> = {}) {
|
||||
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<ReturnType<typeof createAuthenticatedApiClient>>["api"], leaveRequestId: string) {
|
||||
return apiPostData<{ leaveRequestId: string; currentTaskId: string }>(api, `/api/leave-requests/${leaveRequestId}/submit`, {});
|
||||
}
|
||||
|
||||
async function withdrawLeave(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -127,6 +127,8 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
|
||||
- PC 侧边栏是小团队任务入口,不是全量模块目录;一级入口必须按角色、权限、频次和真实办事价值收敛。已启用且有权限的 OA 主办业务台账,例如 `合同`、`采购`、`资产`、`项目`,不默认藏到 `更多`,否则会给常用业务增加二次点击负担。
|
||||
- 审批人、负责人和财务看到 `待我处理`;普通员工默认不显示 `待办` 或 `审批待办` 入口;通知只保留顶部铃铛或通知中心,不进入侧边栏。
|
||||
- PC 请假入口默认表达为 `请假记录`;提交请假、补卡、外出、加班仍优先走企业微信或移动入口,只有 OA 应急接管或指定管理场景才突出提交入口。
|
||||
- 移动请假默认是记录查看和状态确认;普通员工不显示 OA 新建请假表单、编辑和提交动作。HR/管理员应急接管时才显示 `OA 应急` 表单和 `提交应急审批`。
|
||||
- 考勤页普通员工只看月度摘要、异常解释、同步/留痕状态和企微处理说明;`OA 应急修正` 只给 HR/管理员处理企微不可用时的应急接管。
|
||||
- 管理配置类能力在侧边栏合并为 `管理`,具体人员、组织、流程、规则、审计、功能开关和外部系统入口进入管理页后分组承接。
|
||||
- 财务入口应使用真实工作名,例如 `付款`、`应付账款`、`票据`;`AP 运营`、`ERP 集成`、`功能开关` 等内部平台词不作为默认一级菜单文案。
|
||||
- `更多` 只承载低频、冻结、阶段性或偶尔查看的入口,不使用说明型副标题、教学卡片或历史模块解释文案;不得把常用业务台账作为默认二级入口。
|
||||
|
||||
在新工单中引用
屏蔽一个用户