feat(P25-2/P25-3/P26-2/P28-1/P24-2/P29-3): 落地内部OA够用版前端入口与运营页面

关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
这个提交包含在:
2026-03-21 18:03:48 +08:00
父节点 e39e41287e
当前提交 9905c1c79d
共修改 44 个文件,包含 2029 行新增和 169 行删除
+133
查看文件
@@ -0,0 +1,133 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { fetchPortalHome } from "../api/portal";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { PageHeader } from "../components/common/PageHeader";
import { readRecentVisits } from "../lib/recentVisits";
function domainLabel(domain: string) {
if (domain === "reimbursement") return "报销";
if (domain === "leave") return "请假";
if (domain === "purchase_request") return "采购";
if (domain === "travel_request") return "差旅";
return domain;
}
export function PortalHomePage() {
const portalQuery = useQuery({
queryKey: ["portal-home"],
queryFn: () => fetchPortalHome(),
});
const recentVisits = readRecentVisits();
if (portalQuery.isLoading) {
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
}
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 (
<div className="space-y-6">
<PageHeader title="Portal" subtitle="统一入口与待办概览。" />
<div className="card-std p-6 text-sm text-red-600">{message}</div>
<div className="card-std p-6 text-sm text-zinc-600">如 `portal_home_v1` 尚未开启,可先使用 `仪表盘`。</div>
</div>
);
}
const payload = portalQuery.data;
return (
<div className="space-y-6">
<PageHeader title="Portal" subtitle={`你好,${payload.greeting.name}。`} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{payload.cards.map((card) => (
<Link
key={card.key}
to={card.href}
className={
card.severity === "high"
? "card-std border-red-200 bg-red-50 p-5"
: card.severity === "warning"
? "card-std border-amber-200 bg-amber-50 p-5"
: "card-std p-5"
}
>
<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>
))}
</div>
<div className="grid gap-6 xl:grid-cols-3">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">快捷入口</h3>
<div className="grid gap-2">
{payload.quickLinks.map((item) => (
<Link key={item.key} to={item.href} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50">
{item.title}
</Link>
))}
</div>
</div>
<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">
{payload.myApplications.map((item) => (
<Link key={item.domain} to={item.href} className="flex items-center justify-between rounded-lg border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
<span>{domainLabel(item.domain)}</span>
<span className="font-medium text-zinc-900">{item.count}</span>
</Link>
))}
</div>
</div>
<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-lg 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>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">公告</h3>
<div className="space-y-3">
{payload.announcements.length ? payload.announcements.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-3">
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
{item.href ? <a href={item.href} className="mt-2 inline-block text-sm text-primary" target="_blank" rel="noreferrer">查看链接</a> : null}
</div>
)) : <div className="text-sm text-zinc-500">暂无公告</div>}
</div>
</div>
<div className="card-std space-y-3 p-6">
<h3 className="text-base font-semibold text-zinc-900">待办与风险</h3>
<Link to={payload.pendingTasks.href} className="block rounded-lg border border-zinc-200 px-3 py-3 text-sm hover:bg-zinc-50">
我的待办:<span className="font-medium text-zinc-900">{payload.pendingTasks.count}</span>
</Link>
{payload.riskSummary ? (
<Link to={payload.riskSummary.href} className="block rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm hover:bg-amber-100">
待复核风险:<span className="font-medium text-zinc-900">{payload.riskSummary.openCount}</span>
</Link>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">当前没有开放风险项</div>
)}
</div>
</div>
</div>
);
}