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

这个提交包含在:
2026-03-23 09:59:35 +08:00
父节点 f9198a781a
当前提交 7e183c0cdc
共修改 33 个文件,包含 2193 行新增和 1564 行删除
+301 -272
查看文件
@@ -21,6 +21,9 @@ import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ListWorkspace, PrimaryTable } 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";
@@ -129,6 +132,18 @@ export function LeaveRequestPage() {
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
}, [leaveTypeId, leaveTypesQuery.data?.items]);
const balanceSummary = useMemo(() => {
return (balancesQuery.data?.items ?? []).reduce(
(acc, item) => {
acc.available += item.available;
acc.reserved += item.reserved;
acc.used += item.used;
return acc;
},
{ available: 0, reserved: 0, used: 0 },
);
}, [balancesQuery.data?.items]);
const createMutation = useMutation({
mutationFn: async () => {
if (!leaveTypeId) {
@@ -218,303 +233,317 @@ export function LeaveRequestPage() {
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
return (
<div className="space-y-6">
<ListWorkspace>
<PageHeader
title="请假申请"
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
/>
<div className="space-y-3">
<h3 className="text-base font-semibold text-zinc-900">额度概览</h3>
{balancesQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-3">
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
</div>
) : null}
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
<MetricCard title="本次预估" value={requestedDaysPreview ?? "-"} note="创建草稿前可先预览天数" tone={requestedDaysPreview ? "warning" : "neutral"} />
</section>
{balancesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(balancesQuery.error)}
</p>
) : null}
<div className="page-shell-grid">
<SectionPanel title="新建请假草稿" description="先填写时间段与假种,再补充附件和说明。">
<div className="space-y-4">
{formError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</p>
) : null}
{!balancesQuery.isLoading && !balancesQuery.isError ? (
<div className="grid gap-4 md:grid-cols-3">
{(balancesQuery.data?.items ?? []).length ? (
balancesQuery.data?.items.map((item) => (
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
</div>
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-zinc-500">可用</p>
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
</div>
<div>
<p className="text-zinc-500">已冻结</p>
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
</div>
<div>
<p className="text-zinc-500">已使用</p>
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
</div>
<div>
<p className="text-zinc-500">授予额度</p>
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
</div>
</div>
</div>
))
) : (
<div className="card-std p-6 text-sm text-zinc-500 md:col-span-3">
暂无可用假种,请联系 HR 初始化请假类型与策略。
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="leave-type">假期类型</Label>
<select
className="select-std"
id="leave-type"
value={leaveTypeId}
onChange={(event) => setLeaveTypeId(event.target.value)}
>
<option value="">请选择</option>
{(leaveTypesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="leave-start-date">开始日期</Label>
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-start-session">开始时段</Label>
<select
className="select-std"
id="leave-start-session"
value={startSession}
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-date">结束日期</Label>
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-session">结束时段</Label>
<select
className="select-std"
id="leave-end-session"
value={endSession}
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-attachment">附件</Label>
<Input
id="leave-attachment"
type="file"
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
onChange={(event) => {
const nextFile = event.target.files?.[0] ?? null;
setAttachmentFile(nextFile);
}}
/>
<p className="text-xs text-zinc-500">
{selectedLeaveType?.requiresAttachment
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
: "附件为可选项"}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="leave-reason">请假说明</Label>
<Textarea
id="leave-reason"
rows={3}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="请输入请假原因、备注或补充说明"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
<div>
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
</div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建草稿"}
</Button>
</div>
</div>
) : null}
</SectionPanel>
<SectionPanel title="额度概览" description="假种可用额度、冻结与已使用情况按自然周期汇总。">
<div className="space-y-4">
{balancesQuery.isLoading ? (
<div className="grid gap-4">
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
<CardSkeleton lines={4} />
</div>
) : null}
{balancesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(balancesQuery.error)}
</p>
) : null}
{!balancesQuery.isLoading && !balancesQuery.isError ? (
<div className="grid gap-4">
{(balancesQuery.data?.items ?? []).length ? (
balancesQuery.data?.items.map((item) => (
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
</div>
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-zinc-500">可用</p>
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
</div>
<div>
<p className="text-zinc-500">已冻结</p>
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
</div>
<div>
<p className="text-zinc-500">已使用</p>
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
</div>
<div>
<p className="text-zinc-500">授予额度</p>
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
</div>
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-sm text-zinc-500">
暂无可用假种,请联系 HR 初始化请假类型与策略。
</div>
)}
</div>
) : null}
</div>
</SectionPanel>
</div>
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建请假草稿</h3>
{formError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</p>
) : null}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="leave-type">假期类型</Label>
<select
className="select-std"
id="leave-type"
value={leaveTypeId}
onChange={(event) => setLeaveTypeId(event.target.value)}
>
<option value="">请选择</option>
{(leaveTypesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-start-date">开始日期</Label>
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-start-session">开始时段</Label>
<select
className="select-std"
id="leave-start-session"
value={startSession}
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-date">结束日期</Label>
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="leave-end-session">结束时段</Label>
<select
className="select-std"
id="leave-end-session"
value={endSession}
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
>
<option value="am">上午</option>
<option value="pm">下午</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="leave-attachment">附件</Label>
<Input
id="leave-attachment"
type="file"
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
onChange={(event) => {
const nextFile = event.target.files?.[0] ?? null;
setAttachmentFile(nextFile);
}}
/>
<p className="text-xs text-zinc-500">
{selectedLeaveType?.requiresAttachment
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
: "附件为可选项"}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="leave-reason">请假说明</Label>
<Textarea
id="leave-reason"
rows={3}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="请输入请假原因、备注或补充说明"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
<div>
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
</div>
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
{createMutation.isPending ? "创建中..." : "创建草稿"}
</Button>
</div>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
<div className="flex flex-wrap gap-2">
{canViewAll ? (
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
<div className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-sm text-zinc-500">最近一次筛选会保留在当前页面状态中。</div>
<div className="flex flex-wrap gap-2">
{canViewAll ? (
<select
className="select-std"
value={scope}
onChange={(event) => {
setScope(event.target.value as "own" | "all");
setPage(1);
}}
>
<option value="own">只看我的</option>
<option value="all">查看全员</option>
</select>
) : null}
<select
className="select-std"
value={scope}
value={statusFilter}
onChange={(event) => {
setScope(event.target.value as "own" | "all");
setStatusFilter(event.target.value as LeaveRequestStatus | "");
setPage(1);
}}
>
<option value="own">只看我的</option>
<option value="all">查看全员</option>
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="reviewing">审批中</option>
<option value="approved">已通过</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
) : null}
<select
className="select-std"
value={statusFilter}
onChange={(event) => {
setStatusFilter(event.target.value as LeaveRequestStatus | "");
setPage(1);
}}
>
<option value="">全部状态</option>
<option value="draft">草稿</option>
<option value="reviewing">审批中</option>
<option value="approved">已通过</option>
<option value="rejected">已驳回</option>
<option value="cancelled">已取消</option>
</select>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
刷新
</Button>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
刷新
</Button>
</div>
</div>
</div>
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
{requestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(requestsQuery.error)}
</p>
) : null}
{requestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveError(requestsQuery.error)}
</p>
) : null}
{!requestsQuery.isLoading && !requestsQuery.isError ? (
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<tr>
<TableHead>假种 / 申请人</TableHead>
<TableHead>时间段</TableHead>
<TableHead>天数</TableHead>
<TableHead>状态</TableHead>
<TableHead>附件</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-44">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{(requestsQuery.data?.items ?? []).length ? (
requestsQuery.data?.items.map((item) => {
const isOwner = item.ownerId === me?.id;
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
const canWithdraw = isOwner && item.status === "reviewing";
{!requestsQuery.isLoading && !requestsQuery.isError ? (
<>
<PrimaryTable>
<Table>
<TableHeader>
<TableRow>
<TableHead>假种 / 申请人</TableHead>
<TableHead>时间段</TableHead>
<TableHead>天数</TableHead>
<TableHead>状态</TableHead>
<TableHead>附件</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-44">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(requestsQuery.data?.items ?? []).length ? (
requestsQuery.data?.items.map((item) => {
const isOwner = item.ownerId === me?.id;
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
const canWithdraw = isOwner && item.status === "reviewing";
return (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
{item.owner?.name ?? "-"}
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
</div>
</TableCell>
<TableCell>{formatRangeLabel(item)}</TableCell>
<TableCell>{item.requestedDays}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell className="text-xs text-zinc-500">
{item.attachment?.originalName ?? "-"}
</TableCell>
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate(item.id)}
>
提交
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canWithdraw || withdrawMutation.isPending}
onClick={() =>
setWithdrawTarget({
id: item.id,
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
})
}
>
撤回
</Button>
</div>
return (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
<div className="text-xs text-zinc-500">
{item.owner?.name ?? "-"}
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
</div>
</TableCell>
<TableCell>{formatRangeLabel(item)}</TableCell>
<TableCell>{item.requestedDays}</TableCell>
<TableCell><StatusBadge status={item.status} /></TableCell>
<TableCell className="text-xs text-zinc-500">
{item.attachment?.originalName ?? "-"}
</TableCell>
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate(item.id)}
>
提交
</Button>
<Button
size="sm"
variant="secondary"
type="button"
disabled={!canWithdraw || withdrawMutation.isPending}
onClick={() =>
setWithdrawTarget({
id: item.id,
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
})
}
>
撤回
</Button>
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={7}>
暂无请假记录
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell className="py-4 text-zinc-500" colSpan={7}>
暂无请假记录
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
) : null}
)}
</TableBody>
</Table>
</PrimaryTable>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="leave-requests"
/>
</div>
<Pagination
page={page}
totalPages={totalPages}
onPageChange={setPage}
testIdPrefix="leave-requests"
/>
</>
) : null}
</div>
</SectionPanel>
<ConfirmDialog
open={Boolean(withdrawTarget)}
@@ -531,6 +560,6 @@ export function LeaveRequestPage() {
await withdrawMutation.mutateAsync(withdrawTarget.id);
}}
/>
</div>
</ListWorkspace>
);
}
+117 -107
查看文件
@@ -88,7 +88,7 @@ export function LoginPage() {
} else if (err instanceof TypeError) {
setError("无法连接后端服务,请确认 server 已启动(http://127.0.0.1:3000)。");
} else {
setError("登录失败,请稍后重试");
setError("登录失败,请稍后重试。");
}
} finally {
setSubmitting(false);
@@ -102,125 +102,135 @@ export function LoginPage() {
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo));
}
function renderPasswordPanel(testId: string, primary: boolean) {
return (
<div className="space-y-4 rounded-[1.5rem] border border-zinc-200 bg-white p-6 shadow-sm" data-testid={testId}>
<div>
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">{primary ? "推荐入口" : "回退入口"}</div>
<h2 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900">使用账号密码登录</h2>
<p className="mt-2 text-sm leading-6 text-zinc-600">
{primary
? "适合桌面浏览器直接登录;若企业微信入口暂未开放,也可正常使用。"
: "企业微信免登失败、未绑定或超时时,可改用账号密码继续进入。"}
</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-2">
<Label htmlFor="login-identifier">账号</Label>
<Input
autoComplete="username"
data-testid="login-identifier"
id="login-identifier"
onChange={(event) => setIdentifier(event.target.value)}
placeholder="邮箱或手机号"
value={identifier}
/>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">密码</Label>
<Input
autoComplete="current-password"
data-testid="login-password"
id="login-password"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
type="password"
value={password}
/>
</div>
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null}
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primary ? "default" : "secondary"}>
{submitting ? "登录中..." : "登录并进入工作台"}
</Button>
</form>
</div>
);
}
return (
<div className="card-std overflow-hidden border-primary-100 p-0">
<div className="border-b border-zinc-200 bg-gradient-to-br from-primary-50 via-white to-zinc-50 p-6">
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900">登录工作台</h1>
<p className="mt-2 text-sm text-secondary">审批、项目、周报和团队状态统一在 OA 完成。</p>
<div className="mt-4 rounded-xl border border-zinc-200 bg-white/90 px-4 py-3 text-sm text-zinc-600">
<div className="card-std overflow-hidden border-zinc-200 p-0 shadow-md">
<div className="border-b border-zinc-200 bg-gradient-to-br from-zinc-50 via-white to-zinc-100 px-6 py-6 sm:px-8 sm:py-7">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">统一入口</div>
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-zinc-900">登录海音达工作台</h1>
<p className="mt-3 max-w-2xl text-sm leading-6 text-zinc-600">
企业微信负责进入与提醒,真正的审批、项目、周报和团队留痕都在海音达工作台内完成。
</p>
<div className="mt-4 rounded-2xl border border-zinc-200 bg-white/90 px-4 py-3 text-sm text-zinc-600">
{isWecomContext
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台。"
: "桌面浏览器可直接使用账号密码登录;企业微信仅作为入口与提醒。"}
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台;失败时可随时回退到账号密码登录。"
: "当前为浏览器场景,推荐直接使用账号密码登录;企业微信入口会作为次级入口提供。"}
</div>
</div>
<div className="space-y-5 p-6">
<div className="space-y-5 px-6 py-6 sm:px-8 sm:py-7">
{wecomNotice ? (
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
<div className="font-medium">{wecomNotice.title}</div>
<div className="mt-1">{wecomNotice.description}</div>
<div className="mt-1 leading-6">{wecomNotice.description}</div>
</div>
) : null}
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
<div className="space-y-3 rounded-2xl border border-primary-100 bg-primary-50/70 p-5" data-testid="login-wecom-primary-panel">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-primary">推荐入口</p>
<h2 className="mt-1 text-lg font-semibold text-zinc-900">使用企业微信进入工作台</h2>
<p className="mt-1 text-sm text-zinc-600">在企业微信工作台打开时,优先使用免登入口;失败时仍可回退到账号密码登录。</p>
</div>
<Button className="w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
使用企业微信进入工作台
</Button>
<p className="text-xs text-zinc-500">若当前企业微信账号尚未绑定,请先使用账号密码登录,再到个人设置完成绑定。</p>
</div>
) : null}
<div className="space-y-4 rounded-2xl border border-zinc-200 p-5" data-testid={primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel"}>
<div>
<h2 className="text-lg font-semibold text-zinc-900">账号密码登录</h2>
<p className="mt-1 text-sm text-zinc-500">
{primaryMode === "password"
? "使用公司邮箱或手机号登录,复杂填写与审批仍建议在 Web OA 内完成。"
: "企业微信不可用时,可改用账号密码登录。"}
</p>
<p className="mt-1 text-xs text-zinc-500">忘记密码:请联系管理员重置。</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
<Label htmlFor="identifier">账号</Label>
<Input
autoComplete="username"
data-testid="login-identifier"
id="identifier"
onChange={(event) => setIdentifier(event.target.value)}
placeholder="邮箱或手机号"
value={identifier}
/>
</div>
<div className="space-y-1">
<Label htmlFor="password">密码</Label>
<Input
autoComplete="current-password"
data-testid="login-password"
id="password"
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
type="password"
value={password}
/>
</div>
{error ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="login-error"
>
{error}
</p>
) : null}
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primaryMode === "password" ? "default" : "secondary"}>
{submitting ? "登录中..." : "登录"}
</Button>
</form>
</div>
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
<div className="space-y-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">其他入口</p>
<p className="mt-1 text-sm text-zinc-600">企业微信负责打开 OA 和接收提醒,实际业务仍在 OA 内完成。</p>
</div>
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
<div className="grid gap-4">
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
<div className="rounded-[1.5rem] border border-primary-100 bg-primary-50/70 p-6" data-testid="login-wecom-primary-panel">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">推荐入口</div>
<h2 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900">使用企业微信进入工作台</h2>
<p className="mt-2 text-sm leading-6 text-zinc-600">当前是企业微信场景,优先使用免登入口;若未绑定或回调失效,仍可切换为账号密码登录。</p>
<Button className="mt-5 w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
使用企业微信进入工作台
</Button>
) : null}
{oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
) : null}
</div>
) : null}
</div>
) : null}
{isWecomContext && !wecomEntry?.loginUrl && !wecomConfigQuery.isLoading ? (
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomStatus.tone)}`}>
<div className="font-medium">企业微信入口当前不可用</div>
<div className="mt-1">{wecomStatus.description}</div>
</div>
) : null}
{renderPasswordPanel(
primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel",
primaryMode === "password",
)}
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
<div className="rounded-[1.5rem] border border-zinc-200 bg-zinc-50/70 p-6">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">补充入口</div>
<h2 className="mt-2 text-lg font-semibold text-zinc-900">其他进入方式</h2>
<p className="mt-2 text-sm leading-6 text-zinc-600">入口失败时请优先选择可理解、可回退的方式继续进入。</p>
<div className="mt-4 grid gap-3">
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
使用企业微信进入工作台
</Button>
) : null}
{oidcProvider ? (
<Button
className="w-full"
type="button"
variant="secondary"
onClick={() => {
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
}}
>
使用{oidcProvider.label}
</Button>
) : null}
</div>
</div>
) : null}
</div>
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
<div className="font-medium text-zinc-900">企业微信入口状态:{wecomStatus.label}</div>
<div className="mt-1 leading-6">{wecomStatus.description}</div>
{wecomStatus.reasonLabels.length ? (
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-zinc-500">
{wecomStatus.reasonLabels.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
) : null}
</div>
</div>
</div>
);
+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;
+136 -105
查看文件
@@ -1,8 +1,10 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { fetchWecomConfig, startWecomBind, unbindWecom } from "../api/auth";
import { PageHeader } from "../components/common/PageHeader";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/common/PageHeader";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
resolveWecomBindNotice,
@@ -27,6 +29,12 @@ function maskExternalUserId(value: string) {
return `${value.slice(0, 4)}****${value.slice(-4)}`;
}
function statusTone(status?: string) {
if (status === "active") return "badge-success";
if (status === "inactive") return "badge-warning";
return "badge-neutral";
}
export function ProfilePage() {
const { me, permissions } = useAuth();
const [searchParams] = useSearchParams();
@@ -35,12 +43,14 @@ export function ProfilePage() {
queryFn: () => fetchWecomConfig(),
staleTime: 60_000,
});
const bindMutation = useMutation({
mutationFn: async () => startWecomBind("/settings/profile"),
onSuccess: (payload) => {
window.location.assign(payload.loginUrl);
},
});
const unbindMutation = useMutation({
mutationFn: () => unbindWecom(),
onSuccess: () => {
@@ -53,115 +63,136 @@ export function ProfilePage() {
const isBound = Boolean(me?.wecomBinding?.externalUserId);
return (
<div>
<PageHeader title="个人设置" subtitle="维护个人信息、权限摘要和企业微信绑定状态。" />
<PageScaffold>
<PageHeader
title="个人设置"
eyebrow="账号与入口"
subtitle="维护个人资料、角色摘要与企业微信绑定状态。复杂业务仍在海音达工作台内完成,企业微信只负责进入与提醒。"
meta={<span className={statusTone(me?.status)}>{me?.status === "active" ? "账号正常" : "账号状态待确认"}</span>}
/>
<section className="card-std">
<h3 className="card-title">个人信息</h3>
<dl className="grid gap-3 text-sm text-zinc-700 md:grid-cols-2">
<div>
<dt className="text-secondary">姓名</dt>
<dd className="font-medium text-zinc-900">{me?.name ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">员工号</dt>
<dd className="font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">邮箱</dt>
<dd className="font-medium text-zinc-900">{me?.email ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">手机号</dt>
<dd className="font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">部门 ID</dt>
<dd className="font-medium text-zinc-900">{me?.departmentId ?? "-"}</dd>
</div>
<div>
<dt className="text-secondary">直属上级 ID</dt>
<dd className="font-medium text-zinc-900">{me?.directManagerId ?? "-"}</dd>
</div>
</dl>
</section>
<section className="card-std mt-4 space-y-4">
<div>
<h3 className="card-title">企业微信绑定</h3>
<p className="text-sm text-zinc-500">绑定后可从企业微信工作台直接进入 OA;企业微信只做入口与提醒,不承接第二套业务录入。</p>
</div>
{bindNotice ? (
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
<div className="font-medium">{bindNotice.title}</div>
<div className="mt-1">{bindNotice.description}</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-zinc-200 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">接入状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
<div className="mt-1 text-sm text-zinc-600">{wecomStatus.description}</div>
{wecomStatus.reasonLabels.length ? (
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-zinc-500">
{wecomStatus.reasonLabels.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
) : null}
</div>
<div className="rounded-2xl border border-zinc-200 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">当前绑定状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
<div className="mt-1 text-sm text-zinc-600">
{isBound
? "已完成绑定,下次可从企业微信工作台直接进入 OA。"
: "未绑定时,企业微信登录会回退到账号密码登录。"}
</div>
{isBound ? (
<div className="mt-3 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500">
绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}<br />
绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}
<div className="page-shell-grid">
<div className="space-y-6">
<SectionPanel title="基本信息" description="员工可直接理解的个人资料与联系方式。">
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
<div>
<dt className="text-zinc-500">姓名</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
</div>
) : null}
</div>
<div>
<dt className="text-zinc-500">员工号</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
</div>
<div>
<dt className="text-zinc-500">邮箱</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.email ?? "-"}</dd>
</div>
<div>
<dt className="text-zinc-500">手机号</dt>
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
</div>
</dl>
</SectionPanel>
<SectionPanel title="企业微信绑定" description="绑定后可从企业微信工作台直接进入海音达工作台;消息只做提醒与回流,不承接第二套业务录入。">
<div className="space-y-4" data-testid="profile-wecom-card">
{bindNotice ? (
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
<div className="font-medium">{bindNotice.title}</div>
<div className="mt-1">{bindNotice.description}</div>
</div>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="surface-muted px-4 py-4">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">接入状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">{wecomStatus.description}</div>
{wecomStatus.reasonLabels.length ? (
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-zinc-500">
{wecomStatus.reasonLabels.map((label) => (
<li key={label}>{label}</li>
))}
</ul>
) : null}
</div>
<div className="surface-muted px-4 py-4">
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">当前绑定状态</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
<div className="mt-2 text-sm leading-6 text-zinc-600">
{isBound ? "下次可直接从企业微信工作台进入海音达工作台。" : "未绑定时,企业微信登录会自动回退到账号密码登录。"}
</div>
{isBound ? (
<div className="mt-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 text-xs text-zinc-500">
<div>绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}</div>
<div className="mt-1">绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}</div>
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
onClick={() => bindMutation.mutate()}
>
{isBound ? "重新绑定企业微信" : "立即绑定企业微信"}
</Button>
<Button
type="button"
variant="secondary"
disabled={!isBound || unbindMutation.isPending}
onClick={() => unbindMutation.mutate()}
>
解绑
</Button>
</div>
</div>
</SectionPanel>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
onClick={() => bindMutation.mutate()}
>
{isBound ? "重新绑定企业微信" : "立即绑定企业微信"}
</Button>
<Button
type="button"
variant="secondary"
disabled={!isBound || unbindMutation.isPending}
onClick={() => unbindMutation.mutate()}
>
解绑
</Button>
</div>
</section>
<div className="space-y-6">
<SectionPanel title="组织信息" description="对员工展示可理解的说明,不直接暴露内部标识字段。">
<div className="space-y-4 text-sm text-zinc-600">
<div className="surface-muted px-4 py-4">
<div className="font-medium text-zinc-900">组织归属</div>
<div className="mt-2 leading-6">部门与直属汇报关系由管理员统一维护,当前个人页只展示员工可理解的设置入口,避免把内部 ID 直接暴露给员工。</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-zinc-200 px-4 py-4">
<div className="text-zinc-500">部门维护状态</div>
<div className="mt-2 font-medium text-zinc-900">{me?.departmentId ? "已关联组织" : "待管理员维护"}</div>
</div>
<div className="rounded-2xl border border-zinc-200 px-4 py-4">
<div className="text-zinc-500">直属汇报关系</div>
<div className="mt-2 font-medium text-zinc-900">{me?.directManagerId ? "已维护" : "待管理员维护"}</div>
</div>
</div>
</div>
</SectionPanel>
<section className="card-std mt-4">
<h3 className="card-title">权限列表</h3>
<div className="flex flex-wrap gap-2">
{permissions.map((permission) => (
<span
className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary"
key={permission}
>
{permission}
</span>
))}
<SectionPanel title="账号状态与角色" description="角色与权限保持透明,但不让设置页变成技术字段堆叠。">
<div className="space-y-4 text-sm text-zinc-600">
<div className="flex flex-wrap gap-2">
<span className={statusTone(me?.status)}>{me?.status === "active" ? "active" : me?.status ?? "unknown"}</span>
{(me?.roleCodes ?? []).map((role) => (
<span className="badge-neutral" key={role}>{role}</span>
))}
</div>
<div>
<div className="text-sm font-medium text-zinc-900">权限摘要</div>
<div className="mt-2 flex flex-wrap gap-2">
{permissions.map((permission) => (
<span className="badge-neutral" key={permission}>{permission}</span>
))}
</div>
</div>
</div>
</SectionPanel>
</div>
</section>
</div>
</div>
</PageScaffold>
);
}
+384 -375
查看文件
@@ -5,8 +5,12 @@ import { fetchEmployees } from "../api/employees";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
import { fetchContractsRegistry } from "../api/contractsRegistry";
import { mapApiErrorMessage } from "../api/errorMessages";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { MetricCard } from "../components/patterns/MetricCard";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -486,10 +490,19 @@ export function ProjectDetailPage() {
const riskItems = risksQuery.data?.items ?? detail.risks;
return (
<div className="space-y-6">
<PageHeader
<PageScaffold>
<EntityOverviewHero
title={`${detail.projectNo} · ${detail.name}`}
subtitle="项目主档、成员、里程碑、任务、风险、费用、周报与资料引用统一查看。"
subtitle="项目概览、执行、留痕与归档在同一工作台内查看和处理。"
meta={
<>
<StatusBadge status={detail.status} />
{detail.isConfidential ? <span className="badge-warning">涉密项目</span> : null}
<span className="info-chip">客户:{detail.clientAlias || detail.clientName}</span>
<span className="info-chip">负责人:{detail.owner?.name ?? "-"}</span>
<span className="info-chip">项目经理:{detail.manager?.name ?? "-"}</span>
</>
}
actions={
<div className="flex flex-wrap gap-2">
<Button asChild variant="secondary" type="button">
@@ -505,14 +518,14 @@ export function ProjectDetailPage() {
}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<div className="card-std p-4"><div className="text-sm text-zinc-500">成员</div><div className="mt-2 text-2xl font-bold">{counts?.members ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">里程碑</div><div className="mt-2 text-2xl font-bold">{counts?.milestones ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">任务</div><div className="mt-2 text-2xl font-bold">{counts?.tasks ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">风险</div><div className="mt-2 text-2xl font-bold">{counts?.risks ?? 0}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">报销合计</div><div className="mt-2 text-2xl font-bold">{expensesQuery.data?.totalAmount ?? "0.00"}</div></div>
<div className="card-std p-4"><div className="text-sm text-zinc-500">差旅合计</div><div className="mt-2 text-2xl font-bold">{travelQuery.data?.totalAmount ?? "0.00"}</div></div>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<MetricCard title="成员" value={counts?.members ?? 0} note="执行角色与观察者" />
<MetricCard title="里程碑" value={counts?.milestones ?? 0} note="用于跟踪关键节点" />
<MetricCard title="任务" value={counts?.tasks ?? 0} note="按优先级与状态推进" />
<MetricCard title="风险" value={counts?.risks ?? 0} note="高风险建议指定责任人" tone={(counts?.risks ?? 0) > 0 ? "warning" : "neutral"} />
<MetricCard title="报销合计" value={expensesQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联报销" />
<MetricCard title="差旅合计" value={travelQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联差旅" />
</section>
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
<div className="card-std border-amber-200 bg-amber-50 p-4" data-testid="project-closure-warning">
@@ -655,379 +668,375 @@ export function ProjectDetailPage() {
</div>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-zinc-900">项目主档</h3>
<StatusBadge status={detail.status} />
{detail.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密项目</span> : null}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>状态</Label>
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="space-y-2">
<Label>开始日期</Label>
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>实际完成</Label>
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
<option value="">未指定</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
项目包含涉密资料,正文与附件保持离线管理
</label>
<div className="flex justify-end">
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
</Button>
</div>
</div>
<div className="card-std space-y-3 p-6 text-sm">
<h3 className="text-base font-semibold text-zinc-900">项目摘要</h3>
<div>负责人:{detail.owner?.name ?? "-"}</div>
<div>项目经理:{detail.manager?.name ?? "-"}</div>
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
<div>合同台账:{counts?.contracts ?? 0}</div>
<div>交付记录:{counts?.deliveries ?? 0}</div>
<div>资料引用:{counts?.documentRefs ?? 0}</div>
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">成员</h3>
<div className="text-sm text-zinc-500">当前 {memberItems.length} 人</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
<option value="">选择成员</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
添加成员
</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
<TableBody>
{memberItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
</select>
</TableCell>
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
<TableCell>
{item.leftAt ? "已移出" : (
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">里程碑</h3>
<div className="text-sm text-zinc-500">逾期请及时调整状态</div>
</div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{milestoneItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.title}</TableCell>
<TableCell>{item.dueDate ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">任务</h3><div className="text-sm text-zinc-500">支持高优先级与阻塞状态</div></div>
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
<option value="">未指派</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{taskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
<option value="">未指派</option>
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">风险</h3><div className="text-sm text-zinc-500">问题与阻塞统一留痕</div></div>
<div className="space-y-3">
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
<option value="">未指定责任人</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{riskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.detail}</div>
</TableCell>
<TableCell>{item.level}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">执行面板</h3><div className="text-sm text-zinc-500">人、事、费、周报统一查看</div></div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">报销</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.formNumber} · {item.totalAmount}
</Link>
))}
<div className="detail-shell-grid">
<div className="space-y-6">
<SectionPanel title="概览与主档" description="先看主信息,再统一维护时间、负责人和涉密属性。">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>项目名称</Label>
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户名称</Label>
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>客户简称</Label>
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>状态</Label>
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div className="space-y-2">
<Label>开始日期</Label>
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>目标完成</Label>
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>实际完成</Label>
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>项目经理</Label>
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
<option value="">未指定</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
</div>
</div>
<div className="space-y-2">
<Label>项目摘要</Label>
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
项目包含涉密资料,正文与附件保持离线管理
</label>
<div className="flex justify-end">
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
</Button>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">差旅</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.requestNo} · {item.estimatedAmount}
</Link>
))}
</SectionPanel>
<div className="grid gap-6 xl:grid-cols-2">
<SectionPanel title="执行 · 成员" description={`当前 ${memberItems.length} 人`}>
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
<option value="">选择成员</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
</select>
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
添加成员
</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
<TableBody>
{memberItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
</select>
</TableCell>
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
<TableCell>
{item.leftAt ? "已移出" : (
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">项目周报</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
</Link>
))}
</SectionPanel>
<SectionPanel title="执行 · 里程碑" description="逾期请及时调整状态。">
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{milestoneItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.title}</TableCell>
<TableCell>{item.dueDate ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">合同台账</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
{item.contractNo} · {item.counterpartyName}
</SectionPanel>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<SectionPanel title="执行 · 任务" description="支持高优先级与阻塞状态。">
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
<option value="">未指派</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
</div>
<Table>
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{taskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
<option value="">未指派</option>
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
<SectionPanel title="执行 · 风险" description="问题与阻塞统一留痕。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
<option value="">未指定责任人</option>
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
</select>
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
<TableBody>
{riskItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.detail}</div>
</TableCell>
<TableCell>{item.level}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
<TableCell>
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<SectionPanel title="留痕 · 交付记录" description="现场、验收、交接统一留痕。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
</select>
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
<TableBody>
{(deliveriesQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.summary}</div>
</TableCell>
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
<SectionPanel title="归档与合规 · 涉密资料引用" description="只登记目录、链接和归档位置,正文与附件仍离线管理。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
<div className="grid gap-3 md:grid-cols-2">
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
</div>
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
</select>
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
</select>
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
<TableBody>
{(docsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
</TableCell>
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</SectionPanel>
</div>
<SectionPanel title="留痕 · 项目周报关联" description="按项目查看每周进展、风险与下一步动作。">
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
<TableBody>
{(weeklyQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? "-"}</TableCell>
<TableCell>{item.weekStart ?? "-"}</TableCell>
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
<TableCell>{item.nextStepText || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionPanel>
</div>
<div className="space-y-6">
<DetailSideMeta title="项目摘要" description="优先帮助项目负责人判断当前状态、关联量和下一步动作。">
<div>负责人:{detail.owner?.name ?? "-"}</div>
<div>项目经理:{detail.manager?.name ?? "-"}</div>
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
<div>合同台账:{counts?.contracts ?? 0}</div>
<div>交付记录:{counts?.deliveries ?? 0}</div>
<div>资料引用:{counts?.documentRefs ?? 0}</div>
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
</div>
</DetailSideMeta>
<SectionPanel title="执行摘要" description="人、事、费、周报统一查看,帮助快速切回具体页面。">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">报销</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.formNumber} · {item.totalAmount}
</Link>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">差旅</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
<div className="mt-2 space-y-2 text-sm">
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.requestNo} · {item.estimatedAmount}
</Link>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">项目周报</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
</Link>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-200 p-4">
<div className="text-sm text-zinc-500">合同台账</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
{item.contractNo} · {item.counterpartyName}
</div>
))}
</div>
</div>
</div>
</div>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">交付记录</h3><div className="text-sm text-zinc-500">现场、验收、交接留痕</div></div>
<div className="space-y-3">
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
</select>
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
<TableBody>
{(deliveriesQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.summary}</div>
</TableCell>
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
<TableCell>{item.owner?.name ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</SectionPanel>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">涉密资料引用</h3><div className="text-sm text-zinc-500">只登记目录 / 链接 / 归档位置</div></div>
<div className="space-y-3">
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
<div className="grid gap-3 md:grid-cols-2">
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
</div>
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
</select>
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
</select>
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
<TableBody>
{(docsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
</TableCell>
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">项目周报关联</h3><div className="text-sm text-zinc-500">按项目查看每周进展</div></div>
<Table>
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
<TableBody>
{(weeklyQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>{item.employee?.name ?? "-"}</TableCell>
<TableCell>{item.weekStart ?? "-"}</TableCell>
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
<TableCell>{item.nextStepText || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</PageScaffold>
);
}
+73 -39
查看文件
@@ -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>
);
}
+142 -144
查看文件
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
@@ -11,15 +11,21 @@ import { hasPermission } from "../auth/permissions";
import { toast } from "sonner";
import { downloadBlob } from "../lib/download";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { StatusBadge } from "../components/common/StatusBadge";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
const PAGE_SIZE = 20;
function formatCurrency(value: number) {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
}
export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const { permissions, me } = useAuth();
@@ -47,6 +53,20 @@ export function ReimbursementsPage() {
queryFn: () => fetchMyDelegations("active"),
});
const items = listQuery.data?.items ?? [];
const stats = useMemo(() => {
return items.reduce(
(acc, item) => {
acc.currentPageCount += 1;
acc.currentPageAmount += Number(item.totalAmount ?? 0);
if (item.status === "reviewing") acc.reviewing += 1;
if (item.status === "paid") acc.paid += 1;
return acc;
},
{ currentPageCount: 0, currentPageAmount: 0, reviewing: 0, paid: 0 },
);
}, [items]);
const createMutation = useMutation({
mutationFn: () =>
createReimbursement({
@@ -87,7 +107,7 @@ export function ReimbursementsPage() {
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
return (
<div className="space-y-6">
<ListWorkspace>
<PageHeader
title="我的报销"
subtitle="报销列表、详情与提交/撤回/支付动作入口。"
@@ -167,86 +187,62 @@ export function ReimbursementsPage() {
</p>
) : null}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="card-std">
<p className="card-title">待我审批</p>
<div className="flex items-end justify-between">
<h3 className="card-value">12</h3>
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
2.4%
</div>
</div>
</div>
<div className="card-std">
<p className="card-title">已报销总额</p>
<div className="flex items-end justify-between">
<h3 className="card-value">¥124,500</h3>
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
5.1%
</div>
</div>
</div>
<div className="card-std">
<p className="card-title">异常警报</p>
<div className="flex items-end justify-between">
<h3 className="card-value">2</h3>
<div className="flex items-center text-red-500 text-xs font-bold mb-1">
<span className="material-symbols-outlined text-[16px]">arrow_downward</span>
1.2%
</div>
</div>
</div>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条记录`} />
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="按当前页与筛选统计" />
<MetricCard title="审批中" value={stats.reviewing} note="待继续推进的报销单" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
<MetricCard title="已支付" value={stats.paid} note={`当前范围:${scope === "all" ? "全范围" : "仅自己"}`} />
</section>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-2">
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
{[
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
].map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
<FilterBar className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
{[
{ value: "draft", label: "草稿" },
{ value: "reviewing", label: "审批中" },
{ value: "approved", label: "已通过" },
{ value: "paid", label: "已支付" },
{ value: "rejected", label: "已驳回" },
{ value: "cancelled", label: "已取消" },
].map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{canViewAll ? (
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="reimbursements-scope-own"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{canViewAll ? (
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="reimbursements-scope-own"
variant={scope === "own" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
type="button"
>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
</div>
) : null}
</div>
仅自己
</Button>
<Button
data-testid="reimbursements-scope-all"
variant={scope === "all" ? "default" : "secondary"}
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
type="button"
>
全范围
</Button>
</div>
) : null}
</div>
</FilterBar>
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
@@ -263,71 +259,73 @@ export function ReimbursementsPage() {
) : null}
{!listQuery.isLoading && !listQuery.isError ? (
<div className="table-container mt-4">
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
<thead className="table-header">
<tr>
<th className="table-th">单号</th>
<th className="table-th">状态</th>
<th className="table-th">金额</th>
<th className="table-th">报销人</th>
<th className="table-th">日期</th>
<th className="table-th w-24">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50">
{(listQuery.data?.items ?? []).length ? (
listQuery.data?.items.map((item) => (
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
<td className="table-td">
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
{item.formNumber && item.formNumber !== item.id && (
<div className="text-xs text-zinc-500">{item.id}</div>
)}
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</div> : null}
</td>
<td className="table-td"><StatusBadge status={item.status} /></td>
<td className="table-td-strong">
{item.totalAmount != null
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(item.totalAmount))
: "-"}
</td>
<td className="table-td">
<div className="flex items-center gap-2">
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
{(item.owner?.name ?? "U").charAt(0)}
<>
<PrimaryTable>
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
<thead className="table-header">
<tr>
<th className="table-th">单号</th>
<th className="table-th">状态</th>
<th className="table-th">金额</th>
<th className="table-th">报销人</th>
<th className="table-th">日期</th>
<th className="table-th w-24">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50">
{items.length ? (
items.map((item) => (
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
<td className="table-td">
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
{item.formNumber && item.formNumber !== item.id && (
<div className="text-xs text-zinc-500">{item.id}</div>
)}
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</div> : null}
</td>
<td className="table-td"><StatusBadge status={item.status} /></td>
<td className="table-td-strong">
{item.totalAmount != null
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(item.totalAmount))
: "-"}
</td>
<td className="table-td">
<div className="flex items-center gap-2">
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
{(item.owner?.name ?? "U").charAt(0)}
</div>
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
</div>
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
</div>
</td>
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
<td className="table-td">
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
</Link>
</td>
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
<td className="table-td">
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
</Link>
</td>
</tr>
))
) : (
<tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
暂无数据
</td>
</tr>
))
) : (
<tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
暂无数据
</td>
</tr>
)}
</tbody>
</table>
</div>
) : null}
)}
</tbody>
</table>
</PrimaryTable>
<div className="mt-4">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
testIdPrefix="reimbursements"
/>
</div>
</div>
<div className="mt-4">
<Pagination
page={page}
totalPages={totalPages}
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
testIdPrefix="reimbursements"
/>
</div>
</>
) : null}
</ListWorkspace>
);
}
+124 -60
查看文件
@@ -1,8 +1,11 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
import { fetchProjects } from "../api/projects";
import { PageHeader } from "../components/common/PageHeader";
import { FilterBar, ListWorkspace, PrimaryTable } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -32,6 +35,20 @@ export function TodosPage() {
queryFn: () => fetchProjects({}),
});
const todoItems = todosQuery.data?.items ?? [];
const todoStats = useMemo(() => {
return todoItems.reduce(
(acc, item) => {
acc.total += 1;
if (item.status === "todo") acc.todo += 1;
if (item.status === "doing") acc.doing += 1;
if (item.status === "done") acc.done += 1;
return acc;
},
{ total: 0, todo: 0, doing: 0, done: 0 },
);
}, [todoItems]);
const createMutation = useMutation({
mutationFn: () => createTodo({ title, description: description || undefined, projectId: projectId || null }),
onSuccess: async () => {
@@ -72,69 +89,116 @@ export function TodosPage() {
});
return (
<div className="space-y-6">
<ListWorkspace>
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。支持直接挂到项目。" />
<div className="card-std p-6 space-y-4">
<div className="grid gap-3 md:grid-cols-4">
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
<div className="flex gap-2">
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
<option value="">全部状态</option>
<option value="todo">todo</option>
<option value="doing">doing</option>
<option value="done">done</option>
<option value="cancelled">cancelled</option>
</select>
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>新建待办</Button>
</div>
</div>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="全部待办" value={todoStats.total} note="当前筛选结果" />
<MetricCard title="待处理" value={todoStats.todo} note="建议优先清空新增事项" tone={todoStats.todo > 0 ? "warning" : "neutral"} />
<MetricCard title="进行中" value={todoStats.doing} note="重点跟进阻塞项" />
<MetricCard title="已完成" value={todoStats.done} note="已完成项会持续留痕" />
</section>
<div className="card-std p-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>标题</TableHead>
<TableHead>项目</TableHead>
<TableHead>负责人</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(todosQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
</TableCell>
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
<TableCell>{item.priority}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>完成</Button>
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>取消</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="page-shell-grid">
<div className="space-y-6">
<FilterBar className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-zinc-500">按状态筛选后,列表只保留当前关注的工作项。</div>
<div className="flex flex-wrap items-center gap-2">
<select className="select-std w-[180px]" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
<option value="">全部状态</option>
<option value="todo">todo</option>
<option value="doing">doing</option>
<option value="done">done</option>
<option value="cancelled">cancelled</option>
</select>
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["todos"] })}>
刷新列表
</Button>
</div>
</FilterBar>
<SectionPanel title="待办列表" description={todoItems.length ? `当前共 ${todoItems.length} 条待办` : "暂无待办,建议从当前周计划或项目动作拆出任务。"}>
{todosQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(todosQuery.error, "加载待办失败")}</div> : null}
<PrimaryTable>
<Table>
<TableHeader>
<TableRow>
<TableHead>标题</TableHead>
<TableHead>项目</TableHead>
<TableHead>负责人</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{todosQuery.isLoading ? (
<TableRow>
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
待办加载中...
</TableCell>
</TableRow>
) : todoItems.length ? (
todoItems.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.title}</div>
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
</TableCell>
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
<TableCell>{item.priority}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>
完成
</Button>
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>
取消
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
当前没有符合条件的待办。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</PrimaryTable>
</SectionPanel>
</div>
<SectionPanel title="新增待办" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
<div className="space-y-4">
<div className="space-y-2">
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
</div>
<div className="space-y-2">
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
</div>
<div className="space-y-2">
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<option value="">不关联项目</option>
{(projectsQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
</div>
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
{createMutation.isPending ? "新建中..." : "新建待办"}
</Button>
</div>
</SectionPanel>
</div>
</div>
</ListWorkspace>
);
}
+97 -64
查看文件
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTodo } from "../api/todos";
import { fetchProjects } from "../api/projects";
@@ -6,6 +6,9 @@ import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, upda
import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import { Textarea } from "../components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
@@ -73,6 +76,11 @@ export function WeeklyReportsPage() {
nextStepText: item.nextStepText.trim() || undefined,
}));
const linkedProjectCount = useMemo(
() => projectLinks.filter((item) => item.projectId).length,
[projectLinks],
);
const saveMutation = useMutation({
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft, projectLinks: normalizedProjectLinks }),
onSuccess: async () => {
@@ -119,40 +127,54 @@ export function WeeklyReportsPage() {
});
return (
<div className="space-y-6">
<ListWorkspace>
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划,可按项目拆分进展。" />
<div className="card-std p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-zinc-500">选择周起始日(默认自动按周一归档)</div>
<input className="input-std" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
<FilterBar className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium text-zinc-900">按周归档</div>
<div className="text-sm text-zinc-500">默认按周一归档;提交后会同步更新 Portal 与项目摘要。</div>
</div>
<input className="input-std w-[220px]" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
</FilterBar>
<div className="grid gap-4">
<div className="grid gap-2">
<label className="text-sm text-zinc-500">本周完成</label>
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">当前阻塞</label>
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">下周计划</label>
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
需要老板/管理员协助
</label>
</div>
</div>
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard title="当前状态" value={report?.status === "submitted" ? "已提交" : "待完善"} note={report?.submittedAt ? `提交于 ${new Date(report.submittedAt).toLocaleString()}` : "保存后可继续补充"} tone={report?.status === "submitted" ? "neutral" : "warning"} />
<MetricCard title="关联项目" value={linkedProjectCount} note={`共 ${projectsQuery.data?.items.length ?? 0} 个我的项目可选`} />
<MetricCard title="需协助" value={draft.needsHelp ? "是" : "否"} note="勾选后会在管理首页高亮" tone={draft.needsHelp ? "warning" : "neutral"} />
<MetricCard title="团队缺口" value={canViewTeam ? (teamQuery.data?.summary.missingCount ?? 0) : "-"} note={canViewTeam ? `待协助 ${teamQuery.data?.summary.needsHelpCount ?? 0}` : "仅管理员可见"} tone={canViewTeam && (teamQuery.data?.summary.missingCount ?? 0) > 0 ? "warning" : "neutral"} />
</section>
<div className="card-std p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">项目关联</h3>
<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => [...prev, emptyProjectLink()])}>新增项目进展</Button>
<SectionPanel title="本周周报" description="先写本周完成,再补当前阻塞和下周计划。">
<div className="space-y-4">
{reportQuery.isLoading ? <div className="text-sm text-zinc-500">周报加载中...</div> : null}
{reportQuery.isError ? <div className="text-sm text-red-600">{errorMessage(reportQuery.error, "加载周报失败")}</div> : null}
<div className="grid gap-4">
<div className="grid gap-2">
<label className="text-sm text-zinc-500">本周完成</label>
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">当前阻塞</label>
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
</div>
<div className="grid gap-2">
<label className="text-sm text-zinc-500">下周计划</label>
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
需要老板/管理员协助
</label>
</div>
</div>
</SectionPanel>
<SectionPanel
title="项目关联"
description="项目进展、风险与下一步动作建议按项目拆开写。"
actions={<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => [...prev, emptyProjectLink()])}>新增项目进展</Button>}
>
<div className="space-y-4">
{projectLinks.map((item, index) => (
<div key={`${item.projectId}-${index}`} className="rounded-lg border border-zinc-200 p-4 space-y-3">
@@ -170,47 +192,58 @@ export function WeeklyReportsPage() {
<Textarea placeholder="下一步动作" value={item.nextStepText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, nextStepText: event.target.value } : entry))} />
</div>
))}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
</div>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
</div>
</div>
</SectionPanel>
{canViewTeam ? (
<div className="card-std p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">团队汇总</h3>
<div className="text-sm text-zinc-500">
未提交 {teamQuery.data?.summary.missingCount ?? 0} · 需协助 {teamQuery.data?.summary.needsHelpCount ?? 0}
</div>
<SectionPanel title="团队汇总" description="帮助老板和管理员先看到未提交与需协助人员。">
<div className="mb-4 grid gap-3 md:grid-cols-4">
<MetricCard title="团队人数" value={teamQuery.data?.summary.totalEmployees ?? 0} />
<MetricCard title="已提交" value={teamQuery.data?.summary.submittedCount ?? 0} />
<MetricCard title="未提交" value={teamQuery.data?.summary.missingCount ?? 0} tone={(teamQuery.data?.summary.missingCount ?? 0) > 0 ? "warning" : "neutral"} />
<MetricCard title="需协助" value={teamQuery.data?.summary.needsHelpCount ?? 0} tone={(teamQuery.data?.summary.needsHelpCount ?? 0) > 0 ? "warning" : "neutral"} />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
<TableHead>状态</TableHead>
<TableHead>需协助</TableHead>
<TableHead>关联项目</TableHead>
<TableHead>摘要</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(teamQuery.data?.items ?? []).map((item) => (
<TableRow key={item.employee.id}>
<TableCell>{item.employee.name}</TableCell>
<TableCell>{item.report?.status ?? "missing"}</TableCell>
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
<TableCell>{item.report?.projectLinks?.map((link) => link.project?.name ?? link.projectId).join(" / ") || "-"}</TableCell>
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
{teamQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(teamQuery.error, "加载团队周报失败")}</div> : null}
<PrimaryTable>
<Table>
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
<TableHead>状态</TableHead>
<TableHead>需协助</TableHead>
<TableHead>关联项目</TableHead>
<TableHead>摘要</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{teamQuery.isLoading ? (
<TableRow>
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={5}>
团队周报加载中...
</TableCell>
</TableRow>
) : (
(teamQuery.data?.items ?? []).map((item) => (
<TableRow key={item.employee.id}>
<TableCell>{item.employee.name}</TableCell>
<TableCell>{item.report?.status ?? "missing"}</TableCell>
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
<TableCell>{item.report?.projectLinks?.map((link) => link.project?.name ?? link.projectId).join(" / ") || "-"}</TableCell>
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</PrimaryTable>
</SectionPanel>
) : null}
</div>
</ListWorkspace>
);
}