R29-1 R29-6: 重构海音达工作台壳层与高频页面

这个提交包含在:
2026-03-23 09:59:35 +08:00
父节点 f9198a781a
当前提交 7e183c0cdc
共修改 33 个文件,包含 2193 行新增和 1564 行删除
+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;