修正企微主办边界与工作台摘要(portal-task-first-redesign)
这个提交包含在:
@@ -278,8 +278,8 @@ and admin tools available without dominating the default path.
|
||||
### Convention: Approval Task List Defaults
|
||||
|
||||
**What**: PC approval task lists such as `/workflow/tasks` should make
|
||||
`查看处理` the default row action. List-level `通过` is only a secondary shortcut
|
||||
for low-risk items where the row itself carries enough decision context.
|
||||
`查看处理` the default row action. List-level `通过` is disabled by default unless
|
||||
a later Trellis task adds an explicit backend eligibility contract.
|
||||
|
||||
**Contracts**:
|
||||
|
||||
@@ -289,17 +289,23 @@ for low-risk items where the row itself carries enough decision context.
|
||||
`高级 / 历史` on the default task surface.
|
||||
- Time labels should prefer `已等待多久` and `接收于` over database-shaped labels
|
||||
such as `更新时间` when the source field is task creation/arrival time.
|
||||
- A quick `通过` button may appear only when the task is pending, the server
|
||||
allows `approve`, the item is a low-risk leave/attendance task, required row
|
||||
summary fields are present, and the task has not exceeded the pending window
|
||||
available to the frontend. Reimbursement, procurement, travel, contract and
|
||||
other amount/evidence-heavy tasks should route through `查看处理` first.
|
||||
- The confirm dialog for list quick approval must still explain action
|
||||
consequences and include the specific task summary.
|
||||
- HR/attendance items owned by Enterprise WeChat, including leave, missed
|
||||
punch, outing, overtime and check-in related approvals, must not show OA
|
||||
list-level quick approval. OA may show mirror/history/status/exception
|
||||
context and emergency-takeover records according to the business boundary,
|
||||
but normal approval remains in Enterprise WeChat.
|
||||
- Reimbursement, procurement, travel, contract and other amount/evidence-heavy
|
||||
OA-owned tasks should route through `查看处理` first.
|
||||
- Future list-level quick approval requires the backend to expose an explicit
|
||||
eligibility flag such as `quickApproveEligible=true`, plus task-domain and
|
||||
audit semantics in the current Trellis task. Frontend heuristics based only on
|
||||
entity type, duration or row text are not enough.
|
||||
- If list quick approval is reintroduced later, its confirm dialog must still
|
||||
explain action consequences and include the specific task summary.
|
||||
|
||||
**Why**: Small-team approvers need to clear work quickly without training
|
||||
themselves to approve financial or evidence-heavy items from an under-specified
|
||||
row.
|
||||
**Why**: Small-team approvers should build the habit of opening the task detail
|
||||
before approval, and the OA/Enterprise WeChat ownership boundary must stay clear
|
||||
for HR/attendance workflows.
|
||||
|
||||
## Convention: Workbench Onboarding And Profile Scope
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
- `/workflow/tasks` 默认标题改为 `待我处理`,历史入口文案改为 `已处理记录`。
|
||||
- PC 主体保留表格密度,但改为任务列表式表格,突出事项、申请人、金额/天数、找我原因、等待时间和业务编号。
|
||||
- 默认主按钮改为 `查看处理`;`通过` 仅作为低风险次级快捷动作。
|
||||
- 低风险快捷通过范围保守限定为请假/考勤:请假不超过 1 天、信息完整、任务未超过默认待办时限且服务端允许 approve。
|
||||
- 默认主按钮改为 `查看处理`;列表页不再提供 `通过` 快捷动作。
|
||||
- 请假、补卡、外出、加班和打卡属于企业微信主办,OA 待处理列表不按前端实体类型或天数自行开放快捷审批。
|
||||
- 时间表达从 `更新时间` 改为 `已等待多久` 和 `接收于`。
|
||||
- 未改后端接口、审批详情页、权限模型、状态机、企业微信、生产部署或真实数据写入。
|
||||
|
||||
@@ -140,13 +140,26 @@
|
||||
- `git diff --check`:通过。
|
||||
- 截图证据已保存到 `output/playwright/portal-task-first-redesign/portal-employee.png`、`portal-admin.png`、`portal-finance.png`;员工截图已复核并清除了欢迎弹窗遮罩污染。
|
||||
|
||||
2026-05-29 企微主办边界复核后完成补充验证:
|
||||
|
||||
- `cd client && npm run typecheck`:通过。
|
||||
- `cd client && npm run build`:通过。
|
||||
- `cd client && npx playwright test tests/e2e/workflow-action-confirm-ui.spec.ts tests/e2e/workflow-quick-approve.spec.ts --workers=1 --reporter=list`:3 passed。
|
||||
- `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts tests/e2e/visual-shell.spec.ts tests/e2e/r102-reimbursement-main-flow.spec.ts --workers=1 --reporter=list`:23 passed。
|
||||
- `cd client && npx playwright test tests/e2e/pc-core-workflow-visual-wave2.spec.ts --workers=1 --reporter=list`:1 passed。
|
||||
- 补充自审后重跑 `cd client && npx playwright test tests/e2e/login-portal-ux.spec.ts tests/e2e/visual-portal.spec.ts --workers=1 --reporter=list`:15 passed。
|
||||
- `python3 ./.trellis/scripts/task.py validate 05-29-portal-task-first-redesign`:通过。
|
||||
- `node scripts/qa/check-trellis-governance.mjs`:通过。
|
||||
- `git diff --check`:通过。
|
||||
|
||||
## Approved Addendum: 待处理页面任务优先整改
|
||||
|
||||
2026-05-29 用户确认对 `/workflow/tasks` 做小范围页面整改,目标是让审批人逐单看详情后谨慎处理,只有低风险事项保留列表页快捷通过。
|
||||
2026-05-29 用户确认对 `/workflow/tasks` 做小范围页面整改,目标是让审批人逐单看详情后谨慎处理。后续业务边界复核确认:请假、补卡、外出、加班和打卡属于企业微信主办,OA 不提供列表页快捷审批。
|
||||
|
||||
- 默认目标:逐单看详情后处理;低风险事项才允许快捷通过。
|
||||
- 按钮层级:`查看处理` 为主按钮;`通过` 作为次级快捷动作。
|
||||
- 快捷通过范围:仅 `请假 / 考勤` 小事项;请假不超过 1 天、信息完整、未超时且当前用户有 approve 权限。
|
||||
- 默认目标:逐单看详情后处理;列表页默认不提供 `通过` 快捷动作。
|
||||
- 按钮层级:`查看处理` 为主按钮;审批动作进入详情页后执行。
|
||||
- 企微主办边界:请假、补卡、外出、加班、打卡相关事项只做镜像、历史、审计、异常或应急接管展示,正常审批不在 OA 列表页处理。
|
||||
- 未来若恢复列表快捷通过,必须由后端显式提供 eligibility 字段和审计语义,不能由前端按实体类型、天数或行文案自行判断。
|
||||
- 主体形态:保留 PC 表格密度,但改为任务列表式表格,第一列集中展示什么事、为什么找我、等待多久和编号。
|
||||
- 历史入口:`高级 / 历史` 改为 `已处理记录`;点击后默认进入 `已处理`,历史筛选不混入 `待处理`。
|
||||
- 时间表达:首屏优先显示 `已等待多久`,绝对接收时间作为次级信息;不再把任务创建时间叫 `更新时间`。
|
||||
|
||||
@@ -28,6 +28,7 @@ type WorkItem = {
|
||||
severity: "normal" | "warning" | "high";
|
||||
icon: string;
|
||||
priority: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type EmptyAction = {
|
||||
@@ -107,6 +108,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "fact_check",
|
||||
priority: 100,
|
||||
count: payload.pendingTasks.count,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -125,6 +127,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: highPriorityCount > 0 ? "high" : "normal",
|
||||
icon: "check_circle",
|
||||
priority: 90,
|
||||
count: openTodoCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,6 +142,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: payload.weeklyReport.needsHelp ? "high" : "warning",
|
||||
icon: "article",
|
||||
priority: 72,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,6 +158,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "high",
|
||||
icon: "priority_high",
|
||||
priority: 96,
|
||||
count: payload.financeSummary.failedItemCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,6 +173,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "high",
|
||||
icon: "shield_person",
|
||||
priority: 94,
|
||||
count: payload.financeSummary.riskCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,6 +188,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "payments",
|
||||
priority: 86,
|
||||
count: payload.financeSummary.pendingBatchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -197,6 +204,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "workspaces",
|
||||
priority: 64,
|
||||
count: payload.projectSummary.highPriorityTaskCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,6 +219,7 @@ function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: Rol
|
||||
severity: "warning",
|
||||
icon: "event_upcoming",
|
||||
priority: 62,
|
||||
count: payload.projectSummary.followUpDueTodayCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,6 +240,7 @@ function buildProgressItems(payload: PortalHomePayload) {
|
||||
severity: "normal",
|
||||
icon: "done_all",
|
||||
priority: 30,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,6 +265,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "article",
|
||||
priority: 78,
|
||||
count: payload.weeklySummary?.missingCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,6 +280,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "high",
|
||||
icon: "support_agent",
|
||||
priority: 82,
|
||||
count: payload.weeklySummary?.needsHelpCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,6 +295,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "groups",
|
||||
priority: 58,
|
||||
count: payload.teamPresence?.unspecified ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -297,6 +310,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "flag",
|
||||
priority: 74,
|
||||
count: payload.projectSummary?.overdueMilestonesCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -311,6 +325,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "high",
|
||||
icon: "warning",
|
||||
priority: 76,
|
||||
count: payload.projectSummary?.openRiskCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,6 +340,7 @@ function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCap
|
||||
severity: "warning",
|
||||
icon: "inventory_2",
|
||||
priority: 60,
|
||||
count: (payload.peopleLifecycle?.offboardingCount ?? 0) + (payload.peopleLifecycle?.assetReturnPendingCount ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -353,6 +369,7 @@ function buildManagementReminders(
|
||||
severity: "warning",
|
||||
icon: "settings",
|
||||
priority: 88,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,6 +387,7 @@ function buildManagementReminders(
|
||||
severity: "warning",
|
||||
icon: "manage_accounts",
|
||||
priority: 52,
|
||||
count: inactiveLoginCount,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,6 +409,10 @@ function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabili
|
||||
return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2);
|
||||
}
|
||||
|
||||
function sumWorkItemCount(items: WorkItem[]) {
|
||||
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
|
||||
}
|
||||
|
||||
function SummaryStrip({
|
||||
userName,
|
||||
actionCount,
|
||||
@@ -561,6 +583,10 @@ export function PortalHomePage() {
|
||||
const managementReminders = buildManagementReminders(payload, roleCapabilities, canSetupWorkspace);
|
||||
const emptyActions = buildEmptyActions(permissions, roleCapabilities);
|
||||
const primaryItem = todayActionItems[0] ?? null;
|
||||
const actionCount = sumWorkItemCount(todayActionItems);
|
||||
const progressCount = sumWorkItemCount(progressItems);
|
||||
const blockerCount = sumWorkItemCount(teamBlockers);
|
||||
const reminderCount = sumWorkItemCount(managementReminders);
|
||||
|
||||
function handleCloseWelcome() {
|
||||
clearWelcomeDialogFlag();
|
||||
@@ -575,10 +601,10 @@ export function PortalHomePage() {
|
||||
|
||||
<SummaryStrip
|
||||
userName={payload.greeting.name}
|
||||
actionCount={todayActionItems.length}
|
||||
progressCount={progressItems.length}
|
||||
blockerCount={teamBlockers.length}
|
||||
reminderCount={managementReminders.length}
|
||||
actionCount={actionCount}
|
||||
progressCount={progressCount}
|
||||
blockerCount={blockerCount}
|
||||
reminderCount={reminderCount}
|
||||
primaryItem={primaryItem}
|
||||
emptyActions={emptyActions}
|
||||
/>
|
||||
|
||||
@@ -587,17 +587,6 @@ export function ReimbursementsPage() {
|
||||
</div>
|
||||
|
||||
<div className="ops-command-bar__actions">
|
||||
{canApproveExpense ? (
|
||||
<Button
|
||||
data-testid="reimbursements-actionable"
|
||||
size="sm"
|
||||
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
待我处理
|
||||
</Button>
|
||||
) : null}
|
||||
{canShowLedgerEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -698,6 +687,15 @@ export function ReimbursementsPage() {
|
||||
|
||||
{canViewAll ? (
|
||||
<>
|
||||
<Button
|
||||
data-testid="reimbursements-reviewing-ledger"
|
||||
size="sm"
|
||||
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
审批中台账
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
size="sm"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
approveWorkflowTask,
|
||||
fetchWorkflowTasks,
|
||||
fetchWorkflowTaskSummary,
|
||||
type WorkflowTaskSummary,
|
||||
} from "../api/workflow";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import {
|
||||
@@ -28,7 +25,6 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { getWorkflowConfirmCopy } from "../lib/workflowActionConfirm";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const WORKFLOW_DEFAULT_DUE_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -191,18 +187,6 @@ function isTaskDefaultOverdue(task: WorkflowTaskSummary) {
|
||||
return task.status === "pending" && !isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function parseDurationDays(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = /([0-9]+(?:\.[0-9]+)?)/.exec(value);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const days = Number(match[1]);
|
||||
return Number.isFinite(days) ? days : null;
|
||||
}
|
||||
|
||||
function workflowTaskStatusLabel(status: string) {
|
||||
const match = WORKFLOW_STATUS_OPTIONS.find((item) => item.value === status);
|
||||
return match?.label ?? status;
|
||||
@@ -221,37 +205,12 @@ function taskSecondaryMeta(task: WorkflowTaskSummary) {
|
||||
return [formatReceivedTime(task.createdAt), displayReference(task)].join(" · ");
|
||||
}
|
||||
|
||||
function isLowRiskQuickApproveTask(task: WorkflowTaskSummary) {
|
||||
if (task.status !== "pending" || !(task.allowedActions ?? []).includes("approve")) {
|
||||
return false;
|
||||
}
|
||||
if (!displayTitle(task) || displayApplicant(task) === "-") {
|
||||
return false;
|
||||
}
|
||||
const entityType = task.workflowInstance?.entityType;
|
||||
if (entityType === "leave") {
|
||||
const days = parseDurationDays(task.businessSummary?.duration);
|
||||
if (days === null || days > 1) {
|
||||
return false;
|
||||
}
|
||||
} else if (entityType !== "attendance_correction") {
|
||||
return false;
|
||||
}
|
||||
return isWithinDefaultDueWindow(task.createdAt);
|
||||
}
|
||||
|
||||
function isAlreadyProcessedTaskError(error: unknown) {
|
||||
return isApiError(error) && error.details?.task === "already_processed";
|
||||
}
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
|
||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||
const [confirmApproveTaskId, setConfirmApproveTaskId] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => status !== "pending" || entityType !== "all");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function updateSearchParams(next: {
|
||||
status?: WorkflowStatusFilter;
|
||||
@@ -286,35 +245,8 @@ export function WorkflowTasksPage() {
|
||||
queryFn: () => fetchWorkflowTaskSummary(),
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
]);
|
||||
toast.success("审批已通过");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isAlreadyProcessedTaskError(error)) {
|
||||
void queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] });
|
||||
}
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "审批失败,请稍后重试";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
const taskItems = query.data?.items ?? [];
|
||||
const confirmCopy = confirmApproveTaskId ? getWorkflowConfirmCopy("approve") : null;
|
||||
const confirmApproveTask = confirmApproveTaskId
|
||||
? taskItems.find((task) => task.id === confirmApproveTaskId) ?? null
|
||||
: null;
|
||||
const isDefaultTaskView = status === "pending" && entityType === "all";
|
||||
const advancedVisible = showAdvanced || !isDefaultTaskView;
|
||||
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
||||
@@ -339,7 +271,6 @@ export function WorkflowTasksPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
@@ -485,7 +416,7 @@ export function WorkflowTasksPage() {
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
asChild
|
||||
className={`min-h-11 ${isLowRiskQuickApproveTask(task) ? "" : "col-span-2"}`}
|
||||
className="col-span-2 min-h-11"
|
||||
size="sm"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
@@ -493,19 +424,6 @@ export function WorkflowTasksPage() {
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
className="min-h-11"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
@@ -567,18 +485,6 @@ export function WorkflowTasksPage() {
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
{isLowRiskQuickApproveTask(task) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => setConfirmApproveTaskId(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -603,36 +509,5 @@ export function WorkflowTasksPage() {
|
||||
testIdPrefix="workflow-tasks"
|
||||
/>
|
||||
</ListWorkspace>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setConfirmApproveTaskId(null);
|
||||
}
|
||||
}}
|
||||
title={confirmCopy?.title ?? ""}
|
||||
description={confirmCopy ? (
|
||||
<>
|
||||
<span>{confirmCopy.description}</span>
|
||||
{confirmApproveTask ? (
|
||||
<span className="mt-2 block rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-slate-600">
|
||||
当前事项:{displayTaskTitle(confirmApproveTask)} · {taskLeadMeta(confirmApproveTask)}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined}
|
||||
confirmLabel={confirmCopy?.confirmLabel}
|
||||
variant={confirmCopy?.variant}
|
||||
testId="workflow-action-confirm-dialog"
|
||||
confirmTestId="workflow-action-confirm-submit"
|
||||
cancelTestId="workflow-action-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
if (!confirmApproveTaskId) {
|
||||
return;
|
||||
}
|
||||
await approveMutation.mutateAsync(confirmApproveTaskId);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => {
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
|
||||
await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 4 件待处理");
|
||||
await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 7 件待处理");
|
||||
await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible();
|
||||
|
||||
@@ -273,7 +273,7 @@ async function mockWorkflowConfirmApis(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("R7E 审批动作二次确认", () => {
|
||||
test("PC 列表快捷通过必须二次确认后才提交", async ({ page }) => {
|
||||
test("PC 列表页不提供企微主办请假快捷通过", async ({ page }) => {
|
||||
const actionCalls = await mockWorkflowConfirmApis(page);
|
||||
|
||||
await login(page, MANAGER);
|
||||
@@ -282,17 +282,9 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
|
||||
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: "QJ-CONFIRM-001" });
|
||||
await expect(taskRow).toHaveCount(1);
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toHaveCount(0);
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect.poll(() => actionCalls.approve).toBe(1);
|
||||
});
|
||||
|
||||
test("PC 审批详情通过、驳回、退回都先解释动作后果", async ({ page }) => {
|
||||
|
||||
@@ -1,110 +1,232 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
||||
import { openNavLink } from "./helpers/navigation";
|
||||
import { MANAGER, login } from "./helpers/auth";
|
||||
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);
|
||||
test.use({ serviceWorkers: "block" });
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return { requestId: "mock-workflow-boundary", data };
|
||||
}
|
||||
|
||||
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 fulfillJson<T>(route: Route, data: T) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
const manager = {
|
||||
id: "manager-wecom-boundary",
|
||||
employeeNo: "M-WECOM",
|
||||
name: "Manager",
|
||||
email: "[email protected]",
|
||||
phone: null,
|
||||
roleCodes: ["manager"],
|
||||
profileCompleteness: {
|
||||
hasEmergencyContact: true,
|
||||
hasBankAccount: true,
|
||||
hasAvatar: false,
|
||||
hasIdNumber: false,
|
||||
score: 70,
|
||||
},
|
||||
};
|
||||
|
||||
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 pendingLeaveTask = {
|
||||
id: "task-wecom-leave-1",
|
||||
instanceId: "instance-wecom-leave-1",
|
||||
stepId: "manager-review",
|
||||
stepName: "主管审批",
|
||||
assigneeId: manager.id,
|
||||
assigneeRole: "manager",
|
||||
status: "pending",
|
||||
outcome: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
completedAt: null,
|
||||
workflowInstance: {
|
||||
entityType: "leave",
|
||||
entityId: "leave-wecom-1",
|
||||
currentStatus: "reviewing",
|
||||
},
|
||||
businessSummary: {
|
||||
reference: "QJ-WECOM-001",
|
||||
title: "企微主办请假镜像",
|
||||
applicantName: "Employee",
|
||||
amount: null,
|
||||
duration: "1 天",
|
||||
businessStatus: "reviewing",
|
||||
},
|
||||
allowedActions: ["approve", "reject", "return"],
|
||||
};
|
||||
|
||||
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;
|
||||
function navigationItem(key: string, href: string, label: string, icon: string, placement = "main") {
|
||||
return { key, href, label, icon, placement, permissionCodes: [], featureFlagKey: null };
|
||||
}
|
||||
|
||||
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 mockWorkflowBoundaryApis(page: Page) {
|
||||
const actionCalls = { approve: 0 };
|
||||
|
||||
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/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, {
|
||||
enabled: true,
|
||||
entryEnabled: true,
|
||||
oauthEnabled: true,
|
||||
corpId: "wx-test-corp",
|
||||
agentId: "1000002",
|
||||
loginUrl: "/api/auth/wecom/login?returnTo=%2Fportal",
|
||||
bindEnabled: true,
|
||||
defaultReturnTo: "/portal",
|
||||
status: "ready",
|
||||
statusReasons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login" || path === "/api/auth/refresh") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({
|
||||
accessToken: "mock-workflow-boundary-token",
|
||||
expiresIn: 900,
|
||||
mustChangePassword: false,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/logout") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=; Path=/; Max-Age=0" },
|
||||
body: JSON.stringify(envelope({})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/me") {
|
||||
await fulfillJson(route, manager);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-wecom-boundary",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [
|
||||
navigationItem("portal", "/portal", "工作台", "home_app_logo"),
|
||||
navigationItem("workflow", "/workflow/tasks", "待我处理", "fact_check"),
|
||||
navigationItem("leave", "/leave-requests", "请假记录", "event_available"),
|
||||
],
|
||||
admin: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/notifications") {
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 5),
|
||||
total: 0,
|
||||
unreadOnly: url.searchParams.get("unreadOnly") === "1",
|
||||
unreadCount: 0,
|
||||
items: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
hero: { title: "工作台", subtitle: "企微主办边界测试" },
|
||||
metrics: [],
|
||||
todos: [],
|
||||
notifications: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/tasks/summary") {
|
||||
await fulfillJson(route, {
|
||||
scope: "mine",
|
||||
pendingTotal: 1,
|
||||
overdueTotal: 0,
|
||||
earliestPendingAt: pendingLeaveTask.createdAt,
|
||||
averagePendingAgeHours: 1,
|
||||
byEntityType: [{ key: "leave", pendingTotal: 1 }],
|
||||
byStep: [],
|
||||
byAssigneeRole: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/workflow/tasks") {
|
||||
const status = url.searchParams.get("status") ?? "pending";
|
||||
const items = status === "pending" ? [pendingLeaveTask] : [];
|
||||
await fulfillJson(route, {
|
||||
page: 1,
|
||||
pageSize: Number(url.searchParams.get("pageSize") ?? 20),
|
||||
total: items.length,
|
||||
items,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^\/api\/workflow\/tasks\/[^/]+\/approve$/.test(path)) {
|
||||
actionCalls.approve += 1;
|
||||
await fulfillJson(route, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
return actionCalls;
|
||||
}
|
||||
|
||||
test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过请假申请", async ({ page }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const startDate = formatDate(400 + (Date.now() % 3000));
|
||||
const reason = `Playwright 快捷通过 ${Date.now()}`;
|
||||
test("审批列表边界:企微主办请假只进入查看处理", async ({ page }) => {
|
||||
const actionCalls = await mockWorkflowBoundaryApis(page);
|
||||
|
||||
try {
|
||||
await login(page, EMPLOYEE);
|
||||
await (await openNavLink(page, "请假记录")).click();
|
||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||
await login(page, MANAGER);
|
||||
await page.goto("/workflow/tasks");
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
|
||||
const leaveRequestId = await createLeaveDraft(page, startDate, startDate, reason);
|
||||
const submitResult = await submitLeave(employeeClient.api, leaveRequestId);
|
||||
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: "QJ-WECOM-001" });
|
||||
await expect(taskRow).toHaveCount(1);
|
||||
await expect(taskRow).toContainText("请假");
|
||||
await expect(taskRow).toContainText("企微主办请假镜像");
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toHaveCount(0);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, MANAGER);
|
||||
await (await openNavLink(page, "待我处理")).click();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
|
||||
const taskRows = page.locator(
|
||||
`[data-testid="${TEST_IDS.workflow.taskRow}"]:has(a[href="/workflow/tasks/${submitResult.currentTaskId}"])`,
|
||||
);
|
||||
await expect(taskRows).toHaveCount(1, { timeout: 30_000 });
|
||||
const taskRow = taskRows.first();
|
||||
await expect(taskRow).not.toContainText(submitResult.currentTaskId);
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.quickApprove)).toBeVisible();
|
||||
await expect(taskRow.getByTestId(TEST_IDS.workflow.detailLink)).toBeVisible();
|
||||
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.quickApprove).click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("确认后将进入下一审批节点");
|
||||
await page.getByTestId("workflow-action-confirm-submit").click();
|
||||
await expect(taskRows).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/leave-requests");
|
||||
await expect(page.getByRole("row").filter({ hasText: startDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
||||
} finally {
|
||||
await employeeClient.dispose();
|
||||
}
|
||||
const detailLink = taskRow.getByTestId(TEST_IDS.workflow.detailLink);
|
||||
await expect(detailLink).toBeVisible();
|
||||
await expect(detailLink).toHaveAttribute("href", "/workflow/tasks/task-wecom-leave-1");
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户