统一首批页面成熟设计基线(05-30-unified-page-design-review)
这个提交包含在:
@@ -26,7 +26,7 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [
|
||||
{
|
||||
icon: "request_quote",
|
||||
label: "提交报销",
|
||||
description: "录入票据和报销说明,查看处理进度",
|
||||
description: "录入票据和报销说明,查看进度",
|
||||
to: "/reimbursements",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,8 +25,10 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/assets", title: "资产", group: "业务" },
|
||||
{ path: "/workflow/tasks/", title: "审批详情", group: "工作" },
|
||||
{ path: "/workflow/tasks", title: "待我处理", group: "工作" },
|
||||
{ path: "/notifications", title: "通知", group: "工作" },
|
||||
{ path: "/mobile/assets/scan", title: "移动资产扫描", group: "移动工作台" },
|
||||
{ path: "/mobile/approvals", title: "移动审批", group: "移动工作台" },
|
||||
{ path: "/mobile/notifications", title: "移动通知", group: "移动工作台" },
|
||||
{ path: "/mobile/wecom-approval-archive", title: "移动企微审批历史", group: "移动工作台" },
|
||||
{ path: "/mobile/capture", title: "移动 PDF 发票上传", group: "移动工作台" },
|
||||
{ path: "/mobile/leave", title: "移动请假", group: "移动工作台" },
|
||||
|
||||
@@ -660,6 +660,43 @@
|
||||
@apply border p-3.5;
|
||||
}
|
||||
|
||||
.workflow-action-panel {
|
||||
@apply xl:sticky xl:top-20;
|
||||
}
|
||||
|
||||
.workflow-action-bar {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.workflow-action-more {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.workflow-action-more > summary {
|
||||
border: 1px solid rgba(203, 213, 225, 0.92);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
color: var(--color-text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
@apply flex h-9 cursor-pointer list-none items-center px-3 text-sm font-medium transition-colors hover:bg-slate-50;
|
||||
}
|
||||
|
||||
.workflow-action-more > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-action-more[open] > summary {
|
||||
@apply bg-slate-50;
|
||||
}
|
||||
|
||||
.workflow-action-more > div,
|
||||
.workflow-action-more > button {
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
background: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow-md);
|
||||
@apply absolute right-0 top-10 z-20 min-w-28 px-3 py-2 text-left text-sm font-medium text-zinc-800 hover:bg-slate-50;
|
||||
}
|
||||
|
||||
.notification-inbox {
|
||||
@apply space-y-3;
|
||||
}
|
||||
@@ -822,6 +859,33 @@
|
||||
@apply overflow-hidden rounded-lg divide-y divide-slate-200/80;
|
||||
}
|
||||
|
||||
.portal-quick-entry-grid {
|
||||
@apply grid gap-2 sm:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
.portal-quick-entry {
|
||||
border: 1px solid rgba(226, 232, 240, 0.94);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
@apply flex min-h-[64px] items-center gap-3 px-3.5 py-3 transition-colors hover:border-slate-300 hover:bg-white;
|
||||
}
|
||||
|
||||
.portal-quick-entry__icon {
|
||||
background: rgba(0, 57, 138, 0.08);
|
||||
color: var(--color-accent-primary);
|
||||
@apply flex size-9 shrink-0 items-center justify-center rounded-lg;
|
||||
}
|
||||
|
||||
.portal-quick-entry__title {
|
||||
@apply block truncate text-sm font-semibold;
|
||||
}
|
||||
|
||||
.portal-quick-entry__action {
|
||||
color: var(--color-text-tertiary);
|
||||
@apply mt-0.5 block text-xs font-medium;
|
||||
}
|
||||
|
||||
.portal-task-row {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
@@ -164,7 +164,7 @@ export function MobileProfilePage() {
|
||||
</dl>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="密码与安全">
|
||||
<SectionPanel title="账号安全">
|
||||
<div className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-zinc-900">账号密码</div>
|
||||
@@ -176,7 +176,7 @@ export function MobileProfilePage() {
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="企业微信消息提醒">
|
||||
<SectionPanel title="通知偏好">
|
||||
<label className="mobile-list-card flex min-h-12 items-center justify-between gap-3 text-sm text-zinc-800">
|
||||
<span>接收企业微信消息提醒</span>
|
||||
<input
|
||||
|
||||
@@ -16,7 +16,7 @@ const PAGE_SIZE = 20;
|
||||
|
||||
const NOTIFICATION_TYPE_OPTIONS = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "待我处理", value: "workflow.task_assigned" },
|
||||
{ label: "审批提醒", value: "workflow.task_assigned" },
|
||||
{ label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" },
|
||||
{ label: "请假", value: "leave.approved,leave.rejected" },
|
||||
{ label: "采购", value: "purchase_request.approved,purchase_request.rejected" },
|
||||
@@ -80,24 +80,17 @@ export function NotificationsPage() {
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="通知"
|
||||
title="通知中心"
|
||||
subtitle="这里是事件收件箱;需要处理的事项会直达业务详情或审批任务。"
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="notifications-mark-all-read"
|
||||
disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending}
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
>
|
||||
全部标为已读
|
||||
</Button>
|
||||
<Button asChild variant="secondary" data-testid="notifications-back">
|
||||
<Link to="/portal">返回工作台</Link>
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="notifications-mark-all-read"
|
||||
disabled={(listQuery.data?.unreadCount ?? 0) === 0 || markAllReadMutation.isPending}
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
>
|
||||
全部标为已读
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -188,7 +181,7 @@ export function NotificationsPage() {
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{targetLink ? (
|
||||
<Button asChild size="sm" variant="secondary" data-testid="notification-mobile-target-link">
|
||||
<Link to={targetLink}>查看</Link>
|
||||
<Link to={targetLink}>查看详情</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -238,7 +231,7 @@ export function NotificationsPage() {
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{targetLink ? (
|
||||
<Button asChild size="sm" variant="secondary" data-testid="notification-target-link">
|
||||
<Link to={targetLink}>查看</Link>
|
||||
<Link to={targetLink}>查看详情</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
|
||||
+117
-23
@@ -38,6 +38,14 @@ type EmptyAction = {
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
};
|
||||
|
||||
type QuickEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
href: string;
|
||||
actionLabel: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
|
||||
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
|
||||
const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
|
||||
@@ -45,6 +53,10 @@ const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel
|
||||
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
|
||||
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
|
||||
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
|
||||
const CONTRACT_PORTAL_PERMISSIONS = ["project.view_all", "project.manage", "system.settings"];
|
||||
const PROJECT_PORTAL_PERMISSIONS = ["project.view_own", "project.view_all", "project.manage"];
|
||||
const ASSET_PORTAL_PERMISSIONS = ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"];
|
||||
const INVOICE_PORTAL_PERMISSIONS = ["invoice.manage"];
|
||||
|
||||
const SEVERITY_RANK = {
|
||||
high: 3,
|
||||
@@ -398,8 +410,8 @@ function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabili
|
||||
const actions: EmptyAction[] = [];
|
||||
|
||||
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
||||
actions.push({ key: "new-expense", label: "发起报销", href: "/reimbursements", variant: "primary" });
|
||||
actions.push({ key: "my-progress", label: "查看我的进度", href: "/reimbursements", variant: "secondary" });
|
||||
actions.push({ key: "new-expense", label: "新建报销", href: "/reimbursements", variant: "primary" });
|
||||
actions.push({ key: "my-progress", label: "查看进度", href: "/reimbursements", variant: "secondary" });
|
||||
}
|
||||
|
||||
if (roleCapabilities.canManageSystem) {
|
||||
@@ -409,6 +421,82 @@ function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabili
|
||||
return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2);
|
||||
}
|
||||
|
||||
function buildQuickEntries(permissions: string[], roleCapabilities: RoleCapabilities) {
|
||||
const entries: QuickEntry[] = [];
|
||||
|
||||
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
||||
entries.push({
|
||||
key: "reimbursements",
|
||||
label: "报销",
|
||||
href: "/reimbursements",
|
||||
actionLabel: hasAnyPermission(permissions, ["expense.create", "expense.proxy_manage"]) ? "新建报销" : "查看报销",
|
||||
icon: "request_quote",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAnyPermission(permissions, PROCUREMENT_PORTAL_PERMISSIONS)) {
|
||||
entries.push({
|
||||
key: "purchase",
|
||||
label: "采购",
|
||||
href: "/purchase-requests",
|
||||
actionLabel: hasAnyPermission(permissions, ["procurement.create", "procurement.submit"]) ? "新建采购" : "查看采购",
|
||||
icon: "shopping_cart",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAnyPermission(permissions, CONTRACT_PORTAL_PERMISSIONS)) {
|
||||
entries.push({
|
||||
key: "contracts",
|
||||
label: "合同",
|
||||
href: "/contracts-registry",
|
||||
actionLabel: "查看合同",
|
||||
icon: "description",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAnyPermission(permissions, PROJECT_PORTAL_PERMISSIONS)) {
|
||||
entries.push({
|
||||
key: "projects",
|
||||
label: "项目",
|
||||
href: "/projects",
|
||||
actionLabel: "查看项目",
|
||||
icon: "workspaces",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAnyPermission(permissions, ASSET_PORTAL_PERMISSIONS)) {
|
||||
entries.push({
|
||||
key: "assets",
|
||||
label: "资产",
|
||||
href: "/assets",
|
||||
actionLabel: "查看资产",
|
||||
icon: "inventory_2",
|
||||
});
|
||||
}
|
||||
|
||||
if (roleCapabilities.canUseFinance) {
|
||||
entries.push({
|
||||
key: "payments",
|
||||
label: "付款",
|
||||
href: "/payment-batches",
|
||||
actionLabel: "处理付款",
|
||||
icon: "payments",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasAnyPermission(permissions, INVOICE_PORTAL_PERMISSIONS)) {
|
||||
entries.push({
|
||||
key: "invoices",
|
||||
label: "票据",
|
||||
href: "/invoice-pool",
|
||||
actionLabel: "查看票据",
|
||||
icon: "receipt_long",
|
||||
});
|
||||
}
|
||||
|
||||
return entries.slice(0, 6);
|
||||
}
|
||||
|
||||
function sumWorkItemCount(items: WorkItem[]) {
|
||||
return items.reduce((total, item) => total + Math.max(1, item.count), 0);
|
||||
}
|
||||
@@ -419,16 +507,12 @@ function SummaryStrip({
|
||||
progressCount,
|
||||
blockerCount,
|
||||
reminderCount,
|
||||
primaryItem,
|
||||
emptyActions,
|
||||
}: {
|
||||
userName: string;
|
||||
actionCount: number;
|
||||
progressCount: number;
|
||||
blockerCount: number;
|
||||
reminderCount: number;
|
||||
primaryItem: WorkItem | null;
|
||||
emptyActions: EmptyAction[];
|
||||
}) {
|
||||
const attentionCount = blockerCount + reminderCount;
|
||||
const summaryText = actionCount > 0
|
||||
@@ -440,23 +524,9 @@ function SummaryStrip({
|
||||
return (
|
||||
<section className="portal-work-summary" data-testid="portal-work-summary">
|
||||
<div className="min-w-0">
|
||||
<div className="portal-work-summary__eyebrow">工作台</div>
|
||||
<div className="portal-work-summary__title">{summaryText}</div>
|
||||
<div className="portal-work-summary__meta">{userName} · 当前工作区</div>
|
||||
</div>
|
||||
{primaryItem ? (
|
||||
<Button asChild type="button">
|
||||
<Link to={primaryItem.href}>处理最急的一件</Link>
|
||||
</Button>
|
||||
) : emptyActions[0] ? (
|
||||
<Button
|
||||
asChild
|
||||
variant={emptyActions[0].variant === "secondary" || emptyActions[0].variant === "ghost" ? emptyActions[0].variant : "default"}
|
||||
type="button"
|
||||
>
|
||||
<Link to={emptyActions[0].href}>{emptyActions[0].label}</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -493,6 +563,30 @@ function WorkItemList({ items }: { items: WorkItem[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function QuickEntryPanel({ entries }: { entries: QuickEntry[] }) {
|
||||
if (!entries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPanel title="常用入口">
|
||||
<div className="portal-quick-entry-grid" data-testid="portal-quick-entries">
|
||||
{entries.map((entry) => (
|
||||
<Link className="portal-quick-entry" key={entry.key} to={entry.href}>
|
||||
<span className="portal-quick-entry__icon" aria-hidden="true">
|
||||
<span className="material-symbols-outlined text-[18px]">{entry.icon}</span>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="portal-quick-entry__title">{entry.label}</span>
|
||||
<span className="portal-quick-entry__action">{entry.actionLabel}</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function TodayActionPanel({
|
||||
items,
|
||||
emptyActions,
|
||||
@@ -578,11 +672,11 @@ export function PortalHomePage() {
|
||||
roleCapabilities.canManageSystem ||
|
||||
isManagerRole(permissions);
|
||||
const todayActionItems = buildTodayActionItems(payload, roleCapabilities);
|
||||
const quickEntries = buildQuickEntries(permissions, roleCapabilities);
|
||||
const progressItems = buildProgressItems(payload);
|
||||
const teamBlockers = isManagerView ? buildTeamBlockers(payload, roleCapabilities) : [];
|
||||
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);
|
||||
@@ -605,10 +699,10 @@ export function PortalHomePage() {
|
||||
progressCount={progressCount}
|
||||
blockerCount={blockerCount}
|
||||
reminderCount={reminderCount}
|
||||
primaryItem={primaryItem}
|
||||
emptyActions={emptyActions}
|
||||
/>
|
||||
|
||||
<QuickEntryPanel entries={quickEntries} />
|
||||
|
||||
<TodayActionPanel items={todayActionItems} emptyActions={emptyActions} />
|
||||
|
||||
<div className="portal-task-secondary-grid">
|
||||
|
||||
@@ -92,7 +92,7 @@ export function ProfilePage() {
|
||||
mutationFn: (notificationPushEnabled: boolean) => updateNotificationPreferences({ notificationPushEnabled }),
|
||||
onSuccess: async () => {
|
||||
await refreshMe();
|
||||
toast.success("通知设置已更新");
|
||||
toast.success("通知偏好已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveProfileError(error));
|
||||
@@ -108,16 +108,11 @@ export function ProfilePage() {
|
||||
<PageScaffold>
|
||||
<PageHeader
|
||||
title="个人设置"
|
||||
eyebrow="账号与通知"
|
||||
subtitle="只处理个人资料、安全、通知偏好和企业微信绑定。组织和权限维护统一在管理入口处理。"
|
||||
meta={<span className={accountStatusClassName(me?.status)}>{accountStatusLabel(me?.status)}</span>}
|
||||
/>
|
||||
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<SectionPanel
|
||||
title="个人资料"
|
||||
description="只展示本人基础信息。如需调整组织、岗位或角色,由管理员在管理入口维护。"
|
||||
>
|
||||
<SectionPanel title="个人资料">
|
||||
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-zinc-500">姓名</dt>
|
||||
@@ -138,11 +133,10 @@ export function ProfilePage() {
|
||||
</dl>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="密码与安全" description="修改登录密码,保持账号入口可控。">
|
||||
<SectionPanel title="账号安全">
|
||||
<div className="flex flex-col gap-3 text-sm text-zinc-600 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">账号密码</div>
|
||||
<p className="mt-1">修改密码需要输入当前密码;忘记密码时请从登录页找回。</p>
|
||||
</div>
|
||||
<Button asChild type="button" variant="secondary">
|
||||
<Link to="/change-password">修改密码</Link>
|
||||
@@ -150,7 +144,7 @@ export function ProfilePage() {
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="通知设置" description="选择是否接收企业微信个人应用提醒。">
|
||||
<SectionPanel title="通知偏好">
|
||||
<div className="space-y-3 text-sm text-zinc-600">
|
||||
<label className="flex items-center gap-3 text-zinc-800">
|
||||
<input
|
||||
@@ -166,7 +160,7 @@ export function ProfilePage() {
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="企业微信绑定" description="绑定后可从企业微信工作台直接进入 OA;企业微信只负责入口和提醒。">
|
||||
<SectionPanel title="企业微信绑定">
|
||||
<div className="space-y-4" data-testid="profile-wecom-card">
|
||||
{bindNotice ? (
|
||||
<div className={`rounded-lg border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
|
||||
@@ -221,7 +215,6 @@ export function ProfilePage() {
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-5 py-4">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-zinc-900">账号详情</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">组织归属和角色摘要,通常不需要修改。</div>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-500">展开 / 收起</span>
|
||||
</summary>
|
||||
|
||||
@@ -157,8 +157,8 @@ function resolveListAction(item: ReimbursementListItem) {
|
||||
const taskId = item.pendingWorkflow?.task?.id;
|
||||
if (taskId && item.pendingWorkflow?.allowedActions.length) {
|
||||
return {
|
||||
label: "查看处理",
|
||||
mobileLabel: "查看处理",
|
||||
label: "处理",
|
||||
mobileLabel: "处理",
|
||||
to: `/workflow/tasks/${taskId}`,
|
||||
primary: false,
|
||||
};
|
||||
|
||||
@@ -115,31 +115,7 @@ function isAlreadyProcessedTaskError(error: unknown) {
|
||||
return isApiError(error) && error.details?.task === "already_processed";
|
||||
}
|
||||
|
||||
function resolveWorkflowNextActionText(input: {
|
||||
taskStatus: string;
|
||||
entityType?: string | null;
|
||||
allowedActions: string[];
|
||||
}) {
|
||||
if (input.taskStatus !== "pending") {
|
||||
return "当前审批任务已结束,本页用于查看摘要、依据和处理历史。";
|
||||
}
|
||||
|
||||
const entityName = entityTitle(input.entityType ?? undefined);
|
||||
if (input.allowedActions.includes("approve")) {
|
||||
return `先核对${entityName}摘要、金额/时间和附件依据;确认无误后通过,存在缺口时退回补充。`;
|
||||
}
|
||||
if (input.allowedActions.includes("return")) {
|
||||
return `当前节点可退回,请说明需要补充的${entityName}材料或字段。`;
|
||||
}
|
||||
if (input.allowedActions.includes("reject")) {
|
||||
return `当前节点可驳回,请写清业务原因,便于申请人和后续审计理解。`;
|
||||
}
|
||||
if (input.allowedActions.includes("transfer")) {
|
||||
return "当前节点可转审,请选择更合适的审批人并说明原因。";
|
||||
}
|
||||
|
||||
return "当前账号没有可执行动作,请返回待办列表或联系管理员核对权限。";
|
||||
}
|
||||
type SecondaryWorkflowAction = "reject" | "return" | "transfer";
|
||||
|
||||
export function WorkflowTaskDetailPage() {
|
||||
const { taskId = "" } = useParams();
|
||||
@@ -152,7 +128,9 @@ export function WorkflowTaskDetailPage() {
|
||||
const [transferReason, setTransferReason] = useState("当前事项需转交对应审批人处理,请尽快处理。");
|
||||
const [selectedApprover, setSelectedApprover] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [activeSecondaryAction, setActiveSecondaryAction] = useState<SecondaryWorkflowAction | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<WorkflowConfirmAction | null>(null);
|
||||
const [transferConfirmOpen, setTransferConfirmOpen] = useState(false);
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(taskId),
|
||||
@@ -200,6 +178,8 @@ export function WorkflowTaskDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["workflow", "task", taskId] }),
|
||||
]);
|
||||
setActionError("");
|
||||
setActiveSecondaryAction(null);
|
||||
setTransferConfirmOpen(false);
|
||||
const messages: Record<string, string> = { approve: "已通过", reject: "已驳回", return: "已退回", transfer: "已转审" };
|
||||
toast.success(messages[action] ?? "操作成功");
|
||||
},
|
||||
@@ -240,6 +220,20 @@ export function WorkflowTaskDetailPage() {
|
||||
setConfirmAction(action);
|
||||
}
|
||||
|
||||
function requestWorkflowTransfer() {
|
||||
if (!selectedApprover) {
|
||||
setActionError("请选择转审目标");
|
||||
return;
|
||||
}
|
||||
if (transferReason.trim().length < 10) {
|
||||
setActionError("转审理由至少 10 个字");
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError("");
|
||||
setTransferConfirmOpen(true);
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="card-std p-5">
|
||||
@@ -283,23 +277,18 @@ export function WorkflowTaskDetailPage() {
|
||||
const canTransfer = allowedActions.has("transfer");
|
||||
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
|
||||
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
||||
const nextActionText = resolveWorkflowNextActionText({
|
||||
taskStatus: detail.task.status,
|
||||
entityType: detail.workflowInstance?.entityType,
|
||||
allowedActions: detail.allowedActions,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`任务详情 · ${detail.task.stepName}`}
|
||||
subtitle="先核对业务摘要和证据,再处理通过、退回、驳回或转审。"
|
||||
title="审批详情"
|
||||
meta={(
|
||||
<>
|
||||
<StatusBadge status={detail.task.status} />
|
||||
<span className="info-chip">业务状态:{businessStatusLabel}</span>
|
||||
<span className="info-chip">类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
|
||||
<span className="info-chip">节点:{detail.task.stepName}</span>
|
||||
</>
|
||||
)}
|
||||
actions={(
|
||||
@@ -311,20 +300,8 @@ export function WorkflowTaskDetailPage() {
|
||||
|
||||
<div className="detail-workspace-grid">
|
||||
<div className="space-y-5">
|
||||
<SectionPanel title="当前状态与审批对象" description="首屏只放判断审批所需的状态、摘要和下一步。">
|
||||
<SectionPanel title="当前状态与审批对象">
|
||||
<div className="space-y-4" data-testid="workflow-task-overview">
|
||||
<div className="detail-command-panel" data-testid="workflow-task-next-action">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-zinc-500">下一步</div>
|
||||
<p className="mt-1 text-sm leading-6 text-zinc-700">{nextActionText}</p>
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700">
|
||||
可执行:<span className="font-semibold text-zinc-900">{allowedActionLabels || "无"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
|
||||
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
|
||||
@@ -444,107 +421,6 @@ export function WorkflowTaskDetailPage() {
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
<SectionPanel title="审批动作" description="高风险动作会再次确认;驳回、退回和转审需要留下可复核说明。">
|
||||
<div className="detail-action-stack">
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="workflow-task-action-error"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="detail-action-card flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||
onClick={() => requestWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">驳回</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-reject-comment"
|
||||
onChange={(event) => setRejectComment(event.target.value)}
|
||||
rows={2}
|
||||
value={rejectComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-reject"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("reject")}
|
||||
type="button"
|
||||
>
|
||||
提交驳回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">退回</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-return-comment"
|
||||
onChange={(event) => setReturnComment(event.target.value)}
|
||||
rows={2}
|
||||
value={returnComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-return"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("return")}
|
||||
type="button"
|
||||
>
|
||||
提交退回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canTransfer ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-approver"
|
||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||
value={selectedApprover}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approverQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={2}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={() => mutation.mutate("transfer")}
|
||||
type="button"
|
||||
>
|
||||
提交转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="处理历史">
|
||||
<div className="detail-timeline">
|
||||
{detail.history.length ? (
|
||||
@@ -565,16 +441,148 @@ export function WorkflowTaskDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<DetailSideMeta title="任务摘要" description="保留审批留痕所需的节点、角色和时间。">
|
||||
<SectionPanel title="处理" className="workflow-action-panel">
|
||||
<div className="detail-action-stack">
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="workflow-task-action-error"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="workflow-action-bar">
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||
onClick={() => requestWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="workflow-task-return-toggle"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
|
||||
type="button"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="workflow-task-reject-toggle"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
|
||||
type="button"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
{canTransfer ? (
|
||||
<details className="workflow-action-more">
|
||||
<summary>更多</summary>
|
||||
<button
|
||||
data-testid="workflow-task-transfer-menu-item"
|
||||
type="button"
|
||||
onClick={() => setActiveSecondaryAction("transfer")}
|
||||
>
|
||||
转审
|
||||
</button>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeSecondaryAction === "reject" ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">驳回意见</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-reject-comment"
|
||||
onChange={(event) => setRejectComment(event.target.value)}
|
||||
rows={3}
|
||||
value={rejectComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-reject"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("reject")}
|
||||
type="button"
|
||||
>
|
||||
确认驳回
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeSecondaryAction === "return" ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">退回意见</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-return-comment"
|
||||
onChange={(event) => setReturnComment(event.target.value)}
|
||||
rows={3}
|
||||
value={returnComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-return"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("return")}
|
||||
type="button"
|
||||
>
|
||||
确认退回
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeSecondaryAction === "transfer" && canTransfer ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-approver"
|
||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||
value={selectedApprover}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approverQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={3}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={requestWorkflowTransfer}
|
||||
type="button"
|
||||
>
|
||||
确认转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<DetailSideMeta title="任务摘要">
|
||||
<div>节点:{detail.task.stepName}</div>
|
||||
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-zinc-700">
|
||||
{nextActionText}
|
||||
</div>
|
||||
</DetailSideMeta>
|
||||
</div>
|
||||
</div>
|
||||
@@ -600,6 +608,20 @@ export function WorkflowTaskDetailPage() {
|
||||
await mutation.mutateAsync(confirmAction);
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={transferConfirmOpen}
|
||||
onOpenChange={setTransferConfirmOpen}
|
||||
title="确认转审审批?"
|
||||
description={`事项:${entityTitle(detail.workflowInstance?.entityType)} · ${detail.task.stepName}。转审后当前任务会交给目标审批人处理,系统会写入审批历史。`}
|
||||
confirmLabel="确认转审"
|
||||
variant="default"
|
||||
testId="workflow-transfer-confirm-dialog"
|
||||
confirmTestId="workflow-transfer-confirm-submit"
|
||||
cancelTestId="workflow-transfer-confirm-cancel"
|
||||
onConfirm={async () => {
|
||||
await mutation.mutateAsync("transfer");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -15,11 +14,9 @@ import {
|
||||
FilterBar,
|
||||
ListWorkspace,
|
||||
PrimaryTable,
|
||||
StatStrip,
|
||||
WorkspaceEmptyState,
|
||||
WorkspaceHeader,
|
||||
} from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
@@ -210,7 +207,7 @@ export function WorkflowTasksPage() {
|
||||
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
|
||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||
const [showAdvanced, setShowAdvanced] = useState(() => status !== "pending" || entityType !== "all");
|
||||
const taskTab = status === "pending" ? "pending" : "processed";
|
||||
|
||||
function updateSearchParams(next: {
|
||||
status?: WorkflowStatusFilter;
|
||||
@@ -248,9 +245,7 @@ export function WorkflowTasksPage() {
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
const taskItems = query.data?.items ?? [];
|
||||
const isDefaultTaskView = status === "pending" && entityType === "all";
|
||||
const advancedVisible = showAdvanced || !isDefaultTaskView;
|
||||
const pendingTotal = summaryQuery.data?.pendingTotal ?? "-";
|
||||
const overdueTotal = summaryQuery.data?.overdueTotal ?? "-";
|
||||
const pendingEntitySummary =
|
||||
(summaryQuery.data?.byEntityType ?? [])
|
||||
.filter((item) => item.pendingTotal > 0)
|
||||
@@ -260,111 +255,85 @@ export function WorkflowTasksPage() {
|
||||
? formatWaitingDuration(summaryQuery.data.earliestPendingAt)
|
||||
: "暂无待处理";
|
||||
|
||||
function returnToPendingTasks() {
|
||||
setShowAdvanced(false);
|
||||
updateSearchParams({ status: "pending", entityType: "all", page: 1 });
|
||||
}
|
||||
|
||||
function openProcessedRecords() {
|
||||
setShowAdvanced(true);
|
||||
updateSearchParams({ status: "completed", entityType: "all", page: 1 });
|
||||
}
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
<WorkspaceHeader
|
||||
eyebrow="审批"
|
||||
title="待我处理"
|
||||
actions={
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
data-testid="workflow-tasks-advanced-toggle"
|
||||
onClick={advancedVisible ? returnToPendingTasks : openProcessedRecords}
|
||||
>
|
||||
{advancedVisible ? "回到待处理" : "已处理记录"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<WorkspaceHeader title="待我处理" />
|
||||
|
||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3" data-testid="workflow-tasks-focus">
|
||||
<span className="badge-neutral">待处理</span>
|
||||
<span className="text-2xl font-semibold text-zinc-900">{pendingTotal}</span>
|
||||
<span className="text-sm text-zinc-500">
|
||||
最早:{earliestPendingWaiting}
|
||||
</span>
|
||||
{summaryQuery.data?.overdueTotal ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
|
||||
超时 {summaryQuery.data.overdueTotal}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-col gap-3 md:flex-row md:items-center">
|
||||
<Tabs
|
||||
data-testid="workflow-tasks-status-tabs"
|
||||
value={taskTab}
|
||||
onValueChange={(value) =>
|
||||
updateSearchParams({
|
||||
status: value === "pending" ? "pending" : "completed",
|
||||
page: 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
<TabsTrigger data-testid="workflow-tasks-tab-pending" value="pending">
|
||||
待处理
|
||||
</TabsTrigger>
|
||||
<TabsTrigger data-testid="workflow-tasks-tab-processed" value="processed">
|
||||
已处理
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3" data-testid="workflow-tasks-focus">
|
||||
<span className="text-sm font-medium text-zinc-900">待处理 {pendingTotal}</span>
|
||||
<span className="text-sm text-zinc-500">{earliestPendingWaiting}</span>
|
||||
{summaryQuery.data?.overdueTotal ? (
|
||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700">
|
||||
超时 {summaryQuery.data.overdueTotal}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ops-command-bar__actions">
|
||||
<span className="text-xs text-zinc-500">业务:{pendingEntitySummary}</span>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{advancedVisible ? (
|
||||
<div className="space-y-3" data-testid="workflow-tasks-advanced-panel">
|
||||
<div data-testid="workflow-task-summary">
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="待办总数" value={pendingTotal} />
|
||||
<MetricCard title="超时待办" value={overdueTotal} tone="warning" />
|
||||
<MetricCard title="最早待处理" value={earliestPendingWaiting} />
|
||||
<MetricCard
|
||||
title="业务分布"
|
||||
value={<span className="block truncate text-base">{pendingEntitySummary}</span>}
|
||||
/>
|
||||
</StatStrip>
|
||||
</div>
|
||||
|
||||
<FilterBar className="ops-command-bar !p-3 md:!p-3.5">
|
||||
<div className="ops-command-bar__row">
|
||||
<Tabs
|
||||
data-testid="workflow-tasks-status-tabs"
|
||||
value={status === "pending" ? "completed" : status}
|
||||
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
|
||||
>
|
||||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||||
{WORKFLOW_HISTORY_STATUS_OPTIONS.map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="ops-command-bar__actions">
|
||||
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
|
||||
业务类型
|
||||
<span className="hidden text-xs text-zinc-500 xl:inline">业务:{pendingEntitySummary}</span>
|
||||
{taskTab === "processed" ? (
|
||||
<>
|
||||
<label className="ops-filter-label" htmlFor="workflow-status-filter">
|
||||
记录
|
||||
</label>
|
||||
<select
|
||||
id="workflow-entity-type-filter"
|
||||
className="select-sm min-w-32"
|
||||
data-testid="workflow-tasks-entity-filter"
|
||||
value={entityType}
|
||||
onChange={(event) =>
|
||||
updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })
|
||||
}
|
||||
id="workflow-status-filter"
|
||||
className="select-sm min-w-28"
|
||||
data-testid="workflow-tasks-processed-status-filter"
|
||||
value={status === "pending" ? "completed" : status}
|
||||
onChange={(event) => updateSearchParams({ status: event.target.value as WorkflowStatusFilter, page: 1 })}
|
||||
>
|
||||
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
|
||||
{WORKFLOW_HISTORY_STATUS_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
</>
|
||||
) : null}
|
||||
<label className="ops-filter-label" htmlFor="workflow-entity-type-filter">
|
||||
业务
|
||||
</label>
|
||||
<select
|
||||
id="workflow-entity-type-filter"
|
||||
className="select-sm min-w-32"
|
||||
data-testid="workflow-tasks-entity-filter"
|
||||
value={entityType}
|
||||
onChange={(event) =>
|
||||
updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })
|
||||
}
|
||||
>
|
||||
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
{query.isLoading ? <TableSkeleton columns={4} rows={5} /> : null}
|
||||
|
||||
@@ -421,7 +390,7 @@ export function WorkflowTasksPage() {
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
{task.status === "pending" ? "处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -482,7 +451,7 @@ export function WorkflowTasksPage() {
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>
|
||||
{task.status === "pending" ? "查看处理" : "查看详情"}
|
||||
{task.status === "pending" ? "处理" : "查看详情"}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -591,8 +591,8 @@ test("个人设置默认只展示账号通知和企业微信,不展示权限 c
|
||||
|
||||
await expect(page.getByRole("heading", { name: "个人设置" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "个人资料" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "密码与安全" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "通知设置" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "账号安全" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "通知偏好" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "企业微信绑定" })).toBeVisible();
|
||||
await expect(page.getByText("账号详情")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "查看员工档案" })).toHaveCount(0);
|
||||
|
||||
@@ -330,9 +330,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => {
|
||||
await expect(page.locator(".ops-command-bar").first()).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-tasks-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toHaveCount(0);
|
||||
await expect(page.getByTestId("workflow-tasks-status-tabs")).toHaveCount(0);
|
||||
await page.getByTestId("workflow-tasks-advanced-toggle").click();
|
||||
await expect(page.getByTestId("workflow-tasks-advanced-panel")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-tasks-status-tabs")).toBeVisible();
|
||||
await page.getByTestId("workflow-tasks-tab-processed").click();
|
||||
await expect(page.getByTestId("workflow-tasks-processed-status-filter")).toBeVisible();
|
||||
await capture(page, "04-workflow-tasks");
|
||||
});
|
||||
|
||||
@@ -1070,12 +1070,19 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/workflow/tasks/task-wave3");
|
||||
await expect(page.getByRole("heading", { name: /任务详情/ })).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-next-action")).toBeVisible();
|
||||
await expect(page.getByText("审批动作")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-next-action")).toHaveCount(0);
|
||||
await expect(page.getByText("审批动作")).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||
await page.screenshot({ path: screenshotPath("02-pc-workflow-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/notifications");
|
||||
await expect(page.getByRole("heading", { name: "通知" })).toBeVisible();
|
||||
await expect(page.getByTestId("notification-row").first()).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await page.screenshot({ path: screenshotPath("03-pc-notifications.png"), fullPage: true });
|
||||
|
||||
await page.goto("/projects");
|
||||
await expect(page.getByRole("heading", { name: "项目", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("默认只看我负责/我参与")).toBeVisible();
|
||||
@@ -1102,13 +1109,8 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await page.getByTestId("contracts-registry-row").getByRole("button", { name: "归档" }).click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档");
|
||||
await page.getByTestId("action-confirm-cancel").click();
|
||||
await expect(page.getByTestId("action-confirm-dialog")).toHaveCount(0);
|
||||
await page.screenshot({ path: screenshotPath("04-pc-contracts-registry.png"), fullPage: true });
|
||||
|
||||
await page.goto("/notifications");
|
||||
await expect(page.getByRole("heading", { name: "通知" })).toBeVisible();
|
||||
await expect(page.getByTestId("notification-row").first()).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await page.screenshot({ path: screenshotPath("05-pc-notifications.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||
@@ -1137,7 +1139,7 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||
await page.screenshot({ path: screenshotPath("06-mobile-reimbursement-detail.png"), fullPage: true });
|
||||
|
||||
await page.goto("/mobile/approvals");
|
||||
await expect(page.getByRole("heading", { name: "待办", exact: true })).toBeVisible();
|
||||
await expect(page.locator("h1", { hasText: "待我处理" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-approvals-list")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
|
||||
@@ -551,7 +551,7 @@ test.describe("R124 企业微信移动入口矩阵", () => {
|
||||
await expect(mobile.page).toHaveURL(/\/mobile\/profile$/);
|
||||
await expect(mobile.page.getByTestId("mobile-profile-page")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("mobile-profile-wecom-card")).toBeVisible();
|
||||
await expect(mobile.page.getByRole("heading", { name: "密码与安全" })).toBeVisible();
|
||||
await expect(mobile.page.getByRole("heading", { name: "账号安全" })).toBeVisible();
|
||||
await expect(mobile.page.getByRole("heading", { name: "组织与角色摘要" })).toBeVisible();
|
||||
await expect(mobile.page.getByText("active", { exact: true })).toHaveCount(0);
|
||||
await expect(mobile.page.getByText("manager", { exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -399,7 +399,7 @@ test.describe("visual shell", () => {
|
||||
|
||||
await page.goto("/reimbursements?status=reviewing&scope=all");
|
||||
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "查看处理" })).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-detail-link").filter({ hasText: "处理" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "批量通过" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "批量驳回" })).toHaveCount(0);
|
||||
@@ -427,7 +427,7 @@ test.describe("visual shell", () => {
|
||||
|
||||
await expect(page.getByTestId("workflow-tasks-mobile-list")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-tasks-table")).toBeHidden();
|
||||
await expect(page.getByRole("link", { name: "查看" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "处理" })).toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "r97-workflow-tasks-mobile.png");
|
||||
});
|
||||
|
||||
@@ -299,12 +299,14 @@ test.describe("R7E 审批动作二次确认", () => {
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.approve).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-task-reject-toggle").click();
|
||||
await page.getByTestId("workflow-task-reject-comment").fill("资料不符合要求,请重新发起。");
|
||||
await page.getByTestId("workflow-task-reject").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("流程将终止");
|
||||
await page.getByTestId("workflow-action-confirm-cancel").click();
|
||||
expect(actionCalls.reject).toBe(0);
|
||||
|
||||
await page.getByTestId("workflow-task-return-toggle").click();
|
||||
await page.getByTestId("workflow-task-return-comment").fill("请补充附件后重新提交。");
|
||||
await page.getByTestId("workflow-task-return").click();
|
||||
await expect(page.getByTestId("workflow-action-confirm-dialog")).toContainText("回到申请人修改态");
|
||||
|
||||
@@ -212,7 +212,7 @@ async function mockWorkflowBoundaryApis(page: Page) {
|
||||
return actionCalls;
|
||||
}
|
||||
|
||||
test("审批列表边界:企微主办请假只进入查看处理", async ({ page }) => {
|
||||
test("审批列表边界:企微主办请假只进入处理详情", async ({ page }) => {
|
||||
const actionCalls = await mockWorkflowBoundaryApis(page);
|
||||
|
||||
await login(page, MANAGER);
|
||||
|
||||
在新工单中引用
屏蔽一个用户