import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { clockInAttendance, clockOutAttendance, fetchMyTodayAttendance, type AttendanceTodayResponse, } from "../api/attendance"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchPortalHome, type PortalHomePayload, type PortalRecentActivity, type PortalTeamStatusItem, } 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 { employeePortalSchema, managerPortalSchema } from "../features/portal/schema"; import { clearWelcomeDialogFlag, dismissGettingStartedCard, shouldShowGettingStartedCard, shouldShowWelcomeDialog, } from "../lib/onboardingGuide"; import { readRecentVisits } from "../lib/recentVisits"; type SummaryItem = { key: string; href: string; label: string; value: string; note?: string; tone?: "normal" | "warning"; }; type TodoAggregateItem = { title: string; sourceLabel: string; severity: "normal" | "warning" | "high"; href: string; icon: string; }; type QuickActionConfig = { key: string; icon: string; label: string; description: string; to?: string; onClick?: () => void; disabled?: boolean; }; type GettingStartedChecklistItem = { key: string; title: string; description: string; href: string; completed: boolean; }; 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 TODO_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 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 compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[]) { return cards.filter((card) => !excludedKeys.includes(card.key)); } function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) { if (!payload) return "未启用"; return payload.status === "submitted" ? "已提交" : "待填写"; } function resolveErrorMessage(error: unknown) { if (isApiError(error)) { return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; } if (error instanceof Error) { return error.message; } return "请求失败,请稍后重试。"; } function isAttendanceFeatureDisabled(error: unknown) { return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled"; } function formatRelativeTime(value: string | null) { if (!value) { return "-"; } 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 formatShortTime(value: string | null) { if (!value) { return "--:--"; } try { return new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false, }); } catch { return value; } } function formatWorkMinutes(value: number | null | undefined) { if (value == null) { return "-"; } const hours = Math.floor(value / 60); const minutes = value % 60; return `${hours}h ${String(minutes).padStart(2, "0")}m`; } function buildTodoAggregateItems(payload: PortalHomePayload) { const cards = Array.isArray(payload.cards) ? payload.cards : []; const items: TodoAggregateItem[] = []; if (payload.pendingTasks.count > 0) { items.push({ title: `审批待办 ${payload.pendingTasks.count} 条`, sourceLabel: "审批", severity: "warning", href: payload.pendingTasks.href, icon: "fact_check", }); } if ((payload.todoSummary?.openCount ?? 0) > 0) { items.push({ title: `TODO 待办 ${payload.todoSummary?.openCount ?? 0} 条`, sourceLabel: "TODO", severity: (payload.todoSummary?.highPriorityCount ?? 0) > 0 ? "high" : "normal", href: payload.todoSummary?.href ?? "/todos", icon: "check_circle", }); } for (const card of cards) { if (!card.severity || (card.severity !== "warning" && card.severity !== "high")) { continue; } items.push({ title: `${card.title}:${card.value}`, sourceLabel: "工作台", severity: card.severity, href: card.href, icon: card.severity === "high" ? "priority_high" : "warning", }); } const deduped = new Map(); for (const item of items) { deduped.set(`${item.title}|${item.href}|${item.sourceLabel}`, item); } 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); } function resolveTeamStatusBadge(item: PortalTeamStatusItem) { if (item.onLeave) { return { label: item.leaveType ? `请假 · ${item.leaveType}` : "请假", className: "badge-warning", }; } 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" }; } if (!item.attendanceClockIn) { 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", }; } function formatTeamStatusAttendance(item: PortalTeamStatusItem) { if (item.onLeave) { return item.leaveType ? `${item.leaveType} 已审批` : "今日请假"; } if (item.attendanceClockIn && item.attendanceClockOut) { return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`; } if (item.attendanceClockIn) { return `${formatShortTime(item.attendanceClockIn)} 已打卡`; } if (item.presenceStatus === "travel") { return "今日差旅"; } if (item.presenceStatus === "out") { return "当前外出"; } if (item.presenceStatus === "remote") { return "今日居家办公"; } return "暂无打卡记录"; } function resolveClockAction( attendanceToday: AttendanceTodayResponse | null, state: { isLoading: boolean; isError: boolean; featureDisabled: boolean; pending: boolean; }, ) { if (state.pending) { return { label: "处理中...", description: "正在提交考勤动作", disabled: true, action: null, } as const; } if (state.isLoading) { return { label: "打卡读取中", description: "正在读取今日考勤状态", disabled: true, action: null, } as const; } if (state.isError && !state.featureDisabled) { return { label: "打卡不可用", description: "当前无法读取今日考勤状态", disabled: true, action: null, } as const; } if (!attendanceToday) { return { label: "今日考勤", description: "暂无可用考勤数据", disabled: true, action: null, } as const; } if (!attendanceToday.needClockIn) { return { label: "今日无需打卡", description: "系统已自动排除缺卡统计", disabled: true, action: null, } as const; } if (!attendanceToday.item?.clockInAt) { return { label: "上班打卡", description: "记录今日到岗时间", disabled: false, action: "clock-in", } as const; } if (!attendanceToday.item.clockOutAt) { return { label: "下班打卡", description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`, disabled: false, action: "clock-out", } as const; } return { label: "今日已完成", description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`, disabled: true, action: null, } as const; } function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) { if (!items.length) { return ; } return (
{items.map((item) => (
{item.label}
{item.value}
{item.note ?
{item.note}
: null} ))}
); } function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) { const safeAnnouncements = Array.isArray(announcements) ? announcements : []; return ( 查看全部} >
{safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => (
{item.title}
{item.summary}
{item.href ? ( 查看详情 ) : null}
)) : }
); } function RecentVisitsPanel() { const recentVisits = readRecentVisits(); return (
{recentVisits.length ? recentVisits.map((item) => (
{item.title}
{item.path}
)) : }
); } function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) { const safeItems = Array.isArray(items) ? items : []; return (
{safeItems.length ? safeItems.map((item) => ( {item.title} )) : }
); } function SecondaryCardsPanel({ title, description, cards }: { title: string; description: string; cards: PortalHomePayload["cards"] }) { if (!cards.length) { return ; } return (
{cards.map((card) => ( ))}
); } 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 PortalSectionFallback({ title, description, }: { title: string; description: string; }) { return ( ); } function GettingStartedCard({ items, completedCount, onDismiss, }: { items: GettingStartedChecklistItem[]; completedCount: number; onDismiss: () => void; }) { const progressPercent = items.length ? Math.round((completedCount / items.length) * 100) : 0; return ( 关闭清单 )} className="shadow-sm" >
{items.map((item) => ( {item.completed ? "check_circle" : "radio_button_unchecked"}
{item.title}
{item.description}
arrow_forward ))}
完成进度 {completedCount}/{items.length}
); } function QuickActionTile({ icon, label, description, to, onClick, disabled = false, }: QuickActionConfig) { const baseClassName = disabled ? "rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-left text-zinc-400" : "rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-left shadow-sm transition-colors hover:bg-zinc-50"; const content = ( <>
{icon}
{label}
{description}
); if (to && !disabled) { return {content}; } if (onClick) { return ( ); } return
{content}
; } function QuickActionsPanel({ items, }: { items: QuickActionConfig[]; }) { return (
{items.length ? items.map((item) => ( )) : }
); } function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) { return (
{items.length ? items.map((item) => (
{item.icon}
{item.title}
{item.sourceLabel} {item.severity === "high" ? 高优先 : null} {item.severity === "warning" ? 关注 : null}
进入处理
)) : }
); } function TeamStatusBoardPanel({ items }: { items: PortalTeamStatusItem[] }) { const safeItems = Array.isArray(items) ? items : []; return (
{safeItems.length ? safeItems.map((item) => { const badge = resolveTeamStatusBadge(item); return (
{item.name}
{formatTeamStatusAttendance(item)}
{badge.label}
); }) : }
); } function RecentActivitiesPanel({ items }: { items: PortalRecentActivity[] }) { const safeItems = Array.isArray(items) ? items : []; return (
{safeItems.length ? safeItems.map((item) => (
{item.summary}
{formatRelativeTime(item.createdAt)}
)) : }
); } export function PortalHomePage() { const queryClient = useQueryClient(); const { me, permissions } = useAuth(); const [showWelcome, setShowWelcome] = useState(false); const [showGettingStarted, setShowGettingStarted] = useState(false); const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() }); const attendanceTodayQuery = useQuery({ queryKey: ["portal-home", "attendance", "today"], queryFn: () => fetchMyTodayAttendance(), retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2, }); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); const isManagerView = isManagerRole(permissions); useEffect(() => { setShowWelcome(shouldShowWelcomeDialog()); setShowGettingStarted(shouldShowGettingStartedCard()); }, []); const gettingStartedItems: GettingStartedChecklistItem[] = [ { key: "profile", title: "完善个人资料", description: "补充紧急联系人和银行账户", href: "/settings/profile", completed: (me?.profileCompleteness?.score ?? 0) >= 50, }, { key: "notification-preferences", title: "设置通知偏好", description: "选择是否接收外部渠道推送", href: "/settings/profile", completed: true, }, { key: "leave-flow", title: "了解请假流程", description: "查看假期类型和余额", href: "/leave-requests", completed: false, }, { key: "notifications", title: "查看通知中心", description: "了解审批和业务消息", href: "/notifications", completed: false, }, ]; const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length; const attendanceClockInMutation = useMutation({ mutationFn: () => clockInAttendance(), onSuccess: async (response) => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["portal-home"] }), queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }), ]); toast.success( response.item.isLate ? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功", ); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); const attendanceClockOutMutation = useMutation({ mutationFn: () => clockOutAttendance(), onSuccess: async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["portal-home"] }), queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }), queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }), ]); toast.success("下班打卡成功"); }, onError: (error) => { toast.error(resolveErrorMessage(error)); }, }); if (portalQuery.isLoading) { return
加载 Portal 中...
; } if (portalQuery.isError || !portalQuery.data) { const message = isApiError(portalQuery.error) ? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}` : "Portal 加载失败"; return
{message}
; } 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 portalSchema = isManagerView ? managerPortalSchema : employeePortalSchema; const primaryCards = pickCards(cards, portalSchema.heroCards); const primaryCardKeySet = primaryCards.map((card) => card.key); const secondaryCards = compactCards(cards, primaryCardKeySet); const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error); const attendanceToday = attendanceTodayQuery.data ?? null; const clockAction = resolveClockAction(attendanceToday, { isLoading: attendanceTodayQuery.isLoading, isError: attendanceTodayQuery.isError, featureDisabled: attendanceFeatureDisabled, pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending, }); const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards }); function handleCloseWelcome() { clearWelcomeDialogFlag(); setShowWelcome(false); } function handleDismissGettingStarted() { dismissGettingStartedCard(); setShowGettingStarted(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 quickActions: QuickActionConfig[] = []; if (hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) { quickActions.push({ key: "leave", icon: "event_available", label: "提交请假", description: "直接进入请假申请页", to: "/leave-requests?action=create", }); } if (hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) { quickActions.push({ key: "expense", icon: "request_quote", label: "提交报销", description: "进入报销工作台创建单据", to: "/reimbursements", }); } if (!attendanceFeatureDisabled) { quickActions.push({ key: "attendance", icon: clockAction.action === "clock-out" ? "logout" : "schedule", label: clockAction.label, description: clockAction.description, disabled: clockAction.disabled, onClick: clockAction.action === "clock-in" ? () => attendanceClockInMutation.mutate() : clockAction.action === "clock-out" ? () => attendanceClockOutMutation.mutate() : undefined, }); } if (payload.weeklyReport || payload.weeklySummary) { quickActions.push({ key: "weekly", icon: "article", label: "提交周报", description: "进入周报页继续填写或查看团队缺口", to: "/weekly-reports", }); } return ( 完成工作区设置 ) : null } />
{primaryCards.map((card) => ( ))}
{showGettingStarted ? ( )} > ) : null}
)} > )} >
{isManagerView ? ( <>
) : ( <>
)}
); }