From a9d91d3259bfbb631823f27c161c4f539c472a76 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 29 May 2026 22:31:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E4=B8=8E=E6=8A=A5=E9=94=80=E4=BB=BB=E5=8A=A1=E4=BC=98=E5=85=88?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=88portal-task-first-redesign=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 18 + client/src/index.css | 101 ++ client/src/pages/PortalHomePage.tsx | 1404 +++++------------ client/src/pages/ReimbursementsPage.tsx | 285 +++- .../tests/e2e/announcements-presence.spec.ts | 12 +- client/tests/e2e/helpers/smokeAssertions.ts | 2 +- client/tests/e2e/login-portal-ux.spec.ts | 80 +- .../manual/r70-trialrun-deep-assist.spec.ts | 8 +- .../e2e/pc-core-workflow-visual-wave2.spec.ts | 4 +- .../e2e/r102-reimbursement-main-flow.spec.ts | 4 +- client/tests/e2e/r105-pretrial-visual.spec.ts | 2 +- .../e2e/system-action-confirmation.spec.ts | 2 +- client/tests/e2e/visual-portal.spec.ts | 202 ++- client/tests/e2e/visual-shell.spec.ts | 10 +- docs/产品设计规则.md | 3 + 15 files changed, 988 insertions(+), 1149 deletions(-) diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 1887929e..4e92a53d 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -244,6 +244,24 @@ secondary operations: workflow steps that the layout can express. - Low-frequency capabilities are hidden or moved, not removed, unless the current Trellis task explicitly deletes the capability. +- Portal/workbench first screen should use a compact status summary plus + `今日待处理`; do not use a persistent welcome hero, fixed `报销进度` button, + general announcement feed, or folded placeholder sections such as + `团队与通知`, `角色重点` or `试点观测摘要`. +- Workbench sections after `今日待处理` must be conditional: `团队卡点` only for + real blockers, `管理提醒` only for real admin/trial issues, and `我的进度` + only for reliable status updates. Empty data should disappear, not render as + collapsed drawers or statistic cards. +- Each workbench action row should state what the item is, why the current user + is involved, current status/timing, and one next action. Do not represent raw + application counts as progress unless the payload includes an actionable or + status-level signal. +- PC reimbursement list defaults to the initiator's own forms even for users + with finance/approval roles. The first screen may use task cards for + rejected corrections, drafts and reviewing progress; ledger/export/batch, + proxy submit, templates and travel-linking remain secondary and must be hidden + unless the current user has a real matching capability. Row workflow copy + should distinguish `待你处理` from read-only `当前节点`. **Why**: hyb-oa serves small teams with overlapping roles. Core pages must make the next click clear for ordinary employees and approvers, while keeping finance diff --git a/client/src/index.css b/client/src/index.css index f9ea870f..d08beff1 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -793,6 +793,107 @@ @apply h-full; } + .portal-work-summary { + background: rgba(255, 255, 255, 0.96); + border: 1px solid rgba(226, 232, 240, 0.92); + border-radius: var(--radius-panel); + box-shadow: var(--shadow-xs); + @apply flex flex-col gap-3 px-4 py-3.5 md:flex-row md:items-center md:justify-between md:px-5; + } + + .portal-work-summary__eyebrow { + color: var(--color-accent-primary); + @apply text-xs font-bold; + } + + .portal-work-summary__title { + color: var(--color-text-primary); + @apply mt-1 text-lg font-semibold leading-7 md:text-xl; + } + + .portal-work-summary__meta { + color: var(--color-text-tertiary); + @apply mt-1 text-xs font-medium; + } + + .portal-task-list { + border: 1px solid rgba(226, 232, 240, 0.94); + background: rgba(255, 255, 255, 0.72); + @apply overflow-hidden rounded-lg divide-y divide-slate-200/80; + } + + .portal-task-row { + background: rgba(255, 255, 255, 0.88); + color: var(--color-text-primary); + @apply grid min-h-[68px] grid-cols-[auto,minmax(0,1fr)] items-center gap-3 px-3.5 py-3 transition-colors hover:bg-white md:grid-cols-[auto,minmax(0,1fr),auto,auto]; + } + + .portal-task-row--warning { + background: rgba(255, 251, 235, 0.74); + } + + .portal-task-row--high { + background: rgba(254, 242, 242, 0.78); + } + + .portal-task-row__icon { + background: rgba(0, 57, 138, 0.08); + color: var(--color-accent-primary); + @apply flex size-9 items-center justify-center rounded-lg; + } + + .portal-task-row--warning .portal-task-row__icon { + @apply bg-amber-50 text-amber-700; + } + + .portal-task-row--high .portal-task-row__icon { + @apply bg-red-50 text-red-700; + } + + .portal-task-row__title { + color: var(--color-text-primary); + @apply block truncate text-sm font-semibold; + } + + .portal-task-row__reason { + color: var(--color-text-secondary); + @apply mt-1 block text-xs leading-5; + } + + .portal-task-row__meta { + color: var(--color-text-secondary); + @apply ml-12 inline-flex w-fit rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold md:ml-0; + } + + .portal-task-row__action { + color: var(--color-accent-primary); + @apply ml-12 text-sm font-semibold md:ml-0; + } + + .portal-empty-start { + border: 1px dashed rgba(203, 213, 225, 0.9); + background: rgba(248, 250, 252, 0.78); + @apply flex flex-col gap-4 rounded-lg px-4 py-4 md:flex-row md:items-center md:justify-between; + } + + .portal-empty-start__title { + color: var(--color-text-primary); + @apply text-base font-semibold; + } + + .portal-empty-start__desc { + color: var(--color-text-secondary); + @apply mt-1 text-sm leading-6; + } + + .portal-inline-actions { + @apply flex flex-wrap gap-2; + } + + .portal-task-secondary-grid { + @apply grid gap-4 xl:grid-cols-2; + } + .portal-list-card { background: rgba(255, 255, 255, 0.92); border: 1px solid rgba(226, 232, 240, 0.94); diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index 2f405b3a..c36fc613 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -1,78 +1,40 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { mapApiErrorMessage } from "../api/errorMessages"; -import { - fetchPortalHome, - type PortalHomePayload, - type PortalRecentActivity, - type PortalTeamStatusItem, -} from "../api/portal"; -import { fetchWorkflowTaskSummary, type WorkflowTaskProductivitySummary } from "../api/workflow"; +import { fetchPortalHome, type PortalHomePayload } from "../api/portal"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission, isManagerRole } from "../auth/permissions"; -import { EmployeeAvatar } from "../components/common/EmployeeAvatar"; import { ErrorBoundary } from "../components/common/ErrorBoundary"; -import { EmptyStateCard } from "../components/common/EmptyStateCard"; import { WelcomeDialog } from "../components/onboarding/WelcomeDialog"; -import { MetricCard } from "../components/patterns/MetricCard"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { SectionPanel } from "../components/patterns/SectionPanel"; -import { WorkbenchHero } from "../components/patterns/WorkbenchHero"; import { Button } from "../components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; -import { employeePortalSchema, managerPortalSchema } from "../features/portal/schema"; import { clearWelcomeDialogFlag, shouldShowWelcomeDialog, } from "../lib/onboardingGuide"; -import { readRecentVisits } from "../lib/recentVisits"; -type SummaryItem = { +type RoleCapabilities = NonNullable; + +type WorkItem = { key: string; - href: string; - label: string; - value: string; - note?: string; - tone?: "normal" | "warning"; -}; - -function workflowEntityLabel(entityType: string) { - if (entityType === "reimbursement") return "报销"; - if (entityType === "leave") return "请假"; - if (entityType === "purchase_request") return "采购"; - if (entityType === "travel_request") return "差旅"; - if (entityType === "unknown") return "未知业务"; - return entityType; -} - -type TodoAggregateItem = { title: string; - sourceLabel: string; - severity: "normal" | "warning" | "high"; + reason: string; + meta: string; href: string; + actionLabel: string; + severity: "normal" | "warning" | "high"; icon: string; + priority: number; }; -type QuickActionConfig = { +type EmptyAction = { key: string; - icon: string; label: string; - description: string; - to?: string; - onClick?: () => void; - disabled?: boolean; -}; - -type PortalFocusAction = { - label: string; - to: string; -}; - -type PortalFocusLink = { - label: string; - to: string; + href: string; + variant?: "primary" | "secondary" | "ghost"; }; const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"]; @@ -82,23 +44,14 @@ 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 TODO_SEVERITY_RANK = { + +const SEVERITY_RANK = { high: 3, warning: 2, normal: 1, } as const; -function isSummaryItem(item: SummaryItem | null): item is SummaryItem { - return Boolean(item); -} - -function severityTone(severity?: "normal" | "warning" | "high") { - if (severity === "high") return "danger" as const; - if (severity === "warning") return "warning" as const; - return "neutral" as const; -} - -function buildFallbackRoleCapabilities(permissions: string[], roleCodes: string[]) { +function buildFallbackRoleCapabilities(permissions: string[], roleCodes: string[]): RoleCapabilities { const hasAdminRole = roleCodes.includes("admin") || hasAnyPermission(permissions, ["*"]); const hasBusinessApproverRole = roleCodes.some((roleCode) => ["manager", "ceo", "finance", "finance_mgr", "hr"].includes(roleCode), @@ -120,687 +73,455 @@ function buildFallbackRoleCapabilities(permissions: string[], roleCodes: string[ }; } -function pickCards(cards: PortalHomePayload["cards"], keys: string[]) { - const map = new Map(cards.map((card) => [card.key, card])); - return keys.map((key) => map.get(key)).filter((card): card is PortalHomePayload["cards"][number] => Boolean(card)); +function sortWorkItems(items: WorkItem[]) { + return [...items].sort((left, right) => { + const severityCompare = SEVERITY_RANK[right.severity] - SEVERITY_RANK[left.severity]; + if (severityCompare !== 0) { + return severityCompare; + } + return right.priority - left.priority; + }); } -function compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[]) { - return cards.filter((card) => !excludedKeys.includes(card.key)); +function uniqueWorkItems(items: WorkItem[]) { + const deduped = new Map(); + for (const item of items) { + if (!deduped.has(item.key)) { + deduped.set(item.key, item); + } + } + return sortWorkItems(Array.from(deduped.values())); } -function formatRelativeTime(value: string | null) { - if (!value) { - return "-"; - } +function buildTodayActionItems(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) { + const items: WorkItem[] = []; - const timestamp = new Date(value).getTime(); - if (Number.isNaN(timestamp)) { - return "-"; - } - - const diffMs = Date.now() - timestamp; - if (diffMs < 60 * 1000) { - return "刚刚"; - } - - const diffMinutes = Math.floor(diffMs / (60 * 1000)); - if (diffMinutes < 60) { - return `${diffMinutes} 分钟前`; - } - - const diffHours = Math.floor(diffMinutes / 60); - if (diffHours < 24) { - return `${diffHours} 小时前`; - } - - const diffDays = Math.floor(diffHours / 24); - return `${diffDays} 天前`; -} - -function buildTodoAggregateItems(payload: PortalHomePayload) { - const cards = Array.isArray(payload.cards) ? payload.cards : []; - const items: TodoAggregateItem[] = []; if (payload.pendingTasks.count > 0) { items.push({ + key: "workflow-pending", title: `待我处理 ${payload.pendingTasks.count} 条`, - sourceLabel: "待办", - severity: "warning", + reason: "审批、确认或流程处理正在等你。", + meta: "流程待办", href: payload.pendingTasks.href, + actionLabel: "去处理", + severity: "warning", icon: "fact_check", + priority: 100, }); } - if ((payload.todoSummary?.openCount ?? 0) > 0) { + + const openTodoCount = payload.todoSummary?.openCount ?? 0; + if (openTodoCount > 0) { + const highPriorityCount = payload.todoSummary?.highPriorityCount ?? 0; items.push({ - title: `TODO 待办 ${payload.todoSummary?.openCount ?? 0} 条`, - sourceLabel: "TODO", - severity: (payload.todoSummary?.highPriorityCount ?? 0) > 0 ? "high" : "normal", + key: "todo-open", + title: `待办事项 ${openTodoCount} 条`, + reason: highPriorityCount > 0 + ? `其中 ${highPriorityCount} 条是高优先级,需要先确认。` + : "这些事项已分配给你,需要继续推进。", + meta: highPriorityCount > 0 ? "高优先级" : "个人待办", href: payload.todoSummary?.href ?? "/todos", + actionLabel: "查看待办", + severity: highPriorityCount > 0 ? "high" : "normal", icon: "check_circle", + priority: 90, }); } - for (const card of cards) { - if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) { - continue; - } + + if (payload.weeklyReport?.status === "draft") { items.push({ - title: `${card.title}:${card.value}`, - sourceLabel: "工作台", - severity: card.severity, - href: card.href, - icon: card.severity === "high" ? "priority_high" : "warning", + key: "weekly-draft", + title: "本周周报待填写", + reason: "需要补充本周进展、风险和下周计划。", + meta: "本周", + href: payload.weeklyReport.href, + actionLabel: "填写周报", + severity: payload.weeklyReport.needsHelp ? "high" : "warning", + icon: "article", + priority: 72, }); } - const deduped = new Map(); - for (const item of items) { - deduped.set(`${item.title}|${item.href}|${item.sourceLabel}`, item); + if (roleCapabilities.canUseFinance && payload.financeSummary) { + if (payload.financeSummary.failedItemCount > 0) { + items.push({ + key: "finance-failed-payment", + title: `异常付款 ${payload.financeSummary.failedItemCount} 项`, + reason: "付款失败或异常需要财务确认原因。", + meta: "财务异常", + href: payload.financeSummary.href, + actionLabel: "处理异常", + severity: "high", + icon: "priority_high", + priority: 96, + }); + } + + if (payload.financeSummary.riskCount > 0) { + items.push({ + key: "finance-risk-review", + title: `待复核风险 ${payload.financeSummary.riskCount} 项`, + reason: "付款或报销风险需要复核后再推进。", + meta: "风险复核", + href: payload.financeSummary.href, + actionLabel: "去复核", + severity: "high", + icon: "shield_person", + priority: 94, + }); + } + + if (payload.financeSummary.pendingBatchCount > 0) { + items.push({ + key: "finance-pending-batch", + title: `待付款批次 ${payload.financeSummary.pendingBatchCount} 个`, + reason: "需要继续确认、复核或提交付款。", + meta: "付款", + href: payload.financeSummary.href, + actionLabel: "处理付款", + severity: "warning", + icon: "payments", + priority: 86, + }); + } } - return Array.from(deduped.values()) - .sort((left, right) => { - const severityCompare = TODO_SEVERITY_RANK[right.severity] - TODO_SEVERITY_RANK[left.severity]; - if (severityCompare !== 0) { - return severityCompare; - } - return left.title.localeCompare(right.title, "zh-CN"); - }) - .slice(0, 10); + if (payload.projectSummary?.highPriorityTaskCount) { + items.push({ + key: "project-high-priority", + title: `高优先级项目任务 ${payload.projectSummary.highPriorityTaskCount} 项`, + reason: "项目动作需要今天确认下一步。", + meta: "项目", + href: payload.projectSummary.href, + actionLabel: "查看项目", + severity: "warning", + icon: "workspaces", + priority: 64, + }); + } + + if (payload.projectSummary?.followUpDueTodayCount) { + items.push({ + key: "project-follow-up", + title: `今日项目跟进 ${payload.projectSummary.followUpDueTodayCount} 项`, + reason: "客户、交付或验收动作今天需要跟进。", + meta: "今日", + href: payload.projectSummary.href, + actionLabel: "去跟进", + severity: "warning", + icon: "event_upcoming", + priority: 62, + }); + } + + return uniqueWorkItems(items).slice(0, 8); } -function resolveTeamStatusBadge(item: PortalTeamStatusItem) { - if (item.onLeave) { - return { - label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", - className: "badge-warning", - }; +function buildProgressItems(payload: PortalHomePayload) { + const items: WorkItem[] = []; + + if (payload.weeklyReport?.status === "submitted") { + items.push({ + key: "weekly-submitted", + title: "本周周报已提交", + reason: "本周进展已记录,可进入周报页查看。", + meta: "已提交", + href: payload.weeklyReport.href, + actionLabel: "查看", + severity: "normal", + icon: "done_all", + priority: 30, + }); } - if (item.presenceStatus === "office") { - return { label: "在岗", className: "badge-success" }; - } - if (item.presenceStatus === "out") { - return { - label: "外出", - className: "inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10", - }; - } - if (item.presenceStatus === "travel") { - return { - label: "差旅", - className: "inline-flex items-center rounded-full bg-sky-50 px-2.5 py-0.5 text-xs font-semibold text-sky-700 ring-1 ring-inset ring-sky-600/10", - }; - } - if (item.presenceStatus === "remote") { - return { label: "居家", className: "badge-neutral" }; - } - return { - label: "考勤", - className: "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10", - }; + + return uniqueWorkItems(items).slice(0, 3); } -function formatTeamStatusAttendance(item: PortalTeamStatusItem) { - if (item.onLeave) { - return item.leaveType ? `${item.leaveType} 已审批` : "今日请假"; +function buildTeamBlockers(payload: PortalHomePayload, roleCapabilities: RoleCapabilities) { + if (!roleCapabilities.canViewTeam && !roleCapabilities.canManageSystem) { + return []; } - if (item.presenceStatus === "travel") { - return "今日差旅"; + + const items: WorkItem[] = []; + + if ((payload.weeklySummary?.missingCount ?? 0) > 0) { + items.push({ + key: "team-weekly-missing", + title: `${payload.weeklySummary?.missingCount ?? 0} 人未交周报`, + reason: "负责人需要提醒或确认是否有阻塞。", + meta: "团队", + href: payload.weeklySummary?.href ?? "/weekly-reports", + actionLabel: "查看周报", + severity: "warning", + icon: "article", + priority: 78, + }); } - if (item.presenceStatus === "out") { - return "当前外出"; + + if ((payload.weeklySummary?.needsHelpCount ?? 0) > 0) { + items.push({ + key: "team-weekly-help", + title: `${payload.weeklySummary?.needsHelpCount ?? 0} 人标记需要协助`, + reason: "需要负责人确认问题和下一步支持。", + meta: "协助", + href: payload.weeklySummary?.href ?? "/weekly-reports", + actionLabel: "查看详情", + severity: "high", + icon: "support_agent", + priority: 82, + }); } - if (item.presenceStatus === "remote") { - return "今日居家办公"; + + if ((payload.teamPresence?.unspecified ?? 0) > 0) { + items.push({ + key: "team-presence-missing", + title: `${payload.teamPresence?.unspecified ?? 0} 人未更新团队状态`, + reason: "今日在岗、外出或请假状态还不完整。", + meta: "状态", + href: payload.teamPresence?.href ?? "/team", + actionLabel: "查看团队", + severity: "warning", + icon: "groups", + priority: 58, + }); } - return "企微考勤见考勤页"; + + if ((payload.projectSummary?.overdueMilestonesCount ?? 0) > 0) { + items.push({ + key: "team-project-overdue", + title: `${payload.projectSummary?.overdueMilestonesCount ?? 0} 个里程碑已逾期`, + reason: "项目负责人需要调整计划或补充说明。", + meta: "项目", + href: payload.projectSummary?.href ?? "/projects", + actionLabel: "查看项目", + severity: "warning", + icon: "flag", + priority: 74, + }); + } + + if ((payload.projectSummary?.openRiskCount ?? 0) > 0) { + items.push({ + key: "team-project-risk", + title: `${payload.projectSummary?.openRiskCount ?? 0} 个项目风险未关闭`, + reason: "需要确认风险责任人、应对措施和截止时间。", + meta: "风险", + href: payload.projectSummary?.href ?? "/projects", + actionLabel: "处理风险", + severity: "high", + icon: "warning", + priority: 76, + }); + } + + if ((payload.peopleLifecycle?.offboardingCount ?? 0) > 0 || (payload.peopleLifecycle?.assetReturnPendingCount ?? 0) > 0) { + items.push({ + key: "team-offboarding-assets", + title: "离职和资产交接待收口", + reason: `离职 ${payload.peopleLifecycle?.offboardingCount ?? 0} 条,资产 ${payload.peopleLifecycle?.assetReturnPendingCount ?? 0} 项。`, + meta: "交接", + href: payload.peopleLifecycle?.href ?? "/people", + actionLabel: "查看人员", + severity: "warning", + icon: "inventory_2", + priority: 60, + }); + } + + return uniqueWorkItems(items).slice(0, 3); } -function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) { - if (!items.length) { - return ; +function buildManagementReminders( + payload: PortalHomePayload, + roleCapabilities: RoleCapabilities, + canSetupWorkspace: boolean, +) { + if (!roleCapabilities.canManageSystem) { + return []; } - return ( - -
- {items.map((item) => ( - -
-
{item.label}
-
{item.value}
-
- {item.note ?
{item.note}
: null} - - ))} -
-
- ); -} + const items: WorkItem[] = []; -function FinanceSummaryPanel({ summary }: { summary: PortalHomePayload["financeSummary"] }) { - if (!summary) { - return null; + if (!payload.workspace.persisted && canSetupWorkspace) { + items.push({ + key: "workspace-setup", + title: "工作区设置未完成", + reason: "需要确认基础入口、通知策略和可见模块。", + meta: "配置", + href: "/system/workspace", + actionLabel: "去设置", + severity: "warning", + icon: "settings", + priority: 88, + }); } - const items: SummaryItem[] = [ - { - key: "finance-pending-batches", - href: summary.href, - label: "待付款批次", - value: `${summary.pendingBatchCount} 个`, - tone: summary.pendingBatchCount > 0 ? "warning" : "normal", - }, - { - key: "finance-failed-items", - href: summary.href, - label: "异常付款", - value: `${summary.failedItemCount} 项`, - tone: summary.failedItemCount > 0 ? "warning" : "normal", - }, - { - key: "finance-risk-flags", - href: summary.href, - label: "待复核风险", - value: `${summary.riskCount} 项`, - tone: summary.riskCount > 0 ? "warning" : "normal", - }, - ]; + const inactiveLoginCount = payload.adoptionSummary + ? Math.max(0, payload.adoptionSummary.activeEmployeeCount - payload.adoptionSummary.login7dCount) + : 0; + if (inactiveLoginCount > 0) { + items.push({ + key: "trial-login-missing", + title: `近 7 天 ${inactiveLoginCount} 人未登录`, + reason: "试运行覆盖率不足,需要确认账号或提醒到人。", + meta: "试运行", + href: "/system/workspace", + actionLabel: "查看配置", + severity: "warning", + icon: "manage_accounts", + priority: 52, + }); + } - return ; + return uniqueWorkItems(items).slice(0, 3); } -function ApprovalProductivityPanel({ - summary, - title = "审批效率摘要", +function buildEmptyActions(permissions: string[], roleCapabilities: RoleCapabilities) { + 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" }); + } + + if (roleCapabilities.canManageSystem) { + actions.push({ key: "manage-settings", label: "管理设置", href: "/system/workspace", variant: actions.length ? "ghost" : "secondary" }); + } + + return actions.slice(0, roleCapabilities.canManageSystem && actions.length > 2 ? 3 : 2); +} + +function SummaryStrip({ + userName, + actionCount, + progressCount, + blockerCount, + reminderCount, + primaryItem, + emptyActions, }: { - summary: WorkflowTaskProductivitySummary | null; - title?: string; + userName: string; + actionCount: number; + progressCount: number; + blockerCount: number; + reminderCount: number; + primaryItem: WorkItem | null; + emptyActions: EmptyAction[]; }) { - if (!summary) { - return null; - } - - const typeDistribution = summary.byEntityType - .filter((item) => item.pendingTotal > 0) - .map((item) => `${workflowEntityLabel(item.key)} ${item.pendingTotal}`) - .join(" / "); - const items: SummaryItem[] = [ - { - key: "workflow-pending-total", - href: "/workflow/tasks", - label: "当前待办", - value: `${summary.pendingTotal} 条`, - note: summary.earliestPendingAt - ? `最早待处理:${formatRelativeTime(summary.earliestPendingAt)}` - : "暂无待处理审批。", - tone: summary.pendingTotal > 0 ? "warning" : "normal", - }, - { - key: "workflow-overdue-total", - href: "/workflow/tasks", - label: "超时待办", - value: `${summary.overdueTotal} 条`, - note: `平均等待 ${summary.averagePendingAgeHours} 小时`, - tone: summary.overdueTotal > 0 ? "warning" : "normal", - }, - { - key: "workflow-entity-distribution", - href: "/workflow/tasks", - label: "业务分布", - value: typeDistribution || "暂无", - note: "按报销、请假、采购、差旅汇总。", - }, - ]; - - return ; -} - -function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) { - const safeAnnouncements = Array.isArray(announcements) ? announcements : []; + const attentionCount = blockerCount + reminderCount; + const summaryText = actionCount > 0 + ? `今天有 ${actionCount} 件待处理${progressCount > 0 ? `,${progressCount} 个进度更新` : ""}${attentionCount > 0 ? `,${attentionCount} 个团队或管理提醒` : ""}。` + : progressCount > 0 || attentionCount > 0 + ? `今天没有必须处理的事项${progressCount > 0 ? `,有 ${progressCount} 个进度更新` : ""}${attentionCount > 0 ? `,${attentionCount} 个团队或管理提醒` : ""}。` + : "今天没有需要你处理的事项。"; return ( -
- {safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => ( -
-
{item.title}
-
{item.summary}
- {item.href ? ( - - 查看详情 - - ) : null} -
- )) : } -
- ); -} - -function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) { - const safeItems = Array.isArray(items) ? items : []; - - return ( - -
- {safeItems.length ? safeItems.map((item) => ( - - {item.title} - - )) : } +
+
+
工作台
+
{summaryText}
+
{userName} · 当前工作区
- - ); -} - -function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) { - if (!cards.length) { - return ; - } - - return ( - -
- {cards.map((card) => ( - - ))} -
-
- ); -} - -function CompactGroup({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

-
{children}
+ {primaryItem ? ( + + ) : emptyActions[0] ? ( + + ) : null}
); } -function CompactSummaryLinks({ title, items }: { title: string; items: SummaryItem[] }) { - return ( - - {items.length ? items.map((item) => ( - - {item.label} - - {item.value} - - - )) :
暂无摘要。
} -
- ); -} - -function CompactRecentVisits() { - const recentVisits = readRecentVisits(); +function WorkItemRow({ item }: { item: WorkItem }) { + const severityClass = item.severity === "high" + ? "portal-task-row portal-task-row--high" + : item.severity === "warning" + ? "portal-task-row portal-task-row--warning" + : "portal-task-row"; return ( - - {recentVisits.length ? recentVisits.map((item) => ( - - {item.title} - 打开 - - )) :
暂无最近访问。
} -
+ + + + {item.title} + {item.reason} + + {item.meta} + {item.actionLabel} + ); } -function CompactQuickLinks({ items }: { items: PortalHomePayload["quickLinks"] }) { - const safeItems = Array.isArray(items) ? items : []; - +function WorkItemList({ items }: { items: WorkItem[] }) { return ( - - {safeItems.length ? safeItems.slice(0, 6).map((item) => ( - - {item.title} - 进入 - - )) :
暂无常用入口。
} -
+
+ {items.map((item) => ( + + ))} +
); } -function CompactMetricLinks({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) { - return ( - - {cards.length ? cards.slice(0, 6).map((card) => ( - - {card.title} - - {card.value} - - - )) :
暂无统计项。
} -
- ); -} - -function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullable | null }) { - if (!adoptionSummary) { - return null; - } - - return ( -
- -
-
试点观测摘要
-
管理员按需查看。
-
- 展开 / 收起 -
-
-
-
近 7 天登录覆盖率
-
{adoptionSummary.login7dRate}%
-
-
-
周报提交率
-
{adoptionSummary.weeklySubmissionRate}%
-
-
-
团队状态覆盖率
-
{adoptionSummary.presenceCoverageRate}%
-
-
-
当前公告数
-
{adoptionSummary.announcementCount}
-
-
-
-
本周观测提示
-
    - {adoptionSummary.hints.map((hint) => ( -
  • {hint}
  • - ))} -
-
-
- ); -} - -function SecondaryWorkspacePanel({ - title, - description, - children, -}: { - title: string; - description: string; - children: ReactNode; -}) { - return ( -
- -
-
{title}
-
{description}
-
- 展开 / 收起 -
-
- {children} -
-
- ); -} - -function PortalSectionFallback({ - title, - description, -}: { - title: string; - description: string; -}) { - return ( - - - - ); -} - -function QuickActionTile({ - icon, - label, - description, - to, - onClick, - disabled = false, - compact = false, -}: QuickActionConfig & { compact?: boolean }) { - const baseClassName = disabled - ? `portal-action-tile portal-action-tile--disabled${compact ? " portal-action-tile--compact" : ""}` - : `portal-action-tile${compact ? " portal-action-tile--compact" : ""}`; - - const content = ( - <> -
-
- {icon} -
-
-
{label}
-
{description}
-
-
- - ); - - if (to && !disabled) { - return {content}; - } - if (onClick) { - return ( - - ); - } - return
{content}
; -} - -function QuickActionsPanel({ +function TodayActionPanel({ items, - layout = "grid", + emptyActions, }: { - items: QuickActionConfig[]; - layout?: "grid" | "rail"; + items: WorkItem[]; + emptyActions: EmptyAction[]; }) { return ( - -
- {items.length ? items.map((item) => ( - - )) : } -
-
- ); -} - -function TodayTaskPanel({ - primaryAction, - secondaryLinks, - focusItem, - hiddenFocusCount, -}: { - primaryAction: PortalFocusAction | null; - secondaryLinks: PortalFocusLink[]; - focusItem: TodoAggregateItem | null; - hiddenFocusCount: number; -}) { - return ( - -
-
-
-
当前焦点
- {hiddenFocusCount > 0 ?
另有 {hiddenFocusCount} 项
: null} + + {items.length ? ( + + ) : ( +
+
+
今天没有需要你处理的事项。
+
需要发起新申请或回看进度时,从下面的常用动作进入。
- - {focusItem ? ( - -
-
+ {emptyActions.map((action) => ( +
-
-
{focusItem.title}
-
{focusItem.sourceLabel}
-
- arrow_forward -
- - ) : ( -
- 暂无必须立即处理的事项。 -
- )} -
- -
-
下一步
- {primaryAction ? ( - - ) : ( - - )} - - {secondaryLinks.length ? ( -
- {secondaryLinks.map((item) => ( - ))}
) : null}
-
+ )} ); } -function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) { - const safeItems = Array.isArray(items) ? items : []; - const visibleItems = safeItems.slice(0, 5); - const hiddenCount = Math.max(safeItems.length - visibleItems.length, 0); - - return ( - 查看团队页} - className="portal-panel-fill" - > -
- {visibleItems.length ? visibleItems.map((item) => { - const badge = resolveTeamStatusBadge(item); - return ( -
- -
-
{item.name}
-
{formatTeamStatusAttendance(item)}
-
- {badge.label} -
- ); - }) : } -
- {hiddenCount > 0 ? ( -
- 另有 {hiddenCount} 位。 - 查看全部 -
- ) : null} -
- ); -} - -function RecentActivitiesList({ items }: { items: PortalRecentActivity[] }) { - const safeItems = Array.isArray(items) ? items : []; - - return ( -
- {safeItems.length ? safeItems.slice(0, 5).map((item) => ( - -
- -
-
{item.summary}
-
{formatRelativeTime(item.createdAt)}
-
-
- - )) : } -
- ); -} - -function ActivityAnnouncementsPanel({ - activities, - announcements, +function ConditionalWorkPanel({ + title, + items, }: { - activities: PortalRecentActivity[]; - announcements: PortalHomePayload["announcements"]; + title: string; + items: WorkItem[]; }) { + if (!items.length) { + return null; + } + return ( - 公告页} - className="portal-panel-fill" - > - - - 动态 - 公告 - - - - - - - - + + ); } @@ -809,16 +530,6 @@ export function PortalHomePage() { const { me, permissions } = useAuth(); const [showWelcome, setShowWelcome] = useState(false); const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() }); - const workflowSummaryQuery = useQuery({ - enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS), - queryKey: ["workflow", "tasks", "summary", "mine"], - queryFn: () => fetchWorkflowTaskSummary(), - }); - const workflowSystemSummaryQuery = useQuery({ - enabled: hasAnyPermission(permissions, ["system.audit.view"]), - queryKey: ["workflow", "tasks", "summary", "system"], - queryFn: () => fetchWorkflowTaskSummary({ scope: "system" }), - }); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); useEffect(() => { @@ -837,12 +548,6 @@ export function PortalHomePage() { } const payload = portalQuery.data; - const cards = Array.isArray(payload.cards) ? payload.cards : []; - const applicationItems = Array.isArray(payload.myApplications) ? payload.myApplications : []; - const announcementItems = Array.isArray(payload.announcements) ? payload.announcements : []; - const quickLinkItems = Array.isArray(payload.quickLinks) ? payload.quickLinks : []; - const recentActivities = Array.isArray(payload.recentActivities) ? payload.recentActivities : []; - const teamStatusBoard = Array.isArray(payload.teamStatusBoard) ? payload.teamStatusBoard : []; const roleCapabilities = payload.roleCapabilities ?? buildFallbackRoleCapabilities(permissions, me?.roleCodes ?? []); const isManagerView = roleCapabilities.canViewTeam || @@ -850,336 +555,41 @@ export function PortalHomePage() { roleCapabilities.canUseFinance || roleCapabilities.canManageSystem || isManagerRole(permissions); - const workspaceEyebrow = roleCapabilities.canUseFinance - ? "财务工作台" - : roleCapabilities.canManageSystem - ? "管理工作台" - : roleCapabilities.canApproveTasks - ? "审批工作台" - : "个人工作台"; - const portalSchema = isManagerView ? managerPortalSchema : employeePortalSchema; - const primaryCards = pickCards(cards, portalSchema.heroCards); - const primaryCardKeySet = primaryCards.map((card) => card.key); - const secondaryCards = compactCards(cards, primaryCardKeySet); - const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards }); + const todayActionItems = buildTodayActionItems(payload, 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; function handleCloseWelcome() { clearWelcomeDialogFlag(); setShowWelcome(false); } - const employeeSummaryItems = ([ - { - key: "expense", - href: "/reimbursements", - label: "我的报销", - value: `${applicationItems.find((item) => item.domain === "reimbursement")?.count ?? 0} 条`, - }, - { - key: "leave", - href: "/leave-requests", - label: "我的请假", - value: `${applicationItems.find((item) => item.domain === "leave")?.count ?? 0} 条`, - }, - { - key: "assets", - href: "/assets", - label: "我的资产", - value: String(secondaryCards.find((card) => card.key === "my_assets")?.value ?? 0), - }, - payload.todayPresence - ? { - key: "today-status-note", - href: payload.todayPresence.href, - label: "今日状态", - value: payload.todayPresence.label, - note: payload.todayPresence.note ?? "可在团队页更新。", - tone: payload.todayPresence.status === "leave" ? "warning" as const : "normal" as const, - } - : null, - ] as Array).filter(isSummaryItem); - - const managerSummaryItems = ([ - { - key: "team-summary", - href: payload.teamPresence?.href ?? "/team", - label: "今日团队状态", - value: payload.teamPresence ? `在岗 ${payload.teamPresence.office}/${payload.teamPresence.total}` : "待启用", - note: payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态待启用。", - tone: payload.teamPresence && (payload.teamPresence.leave > 0 || payload.teamPresence.unspecified > 0) ? "warning" as const : "normal" as const, - }, - payload.weeklySummary - ? { - key: "weekly-summary", - href: payload.weeklySummary.href, - label: "未提交周报", - value: `${payload.weeklySummary.missingCount} 人`, - note: `需协助 ${payload.weeklySummary.needsHelpCount} 人`, - tone: payload.weeklySummary.missingCount > 0 ? "warning" as const : "normal" as const, - } - : null, - ] as Array).filter(isSummaryItem); - - const approvalSummaryItems = ([ - roleCapabilities.canApproveTasks - ? { - key: "approval-pending", - href: payload.pendingTasks.href, - label: "待我处理", - value: `${payload.pendingTasks.count} 条`, - note: payload.pendingTasks.count > 0 ? "进入待我处理办理。" : "暂无待处理事项。", - tone: payload.pendingTasks.count > 0 ? "warning" as const : "normal" as const, - } - : null, - roleCapabilities.canApproveTasks - ? { - key: "approval-completed", - href: "/workflow/tasks?status=completed", - label: "已处理", - value: "查看", - note: "复核历史处理记录。", - } - : null, - ] as Array).filter(isSummaryItem); - - const systemSummaryItems = ([ - roleCapabilities.canManageSystem - ? { - key: "system-users", - href: "/people", - label: "账号与人员", - value: "管理", - note: "维护账号、角色和组织信息。", - } - : null, - roleCapabilities.canManageSystem - ? { - key: "system-workspace", - href: "/system/workspace", - label: "工作区设置", - value: "配置", - note: "检查入口、通知和基础配置。", - } - : null, - roleCapabilities.canManageSystem - ? { - key: "system-audit", - href: "/audit-logs", - label: "审计日志", - value: "查看", - note: "查询关键操作留痕。", - } - : null, - ] as Array).filter(isSummaryItem); - - const quickActions: QuickActionConfig[] = []; - if (roleCapabilities.canApproveTasks) { - quickActions.push({ - key: "approval", - icon: "fact_check", - label: "处理审批", - description: "待办处理", - to: "/workflow/tasks", - }); - } - if (roleCapabilities.canUseFinance && payload.financeSummary) { - quickActions.push({ - key: "finance", - icon: "payments", - label: "财务处理", - description: "付款 / 风险", - to: payload.financeSummary.href, - }); - } - if (roleCapabilities.canManageSystem) { - quickActions.push({ - key: "system", - icon: "admin_panel_settings", - label: "系统管理", - description: "账号 / 配置", - to: "/people", - }); - } - if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) { - quickActions.push({ - key: "leave", - icon: "event_available", - label: "提交请假", - description: "请假申请", - to: "/leave-requests?action=create", - }); - } - if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) { - quickActions.push({ - key: "expense", - icon: "request_quote", - label: "提交报销", - description: "报销单据", - to: "/reimbursements", - }); - } - quickActions.push({ - key: "attendance", - icon: "schedule", - label: "考勤", - description: "企微摘要", - to: "/attendance", - }); - if (payload.weeklyReport || payload.weeklySummary) { - quickActions.push({ - key: "weekly", - icon: "article", - label: "提交周报", - description: "填写 / 缺口", - to: "/weekly-reports", - }); - } - - const approvalAction = quickActions.find((item) => item.key === "approval"); - const expenseAction = quickActions.find((item) => item.key === "expense"); - const fallbackAction = quickActions.find((item): item is QuickActionConfig & { to: string } => Boolean(item.to)); - const primaryAction: PortalFocusAction | null = - payload.pendingTasks.count > 0 && approvalAction?.to - ? { label: "处理待办", to: approvalAction.to } - : expenseAction?.to - ? { label: "新建报销", to: expenseAction.to } - : fallbackAction - ? { label: fallbackAction.label, to: fallbackAction.to } - : null; - const secondaryLinkCandidates: Array = [ - hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS) - ? { label: "报销进度", to: "/reimbursements" } - : null, - payload.pendingTasks.count > 0 && approvalAction?.to - ? { label: "待我处理", to: approvalAction.to } - : null, - todoAggregateItems.length > 1 - ? { label: "全部待办", to: "/todos" } - : null, - ]; - const secondaryLinks = secondaryLinkCandidates - .filter((item): item is PortalFocusLink => item !== null) - .filter((item) => item.to !== primaryAction?.to) - .slice(0, 2); - return ( - - 完成工作区设置 - - ) : null - } + - + - -
- - )} - > - - - - )} - > - - -
-
- - {isManagerView ? ( - <> - -
- {primaryCards.length ? ( - - ) : null} - - {roleCapabilities.canApproveTasks ? ( - - ) : null} - {roleCapabilities.canApproveTasks ? ( - - ) : null} - {roleCapabilities.canManageSystem ? ( - - ) : null} - {roleCapabilities.canUseFinance ? ( - - ) : null} - {roleCapabilities.canManageSystem ? ( - - ) : null} - {roleCapabilities.canViewTeam ? ( - - ) : null} - -
- - -
- - - - ) : ( - -
- - - - - -
-
- )} +
+ + + +
); } diff --git a/client/src/pages/ReimbursementsPage.tsx b/client/src/pages/ReimbursementsPage.tsx index c8ace230..c58eac7c 100644 --- a/client/src/pages/ReimbursementsPage.tsx +++ b/client/src/pages/ReimbursementsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchMyDelegations } from "../api/delegations"; @@ -74,6 +75,40 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s { value: "cancelled", label: "已取消" }, ]; +const REIMBURSEMENT_TASK_STATUS_OPTIONS = [ + { + value: "rejected", + title: "被驳回待补正", + idleNote: "暂无需要补正的报销", + activeNote: "优先补齐说明或附件后重新提交", + icon: RotateCcw, + accentClass: "border-red-200 bg-red-50 text-red-700", + }, + { + value: "draft", + title: "草稿待完善", + idleNote: "还没有报销草稿", + activeNote: "继续完善明细、发票和附件", + icon: FilePenLine, + accentClass: "border-amber-200 bg-amber-50 text-amber-700", + }, + { + value: "reviewing", + title: "审批中进度", + idleNote: "暂无审批中的报销", + activeNote: "查看当前节点和处理进度", + icon: Clock3, + accentClass: "border-sky-200 bg-sky-50 text-sky-700", + }, +] satisfies Array<{ + value: ReimbursementStatus; + title: string; + idleNote: string; + activeNote: string; + icon: typeof RotateCcw; + accentClass: string; +}>; + function formatCurrency(value: number) { return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value); } @@ -101,6 +136,56 @@ function formatBatchOutcome(action: BatchAction, outcome: string) { return action === "approve" ? "已通过" : "已驳回"; } +function resolveEmptyStateCopy(status: ReimbursementStatus) { + if (status === "draft") { + return { + title: "还没有报销草稿", + description: "需要报销时先新建单据,再补充明细、发票和附件。", + }; + } + if (status === "rejected") { + return { + title: "暂无需要补正的报销", + description: "后续有驳回单据时,会在这里继续编辑并重新提交。", + }; + } + if (status === "reviewing") { + return { + title: "暂无审批中的报销", + description: "提交后的报销会在这里显示当前审批进度。", + }; + } + if (status === "approved") { + return { + title: "暂无已通过记录", + description: "审批通过但未支付的报销会在这里留存。", + }; + } + if (status === "paid") { + return { + title: "暂无已支付记录", + description: "财务支付完成后会在这里查看付款结果。", + }; + } + return { + title: "暂无已取消记录", + description: "撤回或取消的报销记录会在这里留存。", + }; +} + +function resolveWorkflowHint(item: ReimbursementListItem) { + const stepName = item.pendingWorkflow?.task?.stepName; + if (!stepName) { + return null; + } + + const actionableByCurrentUser = item.pendingWorkflow?.allowedActions.length ? true : false; + return { + label: `${actionableByCurrentUser ? "待你处理" : "当前节点"}:${stepName}`, + className: actionableByCurrentUser ? "text-amber-700" : "text-zinc-500", + }; +} + export function ReimbursementsPage() { const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); @@ -126,14 +211,37 @@ export function ReimbursementsPage() { const canViewAll = hasPermission(permissions, "expense.view_all"); const canApproveExpense = hasPermission(permissions, "expense.approve"); const canProxySubmit = hasPermission(permissions, "expense.proxy_submit"); + const canUseFinanceTools = + hasPermission(permissions, "expense.finance.pay") || + hasPermission(permissions, "payment.batch.manage") || + hasPermission(permissions, "risk.review.manage") || + hasPermission(permissions, "invoice.manage"); const isAdministration = (me?.roleCodes ?? []).includes("administration"); const showProxyOwnerSelector = canProxySubmit && isAdministration; + const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools; + const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector); + const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense; const listQuery = useQuery({ queryKey: ["reimbursements", "list", status, page, scope], queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }), }); + const rejectedSummaryQuery = useQuery({ + queryKey: ["reimbursements", "task-summary", "own", "rejected"], + queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }), + }); + + const draftSummaryQuery = useQuery({ + queryKey: ["reimbursements", "task-summary", "own", "draft"], + queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }), + }); + + const reviewingSummaryQuery = useQuery({ + queryKey: ["reimbursements", "task-summary", "own", "reviewing"], + queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }), + }); + const proxyDelegationsQuery = useQuery({ enabled: showProxyOwnerSelector, queryKey: ["delegations", "mine", "active"], @@ -141,13 +249,13 @@ export function ReimbursementsPage() { }); const templateOptionsQuery = useQuery({ - enabled: canCreateExpense, + enabled: shouldLoadCreateOptions, queryKey: ["reimbursement-templates", "enabled", "create-menu"], queryFn: () => fetchReimbursementTemplates({ enabled: true }), }); const linkableTravelRequestsQuery = useQuery({ - enabled: canCreateExpense, + enabled: shouldLoadCreateOptions, queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"], queryFn: () => fetchLinkableTravelRequests({ @@ -187,10 +295,17 @@ export function ReimbursementsPage() { ); const canShowBatchEntry = status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0; - const showAdvancedTools = showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector; const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id)); const allActionableSelected = actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id)); + const taskSummaryTotalByStatus = { + rejected: rejectedSummaryQuery.data?.total, + draft: draftSummaryQuery.data?.total, + reviewing: reviewingSummaryQuery.data?.total, + } satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>; + const canShowCreateOptionsMenu = + canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0); + const emptyState = resolveEmptyStateCopy(status); useEffect(() => { setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id))); @@ -399,18 +514,6 @@ export function ReimbursementsPage() {

) : null} - {templateOptionsQuery.isError ? ( -

- {resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")} -

- ) : null} - - {linkableTravelRequestsQuery.isError ? ( -

- {resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")} -

- ) : null} - {exportError ? (

{exportError} @@ -423,25 +526,65 @@ export function ReimbursementsPage() {

) : null} +
+ {REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => { + const Icon = item.icon; + const count = taskSummaryTotalByStatus[item.value]; + const active = status === item.value && scope === "own"; + const displayCount = typeof count === "number" ? count : "-"; + + return ( + + ); + })} +
+
- setSearchParams({ status: value, page: "1", scope })} - > - - {REIMBURSEMENT_STATUS_OPTIONS.map((item) => ( - - {item.label} - - ))} - - +
+
报销记录
+ setSearchParams({ status: value, page: "1", scope })} + > + + {REIMBURSEMENT_STATUS_OPTIONS.map((item) => ( + + {item.label} + + ))} + + +
{canApproveExpense ? ( @@ -455,14 +598,17 @@ export function ReimbursementsPage() { 待我处理 ) : null} - + {canShowLedgerEntry ? ( + + ) : null}
@@ -506,7 +652,7 @@ export function ReimbursementsPage() { ) : null} - {canCreateExpense ? ( + {canShowCreateOptionsMenu ? (
- {item.pendingWorkflow?.task?.id ? ( -
- 待办:{item.pendingWorkflow.task.stepName} + {workflowHint ? ( +
+ {workflowHint.label}
) : null} @@ -750,7 +893,19 @@ export function ReimbursementsPage() { }) ) : (
- 暂无数据 +
{emptyState.title}
+
{emptyState.description}
+ {status === "draft" && canCreateExpense ? ( + + ) : null}
)}
@@ -776,6 +931,7 @@ export function ReimbursementsPage() { ); const displayCode = resolveReimbursementDisplayCode(item); const canDeleteDraft = item.status === "draft" && canDeleteExpense; + const workflowHint = resolveWorkflowHint(item); return ( @@ -800,9 +956,9 @@ export function ReimbursementsPage() { 项目:{item.project.projectNo} · {item.project.name}
) : null} - {item.pendingWorkflow?.task?.id ? ( -
- 待办:{item.pendingWorkflow.task.stepName} + {workflowHint ? ( +
+ {workflowHint.label}
) : null} @@ -869,7 +1025,21 @@ export function ReimbursementsPage() { ) : ( - 暂无数据 +
+
{emptyState.title}
+
{emptyState.description}
+ {status === "draft" && canCreateExpense ? ( + + ) : null} +
)} @@ -983,7 +1153,6 @@ export function ReimbursementsPage() {
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
-
taskId: {item.taskId}
{formatBatchOutcome(batchResult.action, item.outcome)} diff --git a/client/tests/e2e/announcements-presence.spec.ts b/client/tests/e2e/announcements-presence.spec.ts index 7ac801b8..16fbc1fd 100644 --- a/client/tests/e2e/announcements-presence.spec.ts +++ b/client/tests/e2e/announcements-presence.spec.ts @@ -36,11 +36,9 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇 await page.getByRole("button", { name: "保存状态" }).click(); await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); await page.goto("/portal"); - await page.locator("summary").filter({ hasText: "团队与通知" }).click(); - await page.getByRole("tab", { name: "公告" }).click(); - await expect(page.getByText(announcementTitle)).toBeVisible(); - await page.locator("summary").filter({ hasText: "更多入口" }).click(); - await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); + await expect(page.getByText(announcementTitle)).not.toBeVisible(); await logout(page, EMPLOYEE.name); @@ -51,8 +49,8 @@ test("E2E 公告与团队状态:公告可见 + 今日状态更新 + Portal 汇 await expect(page.getByRole("button", { name: "不在岗" })).toBeVisible(); await page.goto("/portal"); - await page.getByRole("tab", { name: "公告" }).click(); - await expect(page.getByText(announcementTitle)).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByText(announcementTitle)).not.toBeVisible(); await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger)).toBeVisible(); } finally { await adminClient.dispose(); diff --git a/client/tests/e2e/helpers/smokeAssertions.ts b/client/tests/e2e/helpers/smokeAssertions.ts index e7c85289..c93cbb77 100644 --- a/client/tests/e2e/helpers/smokeAssertions.ts +++ b/client/tests/e2e/helpers/smokeAssertions.ts @@ -24,7 +24,7 @@ type Envelope = { export async function assertPortalLoaded(page: Page) { await expect(page).toHaveURL(/\/portal/); await expect(page.getByRole("link", { name: "工作台", exact: true }).first()).toBeVisible(); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); } export async function assertForbiddenPage(page: Page) { diff --git a/client/tests/e2e/login-portal-ux.spec.ts b/client/tests/e2e/login-portal-ux.spec.ts index d9f66cb7..d2716bbc 100644 --- a/client/tests/e2e/login-portal-ux.spec.ts +++ b/client/tests/e2e/login-portal-ux.spec.ts @@ -508,30 +508,17 @@ test("Portal 员工视角优先显示今日动作顺序", async ({ page }) => { await login(page, EMPLOYEE); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.getByText("当前焦点")).toBeVisible(); - await expect(page.getByText("下一步")).toBeVisible(); - await expect(page.getByRole("link", { name: "新建报销" })).toBeVisible(); - - await page.locator("summary").filter({ hasText: "更多入口" }).click(); - const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) => - nodes.map((node) => node.getAttribute("aria-label")), - ); - expect(labels.slice(0, 4)).toEqual([ - "portal-card-my_todos", - "portal-card-today_presence", - "portal-card-my_weekly_report", - "portal-card-my_projects", - ]); - - await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); - await page.locator("summary").filter({ hasText: "团队与通知" }).click(); - await expect(page.getByRole("tab", { name: "公告" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "团队状态" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "通知" })).toBeVisible(); + await expect(page.getByTestId("portal-work-summary")).toContainText("今天有 4 件待处理"); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByRole("link", { name: /待办事项 4 条.*查看待办/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /本周周报待填写.*填写周报/ })).toBeVisible(); + await expect(page.getByText("当前焦点")).not.toBeVisible(); + await expect(page.getByText("下一步", { exact: true })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "更多入口" })).not.toBeVisible(); }); -test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page }) => { +test("Portal 管理视角按角色分发待处理和团队卡点", async ({ page }) => { await mockPasswordLogin(page); await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); @@ -541,26 +528,13 @@ test("Portal 管理视角重排正确,更多入口默认折叠", async ({ page await login(page, MANAGER); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible(); - - await page.locator("summary").filter({ hasText: "角色重点" }).click(); - const labels = await page.locator('[aria-label^="portal-card-"]').evaluateAll((nodes) => - nodes.map((node) => node.getAttribute("aria-label")), - ); - expect(labels.slice(0, 4)).toEqual([ - "portal-card-pending_approvals", - "portal-card-team_presence", - "portal-card-weekly_missing", - "portal-card-weekly_help", - ]); - - await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); - await expect(page.getByText("试点观测摘要")).toBeVisible(); - await expect(page.getByRole("link", { name: "采购申请" })).toBeVisible(); - await expect(page.getByRole("link", { name: "差旅申请" })).not.toBeVisible(); - await page.locator("summary").filter({ hasText: "更多" }).click(); - await expect(page.getByRole("link", { name: "差旅申请" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByRole("link", { name: /待我处理 3 条.*去处理/ })).toBeVisible(); + await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible(); + await expect(page.getByRole("link", { name: /项目风险未关闭.*处理风险/ })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible(); + await expect(page.getByText("试点观测摘要")).not.toBeVisible(); }); test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清单", async ({ page }) => { @@ -598,7 +572,7 @@ test("Portal 新员工只显示轻量欢迎入口,不再显示快速上手清 await expect(page.getByText("完善个人资料")).not.toBeVisible(); await expect(page.getByText("了解请假流程")).not.toBeVisible(); await expect(page.getByText("查看通知中心")).not.toBeVisible(); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); }); test("个人设置默认只展示账号通知和企业微信,不展示权限 code", async ({ page }) => { @@ -668,11 +642,11 @@ test("Portal bootstrap refresh 首次瞬时失败后重试成功仍保留登录 await page.goto("/portal"); await expect(page).toHaveURL(/\/portal/); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); expect(refreshAttempts).toBeGreaterThanOrEqual(2); }); -test("Portal 域管理角色也进入经理视图", async ({ page }) => { +test("Portal 域管理角色也能看到团队卡点", async ({ page }) => { await mockPasswordLogin(page); await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); @@ -682,14 +656,12 @@ test("Portal 域管理角色也进入经理视图", async ({ page }) => { await login(page, EMPLOYEE); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); - await page.locator("summary").filter({ hasText: "角色重点" }).click(); - await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible(); - await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "团队卡点" })).toBeVisible(); + await expect(page.getByRole("link", { name: /未交周报/ })).toBeVisible(); }); -test("Portal 系统摘要审计日志入口指向实际路由", async ({ page }) => { +test("Portal 管理提醒正常时不显示空壳", async ({ page }) => { await mockPasswordLogin(page); await mockNavigation(page, "manager"); await mockPortalHome(page, "manager"); @@ -699,9 +671,9 @@ test("Portal 系统摘要审计日志入口指向实际路由", async ({ page }) await login(page, ADMIN); - await page.locator("summary").filter({ hasText: "角色重点" }).click(); - await expect(page.getByRole("heading", { name: "系统管理" })).toBeVisible(); - await expect(page.getByRole("link", { name: /审计日志.*查看/ })).toHaveAttribute("href", "/audit-logs"); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "管理提醒" })).not.toBeVisible(); + await expect(page.getByText("管理员按需查看")).not.toBeVisible(); }); test("企微 WebView 下默认进入移动工作台", async ({ page }) => { diff --git a/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts b/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts index 490b40a8..462ff3ad 100644 --- a/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts +++ b/client/tests/e2e/manual/r70-trialrun-deep-assist.spec.ts @@ -194,11 +194,9 @@ test("R70 深层自动辅助试运行:周报待办、公告团队状态、真 await expect(page.getByText("今日状态已更新")).toBeVisible({ timeout: 30_000 }); await page.goto("/portal"); - await page.locator("summary").filter({ hasText: "团队与通知" }).click(); - await page.getByRole("tab", { name: "公告" }).click(); - await expect(page.getByText(announcementTitle)).toBeVisible({ timeout: 30_000 }); - await page.locator("summary").filter({ hasText: "更多入口" }).click(); - await expect(page.locator('[aria-label="portal-card-today_presence"]')).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); + await expect(page.getByText(announcementTitle)).not.toBeVisible(); return announcementTitle; }); diff --git a/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts b/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts index d4dc7678..f8444f85 100644 --- a/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts +++ b/client/tests/e2e/pc-core-workflow-visual-wave2.spec.ts @@ -316,8 +316,8 @@ test("PC 核心工作流 Wave2 视觉截图", async ({ page }) => { await page.getByTestId("login-password").fill(visualUser.password); await page.getByTestId("login-submit").click(); await expect(page).toHaveURL(/\/portal/); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.getByRole("link", { name: "处理待办" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByRole("link", { name: /待我处理.*去处理/ })).toBeVisible(); await capture(page, "02-portal"); await page.goto("/reimbursements?status=reviewing&scope=all"); diff --git a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts index aea36982..61dc6a81 100644 --- a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts +++ b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts @@ -320,6 +320,8 @@ test.describe("R102 reimbursement main flow", () => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto("/reimbursements?status=draft"); + await expect(page.getByTestId("reimbursements-task-card-draft")).toBeVisible(); + await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0); await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible(); await expect(page.getByText(reimbursementId)).toHaveCount(0); @@ -328,7 +330,7 @@ test.describe("R102 reimbursement main flow", () => { await expect(page.getByTestId("action-confirm-dialog")).toContainText(formNumber); await page.getByTestId("action-confirm-submit").click(); await expect(page.getByTestId("reimbursement-row")).toHaveCount(0); - await expect(page.getByRole("cell", { name: "暂无数据" })).toBeVisible(); + await expect(page.getByTestId("reimbursements-table").getByText("还没有报销草稿")).toBeVisible(); }); test("详情页发票按当前简化流程添加并隐藏原始码值", async ({ page }) => { diff --git a/client/tests/e2e/r105-pretrial-visual.spec.ts b/client/tests/e2e/r105-pretrial-visual.spec.ts index 87062669..53c74f12 100644 --- a/client/tests/e2e/r105-pretrial-visual.spec.ts +++ b/client/tests/e2e/r105-pretrial-visual.spec.ts @@ -120,7 +120,7 @@ test.describe("R105 pretrial visual acceptance", () => { await loginLiveUser(page, "employee"); await page.goto("/portal"); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); await expectNoLegacyDesignCopy(page); await page.screenshot({ path: screenshotPath("desktop-portal.png"), fullPage: true }); diff --git a/client/tests/e2e/system-action-confirmation.spec.ts b/client/tests/e2e/system-action-confirmation.spec.ts index 04e281d9..4c6c2011 100644 --- a/client/tests/e2e/system-action-confirmation.spec.ts +++ b/client/tests/e2e/system-action-confirmation.spec.ts @@ -290,7 +290,7 @@ test.describe("R7E 全站高风险操作二次确认", () => { await page.getByTestId("action-confirm-submit").click(); await expect.poll(() => calls.deleteReimbursement).toBe(1); - await page.getByRole("button", { name: "高级 / 台账" }).click(); + await page.getByRole("button", { name: "台账工具" }).click(); await page.getByTestId("reimbursements-export").click(); await page.getByTestId("reimbursements-export-csv").click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息"); diff --git a/client/tests/e2e/visual-portal.spec.ts b/client/tests/e2e/visual-portal.spec.ts index ee6b382d..78e1e18c 100644 --- a/client/tests/e2e/visual-portal.spec.ts +++ b/client/tests/e2e/visual-portal.spec.ts @@ -1,58 +1,226 @@ +import { mkdirSync } from "node:fs"; +import path from "node:path"; import { expect, test, type Page, type TestInfo } from "@playwright/test"; -import { EMPLOYEE, FINANCE, login, logout, type E2EUser } from "./helpers/auth"; +import { EMPLOYEE, FINANCE, login, type E2EUser } from "./helpers/auth"; const ADMIN: E2EUser = { identifier: "admin@hyinda.com", password: "AdminPassword1234", name: "Admin", }; +const SCREENSHOT_DIR = path.resolve(process.cwd(), "../output/playwright/portal-task-first-redesign"); async function attachScreenshot(page: Page, testInfo: TestInfo, name: string) { + mkdirSync(SCREENSHOT_DIR, { recursive: true }); + const body = await page.screenshot({ path: path.join(SCREENSHOT_DIR, name), fullPage: true }); await testInfo.attach(name, { - body: await page.screenshot({ fullPage: true }), + body, contentType: "image/png", }); } +function envelope(data: T) { + return { requestId: "visual-portal", data }; +} + +function portalPayload(mode: "employee" | "admin" | "finance") { + const base = { + workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" }, + greeting: { name: mode === "employee" ? "Employee" : mode === "finance" ? "Finance" : "Admin" }, + myApplications: [{ domain: "reimbursement", count: 1, href: "/reimbursements" }], + riskSummary: null, + announcements: [], + quickLinks: [], + todayPresence: null, + weeklyReport: null, + recentActivities: [], + teamStatusBoard: [], + }; + + if (mode === "finance") { + return { + ...base, + cards: [], + pendingTasks: { count: 1, href: "/workflow/tasks" }, + teamPresence: null, + weeklySummary: null, + todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" }, + projectSummary: null, + peopleLifecycle: null, + roleCapabilities: { + canSubmitApplications: true, + canApproveTasks: true, + canUseFinance: true, + canManageSystem: false, + canViewTeam: true, + isAdminOnly: false, + }, + financeSummary: { pendingBatchCount: 2, failedItemCount: 1, riskCount: 1, paymentBatchEnabled: true, href: "/ap-operations" }, + adoptionSummary: null, + }; + } + + if (mode === "admin") { + return { + ...base, + cards: [], + pendingTasks: { count: 0, href: "/workflow/tasks" }, + teamPresence: { date: "2026-05-29", href: "/team", office: 6, remote: 1, out: 0, travel: 0, leave: 0, unspecified: 1, total: 8 }, + weeklySummary: { weekStart: "2026-05-25", submittedCount: 6, missingCount: 2, needsHelpCount: 0, href: "/weekly-reports" }, + todoSummary: { openCount: 0, highPriorityCount: 0, href: "/todos" }, + projectSummary: { myCount: 3, overdueMilestonesCount: 1, highPriorityTaskCount: 0, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" }, + peopleLifecycle: { onboardingCount: 0, offboardingCount: 0, assetReturnPendingCount: 0, href: "/people" }, + roleCapabilities: { + canSubmitApplications: false, + canApproveTasks: false, + canUseFinance: false, + canManageSystem: true, + canViewTeam: true, + isAdminOnly: true, + }, + financeSummary: null, + adoptionSummary: { activeEmployeeCount: 8, login7dCount: 8, weeklySubmittedCount: 6, weeklyMissingCount: 2, presenceUpdatedCount: 7, presenceMissingCount: 1, announcementCount: 0, login7dRate: 100, weeklySubmissionRate: 75, presenceCoverageRate: 87.5, hints: ["本周仍有 2 人未提交周报"] }, + }; + } + + return { + ...base, + cards: [], + pendingTasks: { count: 0, href: "/workflow/tasks" }, + teamPresence: null, + weeklySummary: null, + todoSummary: { openCount: 2, highPriorityCount: 1, href: "/todos" }, + projectSummary: { myCount: 2, overdueMilestonesCount: 0, highPriorityTaskCount: 1, openRiskCount: 0, followUpDueTodayCount: 0, closurePendingProjectCount: 0, href: "/projects" }, + peopleLifecycle: null, + roleCapabilities: { + canSubmitApplications: true, + canApproveTasks: false, + canUseFinance: false, + canManageSystem: false, + canViewTeam: false, + isAdminOnly: false, + }, + financeSummary: null, + adoptionSummary: null, + weeklyReport: { weekStart: "2026-05-25", status: "draft", needsHelp: false, href: "/weekly-reports" }, + }; +} + +async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance") { + await page.addInitScript(() => { + window.localStorage.removeItem("hyb_oa_show_welcome"); + window.localStorage.removeItem("hyb_oa_onboarding_active"); + window.localStorage.removeItem("hyb_oa_getting_started_dismissed"); + }); + + await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { + const pathName = new URL(route.request().url()).pathname; + + if (pathName === "/api/auth/capabilities") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ emailDelivery: { ready: true, statusReasons: [] }, emailLoginCode: { enabled: true }, passwordReset: { enabled: true } })) }); + return; + } + if (pathName === "/api/auth/sso/providers") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope([])) }); + return; + } + if (pathName === "/api/auth/wecom/config") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ enabled: false, entryEnabled: false, oauthEnabled: false, corpId: "", agentId: "", loginUrl: null, bindEnabled: false, defaultReturnTo: "/portal", status: "disabled", statusReasons: [] })) }); + return; + } + if (pathName === "/api/auth/login") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) }); + return; + } + if (pathName === "/api/me") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + id: `visual-${mode}`, + employeeNo: "VISUAL", + name: mode === "employee" ? "Employee" : mode === "finance" ? "Finance" : "Admin", + email: `${mode}@hyinda.com`, + roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"], + notificationPushEnabled: true, + profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 }, + wecomBinding: null, + })), + }); + return; + } + if (pathName === "/api/system/navigation") { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(envelope({ + workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" }, + 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 }, + ], + admin: [], + backup: [], + }, + recommendedRolePresets: [], + })), + }); + return; + } + if (pathName === "/api/notifications") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ page: 1, pageSize: 1, total: 0, unreadOnly: true, unreadCount: 0, items: [] })) }); + return; + } + if (pathName === "/api/portal/home") { + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope(portalPayload(mode))) }); + return; + } + + await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({})) }); + }); +} + test.describe("visual portal", () => { test("员工 Portal 视觉基线", async ({ page }, testInfo) => { + await mockVisualPortal(page, "employee"); await page.setViewportSize({ width: 1440, height: 1200 }); await login(page, EMPLOYEE); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.getByText("当前焦点")).toBeVisible(); - await expect(page.getByText("下一步")).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "更多入口" })).toBeVisible(); + await expect(page.getByTestId("portal-work-summary")).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByText("当前焦点")).not.toBeVisible(); + await expect(page.getByText("下一步", { exact: true })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); await attachScreenshot(page, testInfo, "portal-employee.png"); }); test("管理员 Portal 视觉基线", async ({ page }, testInfo) => { + await mockVisualPortal(page, "admin"); await page.setViewportSize({ width: 1440, height: 1200 }); await login(page, ADMIN); await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.getByText("下一步")).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); - await expect(page.getByText("试点观测摘要")).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByText("下一步", { exact: true })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible(); + await expect(page.getByText("试点观测摘要")).not.toBeVisible(); await attachScreenshot(page, testInfo, "portal-admin.png"); - await logout(page, ADMIN.name); }); test("财务 Portal 显示财务待处理入口", async ({ page }, testInfo) => { + await mockVisualPortal(page, "finance"); await page.setViewportSize({ width: 1440, height: 1200 }); await login(page, FINANCE); - await expect(page.getByText("财务工作台")).toBeVisible(); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "角色重点" })).toBeVisible(); + await expect(page.getByTestId("portal-work-summary")).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "角色重点" })).not.toBeVisible(); await attachScreenshot(page, testInfo, "portal-finance.png"); - await logout(page, FINANCE.name); }); }); diff --git a/client/tests/e2e/visual-shell.spec.ts b/client/tests/e2e/visual-shell.spec.ts index f84ebf7b..f37ee7b9 100644 --- a/client/tests/e2e/visual-shell.spec.ts +++ b/client/tests/e2e/visual-shell.spec.ts @@ -333,16 +333,16 @@ test.describe("visual shell", () => { await page.setViewportSize({ width: 1440, height: 1024 }); await loginVisualUser(page); - await expect(page.getByRole("heading", { name: "今日任务" })).toBeVisible(); - await expect(page.getByText("下一步")).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: "团队与通知" })).toBeVisible(); - await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).toBeVisible(); + await expect(page.getByRole("heading", { name: "今日待处理" })).toBeVisible(); + await expect(page.getByText("下一步", { exact: true })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: "团队与通知" })).not.toBeVisible(); + await expect(page.locator("summary").filter({ hasText: /角色重点|更多入口/ })).not.toBeVisible(); await attachScreenshot(page, testInfo, "pc-portal-wave1.png"); await page.goto("/reimbursements"); await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible(); await expect(page.getByTestId("reimbursements-create")).toBeVisible(); - await expect(page.getByRole("button", { name: "高级 / 台账" })).toBeVisible(); + await expect(page.getByRole("button", { name: "台账工具" })).toBeVisible(); await expect(page.getByTestId("reimbursements-export")).not.toBeVisible(); await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible(); await expect(page.getByTestId("reimbursements-table")).toBeVisible(); diff --git a/docs/产品设计规则.md b/docs/产品设计规则.md index 965bb75a..793c303a 100644 --- a/docs/产品设计规则.md +++ b/docs/产品设计规则.md @@ -46,6 +46,9 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台 - 同一业务对象在同一区域只展示一次主标识;同一状态在同一区域只展示一次。 - 通知中心是事件收件箱,不是待办列表或进度页副本;通知点击应直达业务详情、审批任务或可解释的失败提示。 - 首页只做工作台摘要,不重复完整待办列表、通知列表和进度列表。 +- 首页首屏优先展示 `今日待处理`:覆盖所有需要当前用户行动的事项,例如待审批、待补材料、待付款/待复核、票据异常、配置缺口和明确分配给当前用户的团队动作;普通通知、已完成进度、只读动态和低频管理入口不算待处理。 +- 首页不使用常驻欢迎大卡片、说明型折叠区或入口集合。首屏应使用紧凑状态摘要、可行动事项列表和条件出现的 `团队卡点` / `管理提醒` / `我的进度`,没有真实数据的区域不显示。 +- `报销进度`、团队公告、试运行观测等不作为首页固定按钮或固定模块;只有存在可靠状态、真实阻塞或异常时才出现。 - 首页不承担常驻新手教学职责;首次欢迎提示只做一次性、可关闭的常用业务入口,最多 3 个,不放个人设置、通知中心或“了解流程”这类说明型入口。 - 审批入口同时服务审批人和发起人:审批人处理待我审批,员工查看我发起的进度。 - 普通员工不应看到不可点或无权限的审批入口;审批、财务、负责人和管理员只能看到服务端允许的动作。