R29-1 R29-6: 重构海音达工作台壳层与高频页面
这个提交包含在:
@@ -14,6 +14,9 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { FilterBar, ListWorkspace } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -94,6 +97,22 @@ export function ProjectsPage() {
|
||||
[employeesQuery.data?.items, me?.id],
|
||||
);
|
||||
|
||||
const projectItems = listQuery.data?.items ?? [];
|
||||
const projectStats = useMemo(() => {
|
||||
return projectItems.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += 1;
|
||||
if (item.status === "active") {
|
||||
acc.active += 1;
|
||||
}
|
||||
acc.overdueMilestones += item.counts.overdueMilestones;
|
||||
acc.openRisks += item.counts.openRisks;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, active: 0, overdueMilestones: 0, openRisks: 0 },
|
||||
);
|
||||
}, [projectItems]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createProject({
|
||||
@@ -141,7 +160,7 @@ export function ProjectsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ListWorkspace>
|
||||
<PageHeader
|
||||
title="项目"
|
||||
subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。"
|
||||
@@ -154,9 +173,16 @@ export function ProjectsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),360px]">
|
||||
<div className="space-y-4">
|
||||
<div className="card-std grid gap-3 p-4 md:grid-cols-[minmax(0,1fr),180px,140px]">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} />
|
||||
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
|
||||
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="建议先处理延期事项" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
|
||||
</section>
|
||||
|
||||
<div className="page-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
|
||||
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
|
||||
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
|
||||
{STATUS_OPTIONS.map((item) => (
|
||||
@@ -166,48 +192,56 @@ export function ProjectsPage() {
|
||||
))}
|
||||
</select>
|
||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
|
||||
{mine ? "只看我的" : "查看全部"}
|
||||
{mine ? "只看我的" : "切换到我的"}
|
||||
</Button>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{listQuery.isLoading ? <div className="card-std p-6 text-sm text-zinc-500">项目加载中...</div> : null}
|
||||
{listQuery.isError ? <div className="card-std p-6 text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||
<SectionPanel
|
||||
title="项目列表"
|
||||
description={projectItems.length ? `当前筛出 ${projectItems.length} 个项目` : "按项目名、客户或状态快速缩小范围"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{listQuery.isLoading ? <div className="text-sm text-zinc-500">项目加载中...</div> : null}
|
||||
{listQuery.isError ? <div className="text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<Link key={item.id} to={`/projects/${item.id}`} className="card-std block space-y-3 p-5 hover:border-primary/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.projectNo}</div>
|
||||
<StatusBadge status={item.status} />
|
||||
{item.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密</span> : null}
|
||||
<div className="grid gap-4">
|
||||
{projectItems.map((item) => (
|
||||
<Link key={item.id} to={`/projects/${item.id}`} className="card-std block space-y-3 p-5 hover:border-primary/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.projectNo}</div>
|
||||
<StatusBadge status={item.status} />
|
||||
{item.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-base text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">客户:{item.clientAlias || item.clientName}</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<div>负责人:{item.owner?.name ?? "-"}</div>
|
||||
<div>项目经理:{item.manager?.name ?? "-"}</div>
|
||||
<div>目标完成:{item.targetEndAt ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-base text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">客户:{item.clientAlias || item.clientName}</div>
|
||||
<div className="text-sm text-zinc-600">{item.summary || "暂无项目摘要"}</div>
|
||||
<div className="text-sm text-zinc-500">{summaryLine(item)}</div>
|
||||
</Link>
|
||||
))}
|
||||
{!listQuery.isLoading && !projectItems.length ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-sm text-zinc-500">
|
||||
当前没有符合筛选条件的项目。
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<div>负责人:{item.owner?.name ?? "-"}</div>
|
||||
<div>项目经理:{item.manager?.name ?? "-"}</div>
|
||||
<div>目标完成:{item.targetEndAt ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600">{item.summary || "暂无项目摘要"}</div>
|
||||
<div className="text-sm text-zinc-500">{summaryLine(item)}</div>
|
||||
</Link>
|
||||
))}
|
||||
{!listQuery.isLoading && !(listQuery.data?.items ?? []).length ? (
|
||||
<div className="card-std p-6 text-sm text-zinc-500">当前没有符合筛选条件的项目。</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建项目</h3>
|
||||
<SectionPanel title="新建项目" description="先建主档,再补成员、里程碑与风险。">
|
||||
{!canManageProjects ? (
|
||||
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>项目名称</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
|
||||
@@ -256,11 +290,11 @@ export function ProjectsPage() {
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "创建项目"}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户