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
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户