R29-1 R29-6: 重构海音达工作台壳层与高频页面
这个提交包含在:
+158
-211
@@ -1,13 +1,18 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { readRecentVisits } from "../lib/recentVisits";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||
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 { readRecentVisits } from "../lib/recentVisits";
|
||||
|
||||
type SummaryItem = {
|
||||
key: string;
|
||||
@@ -22,14 +27,10 @@ function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
|
||||
return Boolean(item);
|
||||
}
|
||||
|
||||
function cardClasses(severity?: "normal" | "warning" | "high") {
|
||||
if (severity === "high") {
|
||||
return "card-std border-red-200 bg-red-50 p-5";
|
||||
}
|
||||
if (severity === "warning") {
|
||||
return "card-std border-amber-200 bg-amber-50 p-5";
|
||||
}
|
||||
return "card-std p-5";
|
||||
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[]) {
|
||||
@@ -42,101 +43,59 @@ function compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[])
|
||||
}
|
||||
|
||||
function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
|
||||
if (!payload) {
|
||||
return "未启用";
|
||||
}
|
||||
if (!payload) return "未启用";
|
||||
return payload.status === "submitted" ? "已提交" : "待填写";
|
||||
}
|
||||
|
||||
function renderAnnouncementLink(item: PortalHomePayload["announcements"][number]) {
|
||||
if (!item.href) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
className="mt-2 inline-flex text-sm font-medium text-primary hover:underline"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
查看详情
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<SectionPanel title={title} description="优先看结论与下一步,再进入对应模块处理。">
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.href}
|
||||
className={item.tone === "warning"
|
||||
? "block rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 hover:bg-amber-100"
|
||||
: "block rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50"}
|
||||
? "block rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 hover:bg-amber-100"
|
||||
: "block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50"}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-zinc-900">{item.label}</span>
|
||||
<span className="text-sm font-semibold text-zinc-900">{item.value}</span>
|
||||
<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-1 text-xs text-zinc-500">{item.note}</div> : null}
|
||||
{item.note ? <div className="mt-2 text-sm leading-6 text-zinc-500">{item.note}</div> : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
|
||||
if (!cards.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.key}
|
||||
to={card.href}
|
||||
className={card.severity === "high"
|
||||
? "rounded-xl border border-red-200 bg-red-50 px-4 py-3 hover:bg-red-100"
|
||||
: card.severity === "warning"
|
||||
? "rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 hover:bg-amber-100"
|
||||
: "rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50"}
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">{card.title}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{card.value}</div>
|
||||
</Link>
|
||||
))}
|
||||
<SectionPanel
|
||||
title="公告"
|
||||
description="公告保留在第二阅读层,不与今日任务卡竞争。"
|
||||
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">查看全部</Link>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{announcements.length ? announcements.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">常用入口</h3>
|
||||
<div className="grid gap-2">
|
||||
{items.map((item) => (
|
||||
<Link key={item.key} to={item.href} className="rounded-xl border border-zinc-200 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50">
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,77 +103,91 @@ function RecentVisitsPanel() {
|
||||
const recentVisits = readRecentVisits();
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">最近访问</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{recentVisits.length ? recentVisits.map((item) => (
|
||||
<Link key={item.path} to={item.path} className="block rounded-xl border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.path}</div>
|
||||
</Link>
|
||||
)) : <div className="text-zinc-500">暂无最近访问</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">公告</h3>
|
||||
<Link to="/announcements" className="text-sm text-primary hover:underline">查看全部</Link>
|
||||
</div>
|
||||
<SectionPanel title="继续工作" description="保留最近访问路径,帮助快速回到上一次处理中断的位置。">
|
||||
<div className="space-y-3">
|
||||
{announcements.length ? announcements.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-zinc-200 p-3">
|
||||
{recentVisits.length ? recentVisits.map((item) => (
|
||||
<Link key={item.path} to={item.path} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
|
||||
{renderAnnouncementLink(item)}
|
||||
</div>
|
||||
)) : <div className="rounded-xl border border-dashed border-zinc-200 px-4 py-6 text-sm text-zinc-500">暂无公告,后续可由管理员在公告模块统一发布通知。</div>}
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.path}</div>
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无最近访问" description="开始使用后,最近处理过的页面会显示在这里。" />}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: PortalHomePayload["adoptionSummary"] }) {
|
||||
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
|
||||
return (
|
||||
<SectionPanel title="常用入口" description="只保留高频入口,避免把所有模块堆成超市。">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.length ? items.map((item) => (
|
||||
<Link key={item.key} to={item.href} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm font-medium text-zinc-700 hover:bg-zinc-50">
|
||||
{item.title}
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无常用入口" description="工作区尚未配置推荐入口。" />}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryCardsPanel({ title, description, cards }: { title: string; description: string; cards: PortalHomePayload["cards"] }) {
|
||||
if (!cards.length) {
|
||||
return <EmptyStateCard title={title} description="暂无需要额外关注的统计项。" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPanel title={title} description={description}>
|
||||
<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 AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullable<PortalHomePayload["adoptionSummary"]> | null }) {
|
||||
if (!adoptionSummary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="card-std p-6" open>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">试点观测摘要</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">仅管理员默认展开,用于观察登录、周报和状态覆盖率。</p>
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-wide text-zinc-400">展开 / 收起</span>
|
||||
<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-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">近 7 天登录覆盖率</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.login7dRate}%</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.login7dRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">周报提交率</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">团队状态覆盖率</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">当前公告数</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.announcementCount}</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.announcementCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm">
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
|
||||
<div className="font-medium text-zinc-900">本周观测提示</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-zinc-600">
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{adoptionSummary.hints.map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
@@ -238,23 +211,16 @@ export function PortalHomePage() {
|
||||
const message = isApiError(portalQuery.error)
|
||||
? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}`
|
||||
: "Portal 加载失败";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle="小团队统一入口与高频待办概览。" />
|
||||
<div className="card-std p-6 text-sm text-red-600">{message}</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="card-std p-6 text-sm text-red-600">{message}</div>;
|
||||
}
|
||||
|
||||
const payload = portalQuery.data;
|
||||
const primaryCardKeys = isManagerView
|
||||
? ["pending_approvals", "team_presence", "weekly_missing", "weekly_help"]
|
||||
: ["my_todos", "today_presence", "my_weekly_report", "my_projects"];
|
||||
const primaryCards = pickCards(payload.cards, primaryCardKeys);
|
||||
const portalSchema = isManagerView ? managerPortalSchema : employeePortalSchema;
|
||||
const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
|
||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
|
||||
|
||||
const employeeSummaryCandidates: Array<SummaryItem | null> = [
|
||||
const employeeSummaryItems = ([
|
||||
{
|
||||
key: "expense",
|
||||
href: "/reimbursements",
|
||||
@@ -283,10 +249,9 @@ export function PortalHomePage() {
|
||||
tone: payload.todayPresence.status === "leave" ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
const employeeSummaryItems = employeeSummaryCandidates.filter(isSummaryItem);
|
||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||
|
||||
const managerSummaryCandidates: Array<SummaryItem | null> = [
|
||||
const managerSummaryItems = ([
|
||||
{
|
||||
key: "team-summary",
|
||||
href: payload.teamPresence?.href ?? "/team",
|
||||
@@ -325,87 +290,69 @@ export function PortalHomePage() {
|
||||
tone: payload.peopleLifecycle.offboardingCount > 0 || payload.peopleLifecycle.assetReturnPendingCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
payload.riskSummary
|
||||
? {
|
||||
key: "risk-summary",
|
||||
href: payload.riskSummary.href,
|
||||
label: "待复核风险",
|
||||
value: `${payload.riskSummary.openCount} 项`,
|
||||
note: "仅展示脱敏摘要,详细处理仍在 OA 内完成。",
|
||||
tone: payload.riskSummary.openCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
const managerSummaryItems = managerSummaryCandidates.filter(isSummaryItem);
|
||||
|
||||
const headerSubtitle = isManagerView
|
||||
? `你好,${payload.greeting.name}。先看今天最重要的管理动作。`
|
||||
: `你好,${payload.greeting.name}。先处理今天要做的几件事。`;
|
||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle={headerSubtitle} />
|
||||
<PageScaffold>
|
||||
<WorkbenchHero
|
||||
eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
|
||||
title={`你好,${payload.greeting.name}`}
|
||||
subtitle={isManagerView
|
||||
? "先处理审批、团队状态和周报缺口,再进入项目与组织协同。"
|
||||
: "先处理今天必须完成的事,再继续查看项目、周报和公告。"}
|
||||
actions={
|
||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/system/workspace">完成工作区设置</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<div className="card-std flex items-center justify-between gap-3 border-amber-200 bg-amber-50 p-4">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">工作区尚未初始化</div>
|
||||
<div className="text-sm text-zinc-600">建议先完成小团队模式的导航与模块设置,再邀请全员使用。</div>
|
||||
</div>
|
||||
<Button asChild type="button">
|
||||
<Link to="/system/workspace">去初始化</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
{primaryCards.map((card) => (
|
||||
<Link
|
||||
<MetricCard
|
||||
key={card.key}
|
||||
to={card.href}
|
||||
aria-label={`portal-card-${card.key}`}
|
||||
className={cardClasses(card.severity)}
|
||||
>
|
||||
<div className="text-sm text-zinc-500">{card.title}</div>
|
||||
<div className="mt-2 text-2xl font-bold text-zinc-900">{card.value}</div>
|
||||
</Link>
|
||||
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>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
|
||||
<SummaryPanel title={isManagerView ? "今日管理重点" : "我的申请与资产"} items={isManagerView ? managerSummaryItems : employeeSummaryItems} />
|
||||
<AnnouncementsPanel announcements={payload.announcements} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
<RecentVisitsPanel />
|
||||
<SecondaryCardsPanel title={isManagerView ? "其他提醒" : "更多事项"} cards={secondaryCards} />
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
{isManagerView ? (
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="把需要继续跟进的项目、人员与风险放到第二层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
) : (
|
||||
<RecentVisitsPanel />
|
||||
)}
|
||||
|
||||
{isManagerView ? (
|
||||
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
|
||||
) : (
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{payload.adoptionSummary ? <AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} /> : null}
|
||||
|
||||
{!isManagerView && payload.projectSummary ? (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">项目与周报概览</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Link to={payload.projectSummary.href} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">我的项目</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{payload.projectSummary.myCount}</div>
|
||||
</Link>
|
||||
<Link to={payload.projectSummary.href} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">今日待跟进</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{payload.projectSummary.followUpDueTodayCount}</div>
|
||||
</Link>
|
||||
<Link to={payload.weeklyReport?.href ?? "/weekly-reports"} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">本周周报</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatWeeklyStatus(payload.weeklyReport)}</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{isManagerView ? <QuickLinksPanel items={payload.quickLinks} /> : null}
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default PortalHomePage;
|
||||
|
||||
在新工单中引用
屏蔽一个用户