1259 行
44 KiB
TypeScript
1259 行
44 KiB
TypeScript
import { type ReactNode, 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 { 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,
|
||
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";
|
||
};
|
||
|
||
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";
|
||
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 PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
|
||
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
|
||
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 = {
|
||
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[]) {
|
||
const hasAdminRole = roleCodes.includes("admin") || hasAnyPermission(permissions, ["*"]);
|
||
const hasBusinessApproverRole = roleCodes.some((roleCode) =>
|
||
["manager", "ceo", "finance", "finance_mgr", "hr"].includes(roleCode),
|
||
);
|
||
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
|
||
|
||
return {
|
||
canSubmitApplications: hasAnyPermission(permissions, [
|
||
...LEAVE_PORTAL_PERMISSIONS,
|
||
...EXPENSE_PORTAL_PERMISSIONS,
|
||
...PROCUREMENT_PORTAL_PERMISSIONS,
|
||
...TRAVEL_PORTAL_PERMISSIONS,
|
||
]),
|
||
canApproveTasks: hasBusinessApproverRole && hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
|
||
canUseFinance: hasFinanceRole && hasAnyPermission(permissions, FINANCE_PORTAL_PERMISSIONS),
|
||
canManageSystem: hasAdminRole || hasAnyPermission(permissions, SYSTEM_PORTAL_PERMISSIONS),
|
||
canViewTeam: hasBusinessApproverRole || hasAnyPermission(permissions, ["report.dashboard.view", "system.users", "project.manage"]),
|
||
isAdminOnly: hasAdminRole && !hasBusinessApproverRole,
|
||
};
|
||
}
|
||
|
||
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 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 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<string, TodoAggregateItem>();
|
||
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" };
|
||
}
|
||
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.presenceStatus === "travel") {
|
||
return "今日差旅";
|
||
}
|
||
if (item.presenceStatus === "out") {
|
||
return "当前外出";
|
||
}
|
||
if (item.presenceStatus === "remote") {
|
||
return "今日居家办公";
|
||
}
|
||
return "企微考勤见考勤页";
|
||
}
|
||
|
||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||
if (!items.length) {
|
||
return <EmptyStateCard title={title} description="暂无摘要。" />;
|
||
}
|
||
|
||
return (
|
||
<SectionPanel title={title}>
|
||
<div className="space-y-3">
|
||
{items.map((item) => (
|
||
<Link
|
||
key={item.key}
|
||
to={item.href}
|
||
className={item.tone === "warning" ? "portal-list-card portal-list-card--warning" : "portal-list-card"}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="font-medium text-zinc-900">{item.label}</div>
|
||
<div className="text-sm font-semibold text-zinc-900">{item.value}</div>
|
||
</div>
|
||
{item.note ? <div className="mt-2 text-sm leading-6 text-zinc-500">{item.note}</div> : null}
|
||
</Link>
|
||
))}
|
||
</div>
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
function FinanceSummaryPanel({ summary }: { summary: PortalHomePayload["financeSummary"] }) {
|
||
if (!summary) {
|
||
return null;
|
||
}
|
||
|
||
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",
|
||
},
|
||
];
|
||
|
||
return <SummaryPanel title="财务待处理" items={items} />;
|
||
}
|
||
|
||
function ApprovalProductivityPanel({
|
||
summary,
|
||
title = "审批效率摘要",
|
||
}: {
|
||
summary: WorkflowTaskProductivitySummary | null;
|
||
title?: string;
|
||
}) {
|
||
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 <SummaryPanel title={title} items={items} />;
|
||
}
|
||
|
||
function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{safeAnnouncements.length ? safeAnnouncements.slice(0, 4).map((item) => (
|
||
<div key={item.id} className="portal-list-card portal-list-card--compact">
|
||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||
<div className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</div>
|
||
{item.href ? (
|
||
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.href} rel="noreferrer" target="_blank">
|
||
查看详情
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
)) : <EmptyStateCard title="暂无公告" description="没有公告。" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
|
||
const safeItems = Array.isArray(items) ? items : [];
|
||
|
||
return (
|
||
<SectionPanel title="入口">
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{safeItems.length ? safeItems.map((item) => (
|
||
<Link key={item.key} to={item.href} className="portal-list-card text-sm font-medium text-slate-700">
|
||
{item.title}
|
||
</Link>
|
||
)) : <EmptyStateCard title="暂无常用入口" description="尚未配置。" />}
|
||
</div>
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
|
||
if (!cards.length) {
|
||
return <EmptyStateCard title={title} description="暂无统计项。" />;
|
||
}
|
||
|
||
return (
|
||
<SectionPanel title={title}>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{cards.map((card) => (
|
||
<MetricCard
|
||
key={card.key}
|
||
title={card.title}
|
||
value={card.value}
|
||
href={card.href}
|
||
note="查看"
|
||
tone={severityTone(card.severity)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
function CompactGroup({ title, children }: { title: string; children: ReactNode }) {
|
||
return (
|
||
<section className="portal-compact-group">
|
||
<h3 className="portal-compact-group__title">{title}</h3>
|
||
<div className="portal-compact-group__body">{children}</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function CompactSummaryLinks({ title, items }: { title: string; items: SummaryItem[] }) {
|
||
return (
|
||
<CompactGroup title={title}>
|
||
{items.length ? items.map((item) => (
|
||
<Link key={item.key} to={item.href} className="portal-compact-row">
|
||
<span className="min-w-0 truncate">{item.label}</span>
|
||
<span className={item.tone === "warning" ? "shrink-0 font-semibold text-amber-700" : "shrink-0 font-semibold text-slate-900"}>
|
||
{item.value}
|
||
</span>
|
||
</Link>
|
||
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无摘要。</div>}
|
||
</CompactGroup>
|
||
);
|
||
}
|
||
|
||
function CompactRecentVisits() {
|
||
const recentVisits = readRecentVisits();
|
||
|
||
return (
|
||
<CompactGroup title="继续工作">
|
||
{recentVisits.length ? recentVisits.map((item) => (
|
||
<Link key={item.path} to={item.path} className="portal-compact-row">
|
||
<span className="min-w-0 truncate">{item.title}</span>
|
||
<span className="shrink-0 text-xs text-slate-400">打开</span>
|
||
</Link>
|
||
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无最近访问。</div>}
|
||
</CompactGroup>
|
||
);
|
||
}
|
||
|
||
function CompactQuickLinks({ items }: { items: PortalHomePayload["quickLinks"] }) {
|
||
const safeItems = Array.isArray(items) ? items : [];
|
||
|
||
return (
|
||
<CompactGroup title="常用入口">
|
||
{safeItems.length ? safeItems.slice(0, 6).map((item) => (
|
||
<Link key={item.key} to={item.href} className="portal-compact-row">
|
||
<span className="min-w-0 truncate">{item.title}</span>
|
||
<span className="shrink-0 text-xs text-slate-400">进入</span>
|
||
</Link>
|
||
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无常用入口。</div>}
|
||
</CompactGroup>
|
||
);
|
||
}
|
||
|
||
function CompactMetricLinks({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
|
||
return (
|
||
<CompactGroup title={title}>
|
||
{cards.length ? cards.slice(0, 6).map((card) => (
|
||
<Link key={card.key} to={card.href} className="portal-compact-row">
|
||
<span className="min-w-0 truncate">{card.title}</span>
|
||
<span className={card.severity === "high" || card.severity === "warning" ? "shrink-0 font-semibold text-amber-700" : "shrink-0 font-semibold text-slate-900"}>
|
||
{card.value}
|
||
</span>
|
||
</Link>
|
||
)) : <div className="px-2 py-1.5 text-sm text-slate-500">暂无统计项。</div>}
|
||
</CompactGroup>
|
||
);
|
||
}
|
||
|
||
function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullable<PortalHomePayload["adoptionSummary"]> | null }) {
|
||
if (!adoptionSummary) {
|
||
return null;
|
||
}
|
||
|
||
return (
|
||
<details className="card-std portal-secondary-drawer">
|
||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-left">
|
||
<div>
|
||
<div className="text-base font-semibold text-zinc-900">试点观测摘要</div>
|
||
<div className="mt-1 text-sm text-zinc-500">管理员按需查看。</div>
|
||
</div>
|
||
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">展开 / 收起</span>
|
||
</summary>
|
||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
<div className="portal-status-card text-sm">
|
||
<div className="text-zinc-500">近 7 天登录覆盖率</div>
|
||
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.login7dRate}%</div>
|
||
</div>
|
||
<div className="portal-status-card text-sm">
|
||
<div className="text-zinc-500">周报提交率</div>
|
||
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
|
||
</div>
|
||
<div className="portal-status-card text-sm">
|
||
<div className="text-zinc-500">团队状态覆盖率</div>
|
||
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
|
||
</div>
|
||
<div className="portal-status-card text-sm">
|
||
<div className="text-zinc-500">当前公告数</div>
|
||
<div className="mt-2 text-2xl font-semibold text-zinc-900">{adoptionSummary.announcementCount}</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600">
|
||
<div className="font-medium text-zinc-900">本周观测提示</div>
|
||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||
{adoptionSummary.hints.map((hint) => (
|
||
<li key={hint}>{hint}</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function SecondaryWorkspacePanel({
|
||
title,
|
||
description,
|
||
children,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<details className="card-std portal-secondary-drawer">
|
||
<summary className="portal-secondary-summary">
|
||
<div>
|
||
<div className="text-base font-semibold text-zinc-900">{title}</div>
|
||
<div className="mt-1 text-sm text-zinc-500">{description}</div>
|
||
</div>
|
||
<span className="portal-secondary-summary__toggle">展开 / 收起</span>
|
||
</summary>
|
||
<div className="portal-secondary-drawer__body">
|
||
{children}
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
function PortalSectionFallback({
|
||
title,
|
||
description,
|
||
}: {
|
||
title: string;
|
||
description: string;
|
||
}) {
|
||
return (
|
||
<SectionPanel title={title} description={description}>
|
||
<EmptyStateCard title={`${title}暂时不可用`} description="该区域渲染失败,请刷新页面后重试。" />
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
function GettingStartedCard({
|
||
items,
|
||
completedCount,
|
||
onDismiss,
|
||
}: {
|
||
items: GettingStartedChecklistItem[];
|
||
completedCount: number;
|
||
onDismiss: () => void;
|
||
}) {
|
||
const progressPercent = items.length ? Math.round((completedCount / items.length) * 100) : 0;
|
||
|
||
return (
|
||
<SectionPanel
|
||
title="快速上手"
|
||
description="基础资料"
|
||
actions={(
|
||
<button
|
||
type="button"
|
||
className="text-sm font-medium text-zinc-500 transition-colors hover:text-zinc-700"
|
||
data-testid="onboarding-getting-started-dismiss"
|
||
onClick={onDismiss}
|
||
>
|
||
关闭清单
|
||
</button>
|
||
)}
|
||
className="shadow-sm"
|
||
>
|
||
<div className="space-y-3" data-testid="onboarding-getting-started-card">
|
||
{items.map((item) => (
|
||
<Link
|
||
key={item.key}
|
||
to={item.href}
|
||
className="portal-list-card flex items-center gap-3"
|
||
>
|
||
<span className={item.completed ? "text-emerald-600" : "text-zinc-400"}>
|
||
<span className="material-symbols-outlined text-[20px]">
|
||
{item.completed ? "check_circle" : "radio_button_unchecked"}
|
||
</span>
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||
<div className="mt-1 text-sm leading-6 text-zinc-500">{item.description}</div>
|
||
</div>
|
||
<span className="material-symbols-outlined text-zinc-400">arrow_forward</span>
|
||
</Link>
|
||
))}
|
||
|
||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4">
|
||
<div className="flex items-center justify-between gap-3 text-sm">
|
||
<span className="font-medium text-zinc-900">完成进度</span>
|
||
<span className="text-zinc-500">{completedCount}/{items.length}</span>
|
||
</div>
|
||
<div className="mt-3 h-2 overflow-hidden rounded-full bg-slate-200">
|
||
<div
|
||
className="h-full rounded-full bg-primary transition-all"
|
||
style={{ width: `${progressPercent}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
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 = (
|
||
<>
|
||
<div className="flex items-center gap-3">
|
||
<div className={disabled
|
||
? "portal-action-icon portal-action-icon--muted"
|
||
: "portal-action-icon"}
|
||
>
|
||
<span className="material-symbols-outlined text-[20px]">{icon}</span>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className={disabled ? "truncate font-medium text-zinc-500" : "truncate font-medium text-zinc-900"}>{label}</div>
|
||
<div className="mt-1 text-xs leading-5 text-zinc-500">{description}</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
|
||
if (to && !disabled) {
|
||
return <Link to={to} className={baseClassName}>{content}</Link>;
|
||
}
|
||
if (onClick) {
|
||
return (
|
||
<button type="button" className={baseClassName} disabled={disabled} onClick={onClick}>
|
||
{content}
|
||
</button>
|
||
);
|
||
}
|
||
return <div className={baseClassName}>{content}</div>;
|
||
}
|
||
|
||
function QuickActionsPanel({
|
||
items,
|
||
layout = "grid",
|
||
}: {
|
||
items: QuickActionConfig[];
|
||
layout?: "grid" | "rail";
|
||
}) {
|
||
return (
|
||
<SectionPanel
|
||
title="常用"
|
||
className={layout === "rail" ? "portal-panel-fill" : undefined}
|
||
>
|
||
<div className={layout === "rail" ? "portal-action-stack" : "grid gap-3 sm:grid-cols-2 xl:grid-cols-4"}>
|
||
{items.length ? items.map((item) => (
|
||
<QuickActionTile
|
||
key={item.key}
|
||
icon={item.icon}
|
||
label={item.label}
|
||
description={item.description}
|
||
to={item.to}
|
||
onClick={item.onClick}
|
||
disabled={item.disabled}
|
||
compact={layout === "rail"}
|
||
/>
|
||
)) : <EmptyStateCard title="暂无快捷动作" description="当前账号没有可直接执行的首页动作。" />}
|
||
</div>
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
function TodoAggregatePanel({ items }: { items: TodoAggregateItem[] }) {
|
||
const visibleItems = items.slice(0, 5);
|
||
const hiddenCount = Math.max(items.length - visibleItems.length, 0);
|
||
|
||
return (
|
||
<SectionPanel
|
||
title="待处理"
|
||
className="portal-panel-fill"
|
||
>
|
||
<div className="space-y-3">
|
||
{visibleItems.length ? visibleItems.map((item) => (
|
||
<Link key={`${item.title}-${item.href}-${item.sourceLabel}`} to={item.href} className="portal-list-card">
|
||
<div className="flex items-start gap-3">
|
||
<div className={item.severity === "high"
|
||
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-red-50 text-red-600"
|
||
: item.severity === "warning"
|
||
? "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-amber-50 text-amber-600"
|
||
: "mt-0.5 flex size-9 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-600"}
|
||
>
|
||
<span className="material-symbols-outlined text-[18px]">{item.icon}</span>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||
<span className="badge-neutral">{item.sourceLabel}</span>
|
||
{item.severity === "high" ? <span className="badge-error">高优先</span> : null}
|
||
{item.severity === "warning" ? <span className="badge-warning">关注</span> : null}
|
||
</div>
|
||
<div className="mt-1 text-sm text-primary">进入</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)) : <EmptyStateCard title="暂无待办" description="没有需要立刻处理的事。" />}
|
||
</div>
|
||
{hiddenCount > 0 ? (
|
||
<div className="portal-panel-footer">
|
||
<span>另有 {hiddenCount} 项。</span>
|
||
<Link to="/todos" className="font-medium text-primary hover:underline">查看全部待办</Link>
|
||
</div>
|
||
) : null}
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<SectionPanel
|
||
title="团队状态"
|
||
actions={<Link to="/team" className="text-sm font-medium text-primary hover:underline">查看团队页</Link>}
|
||
className="portal-panel-fill"
|
||
>
|
||
<div className="portal-team-list">
|
||
{visibleItems.length ? visibleItems.map((item) => {
|
||
const badge = resolveTeamStatusBadge(item);
|
||
return (
|
||
<div key={item.employeeId} className="portal-team-row">
|
||
<EmployeeAvatar name={item.name} src={item.avatarUrl} size="sm" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-medium text-zinc-900">{item.name}</div>
|
||
<div className="mt-1 truncate text-xs text-zinc-500">{formatTeamStatusAttendance(item)}</div>
|
||
</div>
|
||
<span className={badge.className}>{badge.label}</span>
|
||
</div>
|
||
);
|
||
}) : <EmptyStateCard title="暂无团队状态" description="没有成员状态。" />}
|
||
</div>
|
||
{hiddenCount > 0 ? (
|
||
<div className="portal-panel-footer">
|
||
<span>另有 {hiddenCount} 位。</span>
|
||
<Link to="/team" className="font-medium text-primary hover:underline">查看全部</Link>
|
||
</div>
|
||
) : null}
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
function RecentActivitiesList({ items }: { items: PortalRecentActivity[] }) {
|
||
const safeItems = Array.isArray(items) ? items : [];
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{safeItems.length ? safeItems.slice(0, 5).map((item) => (
|
||
<Link key={item.id} to={item.href} className="portal-list-card portal-list-card--compact">
|
||
<div className="flex items-start gap-3">
|
||
<EmployeeAvatar name={item.actorName} size="sm" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-medium leading-6 text-zinc-900">{item.summary}</div>
|
||
<div className="mt-1 text-xs text-zinc-500">{formatRelativeTime(item.createdAt)}</div>
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
)) : <EmptyStateCard title="暂无近期动态" description="当前没有可展示的最近业务动作。" />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActivityAnnouncementsPanel({
|
||
activities,
|
||
announcements,
|
||
}: {
|
||
activities: PortalRecentActivity[];
|
||
announcements: PortalHomePayload["announcements"];
|
||
}) {
|
||
return (
|
||
<SectionPanel
|
||
title="通知"
|
||
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">公告页</Link>}
|
||
className="portal-panel-fill"
|
||
>
|
||
<Tabs defaultValue="activity" className="portal-insight-tabs">
|
||
<TabsList className="portal-insight-tabs__list">
|
||
<TabsTrigger value="activity">动态</TabsTrigger>
|
||
<TabsTrigger value="announcements">公告</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="activity" className="portal-insight-tabs__content">
|
||
<RecentActivitiesList items={activities} />
|
||
</TabsContent>
|
||
<TabsContent value="announcements" className="portal-insight-tabs__content">
|
||
<AnnouncementsList announcements={announcements} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</SectionPanel>
|
||
);
|
||
}
|
||
|
||
export function PortalHomePage() {
|
||
const { me, permissions } = useAuth();
|
||
const [showWelcome, setShowWelcome] = useState(false);
|
||
const [showGettingStarted, setShowGettingStarted] = 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(() => {
|
||
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;
|
||
|
||
if (portalQuery.isLoading) {
|
||
return <div className="card-std p-6 text-sm text-zinc-500">加载工作台中...</div>;
|
||
}
|
||
|
||
if (portalQuery.isError || !portalQuery.data) {
|
||
const message = isApiError(portalQuery.error)
|
||
? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}`
|
||
: "工作台加载失败";
|
||
return <div className="card-std p-6 text-sm text-red-600">{message}</div>;
|
||
}
|
||
|
||
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 ||
|
||
roleCapabilities.canApproveTasks ||
|
||
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 });
|
||
|
||
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<SummaryItem | null>).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<SummaryItem | null>).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<SummaryItem | null>).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",
|
||
label: "审计日志",
|
||
value: "查看",
|
||
note: "查询关键操作留痕。",
|
||
}
|
||
: null,
|
||
] as Array<SummaryItem | null>).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: "企微摘要 / OA 修正",
|
||
to: "/attendance",
|
||
});
|
||
if (payload.weeklyReport || payload.weeklySummary) {
|
||
quickActions.push({
|
||
key: "weekly",
|
||
icon: "article",
|
||
label: "提交周报",
|
||
description: "填写 / 查看缺口",
|
||
to: "/weekly-reports",
|
||
});
|
||
}
|
||
|
||
return (
|
||
<PageScaffold>
|
||
<ErrorBoundary fallback={null}>
|
||
<WelcomeDialog open={showWelcome} onClose={handleCloseWelcome} />
|
||
</ErrorBoundary>
|
||
|
||
<WorkbenchHero
|
||
eyebrow={workspaceEyebrow}
|
||
title={`你好,${payload.greeting.name}`}
|
||
className="workbench-hero--compact"
|
||
subtitle={roleCapabilities.canUseFinance
|
||
? "付款、风险、审批。"
|
||
: roleCapabilities.canManageSystem
|
||
? "配置、审计、通知。"
|
||
: isManagerView
|
||
? "审批、团队、周报。"
|
||
: "待办、申请、考勤。"}
|
||
actions={
|
||
!payload.workspace.persisted && canSetupWorkspace ? (
|
||
<Button asChild variant="secondary" type="button">
|
||
<Link to="/system/workspace">完成工作区设置</Link>
|
||
</Button>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
<div className="portal-metric-strip ops-status-strip">
|
||
{primaryCards.map((card) => (
|
||
<MetricCard
|
||
key={card.key}
|
||
ariaLabel={`portal-card-${card.key}`}
|
||
href={card.href}
|
||
note={card.key === "my_weekly_report"
|
||
? formatWeeklyStatus(payload.weeklyReport)
|
||
: card.key === "today_presence"
|
||
? payload.todayPresence?.note ?? "更新今日状态"
|
||
: card.key === "team_presence"
|
||
? payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态板待启用"
|
||
: undefined}
|
||
title={card.title}
|
||
tone={severityTone(card.severity)}
|
||
value={card.value}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{showGettingStarted ? (
|
||
<ErrorBoundary
|
||
fallback={(
|
||
<PortalSectionFallback
|
||
title="快速上手"
|
||
description="基础资料"
|
||
/>
|
||
)}
|
||
>
|
||
<GettingStartedCard
|
||
items={gettingStartedItems}
|
||
completedCount={gettingStartedCompletedCount}
|
||
onDismiss={handleDismissGettingStarted}
|
||
/>
|
||
</ErrorBoundary>
|
||
) : null}
|
||
|
||
<div className="portal-home-grid">
|
||
<TodoAggregatePanel items={todoAggregateItems} />
|
||
<QuickActionsPanel items={quickActions} layout="rail" />
|
||
</div>
|
||
|
||
<div className="portal-home-grid portal-home-grid--collaboration">
|
||
<ErrorBoundary
|
||
fallback={(
|
||
<PortalSectionFallback
|
||
title="团队全员状态"
|
||
description="今日团队状态。"
|
||
/>
|
||
)}
|
||
>
|
||
<TeamStatusBoardPanel items={teamStatusBoard} />
|
||
</ErrorBoundary>
|
||
<ErrorBoundary
|
||
fallback={(
|
||
<PortalSectionFallback
|
||
title="近期动态流"
|
||
description="最近业务动作。"
|
||
/>
|
||
)}
|
||
>
|
||
<ActivityAnnouncementsPanel activities={recentActivities} announcements={announcementItems} />
|
||
</ErrorBoundary>
|
||
</div>
|
||
|
||
{isManagerView ? (
|
||
<>
|
||
<SecondaryWorkspacePanel
|
||
title="角色重点"
|
||
description="常用入口"
|
||
>
|
||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr),minmax(0,1fr),360px]">
|
||
{roleCapabilities.canApproveTasks ? (
|
||
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
|
||
) : null}
|
||
{roleCapabilities.canApproveTasks ? (
|
||
<ApprovalProductivityPanel summary={workflowSummaryQuery.data ?? null} />
|
||
) : null}
|
||
{roleCapabilities.canManageSystem ? (
|
||
<ApprovalProductivityPanel
|
||
title="全局审批效率摘要"
|
||
summary={workflowSystemSummaryQuery.data ?? null}
|
||
/>
|
||
) : null}
|
||
{roleCapabilities.canUseFinance ? (
|
||
<FinanceSummaryPanel summary={payload.financeSummary ?? null} />
|
||
) : null}
|
||
{roleCapabilities.canManageSystem ? (
|
||
<SummaryPanel title="系统管理" items={systemSummaryItems} />
|
||
) : null}
|
||
{roleCapabilities.canViewTeam ? (
|
||
<SummaryPanel title="今日团队状态" items={managerSummaryItems} />
|
||
) : null}
|
||
<QuickLinksPanel items={quickLinkItems} />
|
||
</div>
|
||
|
||
<SecondaryCardsPanel
|
||
title="项目与组织"
|
||
cards={secondaryCards}
|
||
/>
|
||
</SecondaryWorkspacePanel>
|
||
|
||
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
|
||
</>
|
||
) : (
|
||
<SecondaryWorkspacePanel
|
||
title="更多入口"
|
||
description="申请、资产、最近访问和项目指标"
|
||
>
|
||
<div className="portal-secondary-compact-grid">
|
||
<CompactSummaryLinks title="我的申请与资产" items={employeeSummaryItems} />
|
||
<CompactRecentVisits />
|
||
<CompactQuickLinks items={quickLinkItems} />
|
||
<CompactMetricLinks title="项目与协同" cards={secondaryCards} />
|
||
</div>
|
||
</SecondaryWorkspacePanel>
|
||
)}
|
||
</PageScaffold>
|
||
);
|
||
}
|