实现制度签收轻量回执(05-28-oa-policy-acknowledgement-v1)
这个提交包含在:
+55
-16
@@ -1,5 +1,29 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type AnnouncementCategory = "general" | "policy";
|
||||
export type AnnouncementAcknowledgementStatus = "not_required" | "pending" | "acknowledged" | "overdue";
|
||||
|
||||
export type PortalAnnouncementAcknowledgement = {
|
||||
status: AnnouncementAcknowledgementStatus;
|
||||
acknowledgedAt: string | null;
|
||||
overdue: boolean;
|
||||
};
|
||||
|
||||
export type PortalAnnouncementAcknowledgementSummary = {
|
||||
required: boolean;
|
||||
totalActiveEmployees: number;
|
||||
acknowledgedCount: number;
|
||||
pendingCount: number;
|
||||
overdueCount: number;
|
||||
};
|
||||
|
||||
export type PortalAnnouncementPendingEmployee = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
status: "pending" | "overdue";
|
||||
};
|
||||
|
||||
export type PortalAnnouncement = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -9,16 +33,18 @@ export type PortalAnnouncement = {
|
||||
endsAt: string | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
category: AnnouncementCategory;
|
||||
requiresAcknowledgement: boolean;
|
||||
acknowledgementStatement: string | null;
|
||||
acknowledgementDueAt: string | null;
|
||||
acknowledgement?: PortalAnnouncementAcknowledgement;
|
||||
acknowledgementSummary?: PortalAnnouncementAcknowledgementSummary;
|
||||
pendingEmployees?: PortalAnnouncementPendingEmployee[];
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAnnouncements(manage = false) {
|
||||
const suffix = manage ? "?manage=1" : "";
|
||||
return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`);
|
||||
}
|
||||
|
||||
export async function createAnnouncement(payload: {
|
||||
export type AnnouncementWritePayload = {
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl?: string | null;
|
||||
@@ -26,22 +52,25 @@ export async function createAnnouncement(payload: {
|
||||
endsAt?: string | null;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
}) {
|
||||
category?: AnnouncementCategory;
|
||||
requiresAcknowledgement?: boolean;
|
||||
acknowledgementStatement?: string | null;
|
||||
acknowledgementDueAt?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchAnnouncements(manage = false) {
|
||||
const suffix = manage ? "?manage=1" : "";
|
||||
return apiRequestData<{ items: PortalAnnouncement[] }>(`/api/announcements${suffix}`);
|
||||
}
|
||||
|
||||
export async function createAnnouncement(payload: AnnouncementWritePayload) {
|
||||
return apiRequestData<{ item: PortalAnnouncement }>("/api/announcements", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAnnouncement(id: string, payload: Partial<{
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl: string | null;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
}>) {
|
||||
export async function updateAnnouncement(id: string, payload: Partial<AnnouncementWritePayload>) {
|
||||
return apiRequestData<{ item: PortalAnnouncement }>(`/api/announcements/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
@@ -54,3 +83,13 @@ export async function archiveAnnouncement(id: string) {
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function acknowledgeAnnouncement(id: string) {
|
||||
return apiRequestData<{ item: { id: string | null; announcementId: string; status: "acknowledged"; acknowledgedAt: string | null } }>(
|
||||
`/api/announcements/${id}/acknowledge`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirmed: true }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,135 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchAnnouncements } from "../api/announcements";
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { acknowledgeAnnouncement, fetchAnnouncements, type AnnouncementAcknowledgementStatus, type PortalAnnouncement } from "../api/announcements";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
|
||||
|
||||
function acknowledgementLabel(status: AnnouncementAcknowledgementStatus) {
|
||||
if (status === "acknowledged") return "已签收";
|
||||
if (status === "overdue") return "已逾期";
|
||||
if (status === "pending") return "待签收";
|
||||
return "普通公告";
|
||||
}
|
||||
|
||||
function acknowledgementBadgeClass(status: AnnouncementAcknowledgementStatus) {
|
||||
if (status === "acknowledged") return "badge-success";
|
||||
if (status === "overdue") return "badge-error";
|
||||
if (status === "pending") return "badge-warning";
|
||||
return "badge-neutral";
|
||||
}
|
||||
|
||||
function AnnouncementCard({
|
||||
item,
|
||||
checked,
|
||||
submitting,
|
||||
onCheckedChange,
|
||||
onAcknowledge,
|
||||
}: {
|
||||
item: PortalAnnouncement;
|
||||
checked: boolean;
|
||||
submitting: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
onAcknowledge: () => void;
|
||||
}) {
|
||||
const acknowledgement = item.acknowledgement ?? { status: "not_required", acknowledgedAt: null, overdue: false };
|
||||
const requiresAcknowledgement = item.requiresAcknowledgement;
|
||||
const isAcknowledged = acknowledgement.status === "acknowledged";
|
||||
const canSubmit = requiresAcknowledgement && !isAcknowledged && checked && !submitting;
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`card-std p-5 md:p-6 ${requiresAcknowledgement ? "border-l-4 border-l-[var(--color-accent-primary)]" : ""}`}
|
||||
data-testid={`announcement-card-${item.id}`}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-lg font-semibold leading-7 text-zinc-900">{item.title}</h2>
|
||||
<span className={acknowledgementBadgeClass(acknowledgement.status)}>
|
||||
{acknowledgementLabel(acknowledgement.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</p>
|
||||
</div>
|
||||
{item.acknowledgementDueAt ? (
|
||||
<div className="shrink-0 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-zinc-600">
|
||||
截止:{formatDisplayDate(item.acknowledgementDueAt)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{item.linkUrl ? (
|
||||
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.linkUrl} target="_blank" rel="noreferrer">
|
||||
查看链接
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{requiresAcknowledgement ? (
|
||||
<div className="mt-4 rounded-lg border border-slate-200 bg-slate-50/70 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-500">签收声明</div>
|
||||
<div className="mt-1 text-sm leading-6 text-zinc-800">{item.acknowledgementStatement}</div>
|
||||
{isAcknowledged ? (
|
||||
<div className="mt-3 text-sm text-emerald-700">
|
||||
已于 {formatDisplayDateTime(acknowledgement.acknowledgedAt)} 完成本人签收。
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<label className="flex min-w-0 items-start gap-2 text-sm leading-6 text-zinc-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 h-4 w-4 rounded border-zinc-300"
|
||||
checked={checked}
|
||||
onChange={(event) => onCheckedChange(event.target.checked)}
|
||||
/>
|
||||
<span>我已阅读并理解该制度内容,确认按公司制度执行。</span>
|
||||
</label>
|
||||
<Button type="button" className="w-full md:w-auto" disabled={!canSubmit} onClick={onAcknowledge}>
|
||||
{submitting ? "提交中..." : acknowledgement.status === "overdue" ? "逾期补签" : "确认签收"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnnouncementsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [checkedById, setCheckedById] = useState<Record<string, boolean>>({});
|
||||
const announcementsQuery = useQuery({ queryKey: ["announcements", "public"], queryFn: () => fetchAnnouncements(false) });
|
||||
const acknowledgeMutation = useMutation({
|
||||
mutationFn: acknowledgeAnnouncement,
|
||||
onSuccess: async (_data, id) => {
|
||||
setCheckedById((current) => ({ ...current, [id]: false }));
|
||||
await queryClient.invalidateQueries({ queryKey: ["announcements"] });
|
||||
toast.success("签收已记录");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("签收失败,请刷新后重试");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="公告" subtitle="全员查看公司通知、制度链接和 FAQ 入口。" />
|
||||
<PageHeader title="公告" subtitle="查看公司通知、制度链接和需要本人确认的制度签收。" />
|
||||
<div className="space-y-4">
|
||||
{announcementsQuery.isLoading ? <div className="card-std p-6 text-sm text-zinc-500">公告加载中...</div> : null}
|
||||
{(announcementsQuery.data?.items ?? []).map((item) => (
|
||||
<div key={item.id} className="card-std p-6">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm text-zinc-600">{item.summary}</div>
|
||||
{item.linkUrl ? <a className="mt-3 inline-block text-sm text-primary" href={item.linkUrl} target="_blank" rel="noreferrer">查看链接</a> : null}
|
||||
</div>
|
||||
<AnnouncementCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
checked={Boolean(checkedById[item.id])}
|
||||
submitting={acknowledgeMutation.isPending && acknowledgeMutation.variables === item.id}
|
||||
onCheckedChange={(checked) => setCheckedById((current) => ({ ...current, [item.id]: checked }))}
|
||||
onAcknowledge={() => acknowledgeMutation.mutate(item.id)}
|
||||
/>
|
||||
))}
|
||||
{!announcementsQuery.isLoading && !(announcementsQuery.data?.items ?? []).length ? (
|
||||
<div className="card-std p-6 text-sm text-zinc-500">暂无公告</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement } from "../api/announcements";
|
||||
import { toast } from "sonner";
|
||||
import { archiveAnnouncement, createAnnouncement, fetchAnnouncements, updateAnnouncement, type AnnouncementCategory, type PortalAnnouncement } from "../api/announcements";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
|
||||
|
||||
function toChinaEndOfDayIso(value: string) {
|
||||
if (!value) return null;
|
||||
return new Date(`${value}T23:59:59+08:00`).toISOString();
|
||||
}
|
||||
|
||||
function acknowledgementModeLabel(category: AnnouncementCategory, requiresAcknowledgement: boolean) {
|
||||
return category === "policy" || requiresAcknowledgement ? "制度签收" : "普通公告";
|
||||
}
|
||||
|
||||
function formatSummary(item: PortalAnnouncement) {
|
||||
const summary = item.acknowledgementSummary;
|
||||
if (!summary?.required) return "无需签收";
|
||||
return `已签 ${summary.acknowledgedCount}/${summary.totalActiveEmployees} · 待签 ${summary.pendingCount}`;
|
||||
}
|
||||
|
||||
export function PortalAnnouncementsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -13,20 +31,39 @@ export function PortalAnnouncementsPage() {
|
||||
const [summary, setSummary] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [priority, setPriority] = useState("100");
|
||||
const [mode, setMode] = useState<AnnouncementCategory>("general");
|
||||
const [acknowledgementStatement, setAcknowledgementStatement] = useState("我已阅读并理解该制度,确认按公司制度执行。");
|
||||
const [acknowledgementDueDate, setAcknowledgementDueDate] = useState("");
|
||||
|
||||
const listQuery = useQuery({ queryKey: ["announcements", "manage"], queryFn: () => fetchAnnouncements(true) });
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ["announcements"] });
|
||||
const isPolicy = mode === "policy";
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createAnnouncement({ title, summary, linkUrl: linkUrl || null, priority: Number(priority || 100) }),
|
||||
mutationFn: () => createAnnouncement({
|
||||
title,
|
||||
summary,
|
||||
linkUrl: linkUrl || null,
|
||||
priority: Number(priority || 100),
|
||||
category: mode,
|
||||
requiresAcknowledgement: isPolicy,
|
||||
acknowledgementStatement: isPolicy ? acknowledgementStatement.trim() : null,
|
||||
acknowledgementDueAt: isPolicy ? toChinaEndOfDayIso(acknowledgementDueDate) : null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setTitle("");
|
||||
setSummary("");
|
||||
setLinkUrl("");
|
||||
setPriority("100");
|
||||
setMode("general");
|
||||
setAcknowledgementStatement("我已阅读并理解该制度,确认按公司制度执行。");
|
||||
setAcknowledgementDueDate("");
|
||||
await refresh();
|
||||
toast.success("公告已创建");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("公告创建失败,请检查填写内容");
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
@@ -45,39 +82,149 @@ export function PortalAnnouncementsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!title.trim() || !summary.trim()) {
|
||||
toast.error("请填写标题和摘要");
|
||||
return;
|
||||
}
|
||||
if (isPolicy && !acknowledgementStatement.trim()) {
|
||||
toast.error("制度签收公告必须填写签收声明");
|
||||
return;
|
||||
}
|
||||
createMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="公告管理" subtitle="轻量发布公告、制度链接和 FAQ 入口。" />
|
||||
<div className="grid gap-6 xl:grid-cols-[360px,1fr]">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建公告</h3>
|
||||
<div className="space-y-2"><Label>标题</Label><Input value={title} onChange={(event) => setTitle(event.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>摘要</Label><textarea className="min-h-28 w-full rounded-lg border border-zinc-300 px-3 py-2 text-sm" value={summary} onChange={(event) => setSummary(event.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>链接</Label><Input value={linkUrl} onChange={(event) => setLinkUrl(event.target.value)} /></div>
|
||||
<div className="space-y-2"><Label>优先级</Label><Input value={priority} onChange={(event) => setPriority(event.target.value)} /></div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>{createMutation.isPending ? "创建中..." : "创建公告"}</Button>
|
||||
</div>
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">公告列表</h3>
|
||||
<div className="space-y-3">
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">priority={item.priority} · {item.enabled ? "enabled" : "disabled"}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => toggleMutation.mutate({ id: item.id, enabled: !item.enabled })}>{item.enabled ? "停用" : "启用"}</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => archiveMutation.mutate(item.id)}>归档</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader title="公告管理" subtitle="发布普通公告,或创建需要全员显式确认的制度签收公告。" />
|
||||
<div className="admin-workspace-grid xl:grid-cols-[360px,minmax(0,1fr)]">
|
||||
<section className="admin-command-panel space-y-4 p-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">新建公告</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-500">制度内容变化时请新建公告版本,不直接覆盖已签收内容。</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>公告类型</Label>
|
||||
<Tabs value={mode} onValueChange={(value) => setMode(value as AnnouncementCategory)}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-2">
|
||||
<TabsTrigger value="general">普通公告</TabsTrigger>
|
||||
<TabsTrigger value="policy">制度签收</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>标题</Label>
|
||||
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="例如:费用报销制度签收" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>摘要</Label>
|
||||
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="写清楚适用范围、入口和处理要求。" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>链接</Label>
|
||||
<Input value={linkUrl} onChange={(event) => setLinkUrl(event.target.value)} placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
{isPolicy ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3.5">
|
||||
<div className="space-y-2">
|
||||
<Label>签收声明</Label>
|
||||
<Textarea
|
||||
className="min-h-[88px]"
|
||||
value={acknowledgementStatement}
|
||||
onChange={(event) => setAcknowledgementStatement(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label>签收截止日</Label>
|
||||
<Input type="date" value={acknowledgementDueDate} onChange={(event) => setAcknowledgementDueDate(event.target.value)} />
|
||||
<p className="text-xs leading-5 text-zinc-500">按国内习惯,截止日当天 23:59 前均视为未逾期。</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>优先级</Label>
|
||||
<Input value={priority} onChange={(event) => setPriority(event.target.value)} inputMode="numeric" />
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={handleCreate}>
|
||||
{createMutation.isPending ? "创建中..." : isPolicy ? "创建制度签收公告" : "创建公告"}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="admin-command-panel space-y-4 p-5">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">公告列表</h2>
|
||||
<p className="mt-1 text-xs leading-5 text-zinc-500">管理员可查看制度签收统计;未签名单仅展示员工号和姓名。</p>
|
||||
</div>
|
||||
{listQuery.isFetching ? <span className="badge-neutral">刷新中</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(listQuery.data?.items ?? []).map((item) => {
|
||||
const summaryStats = item.acknowledgementSummary;
|
||||
const pendingEmployees = item.pendingEmployees ?? [];
|
||||
const pendingPreview = pendingEmployees.slice(0, 6);
|
||||
return (
|
||||
<article key={item.id} className="admin-tile-card space-y-3">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-semibold text-zinc-900">{item.title}</h3>
|
||||
<span className={item.requiresAcknowledgement ? "badge-warning" : "badge-neutral"}>
|
||||
{acknowledgementModeLabel(item.category, item.requiresAcknowledgement)}
|
||||
</span>
|
||||
<span className={item.enabled ? "badge-success" : "badge-neutral"}>{item.enabled ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||
<span>优先级 {item.priority}</span>
|
||||
<span>更新 {formatDisplayDateTime(item.updatedAt)}</span>
|
||||
{item.acknowledgementDueAt ? <span>截止 {formatDisplayDate(item.acknowledgementDueAt)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => toggleMutation.mutate({ id: item.id, enabled: !item.enabled })}>
|
||||
{item.enabled ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => archiveMutation.mutate(item.id)}>归档</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.requiresAcknowledgement && summaryStats ? (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3.5">
|
||||
<div className="grid gap-2 text-sm md:grid-cols-4">
|
||||
<div><span className="text-zinc-500">范围</span><div className="font-semibold text-zinc-900">{summaryStats.totalActiveEmployees} 人</div></div>
|
||||
<div><span className="text-zinc-500">已签</span><div className="font-semibold text-emerald-700">{summaryStats.acknowledgedCount} 人</div></div>
|
||||
<div><span className="text-zinc-500">待签</span><div className="font-semibold text-amber-700">{summaryStats.pendingCount} 人</div></div>
|
||||
<div><span className="text-zinc-500">逾期</span><div className="font-semibold text-red-700">{summaryStats.overdueCount} 人</div></div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs font-semibold text-zinc-500">{formatSummary(item)}</div>
|
||||
{pendingPreview.length ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{pendingPreview.map((employee) => (
|
||||
<span key={employee.id} className={employee.status === "overdue" ? "badge-error" : "badge-warning"}>
|
||||
{employee.employeeNo} · {employee.name}
|
||||
</span>
|
||||
))}
|
||||
{pendingEmployees.length > pendingPreview.length ? (
|
||||
<span className="badge-neutral">另 {pendingEmployees.length - pendingPreview.length} 人</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm text-emerald-700">当前 active 员工均已签收。</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{!listQuery.data?.items?.length ? <div className="text-sm text-zinc-500">暂无公告</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
import { ADMIN, EMPLOYEE, login } from "./helpers/auth";
|
||||
|
||||
type MockUserKey = "admin" | "employee";
|
||||
|
||||
type MockAnnouncement = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl: string | null;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
category: "general" | "policy";
|
||||
requiresAcknowledgement: boolean;
|
||||
acknowledgementStatement: string | null;
|
||||
acknowledgementDueAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
acknowledgedEmployeeIds: Set<string>;
|
||||
};
|
||||
|
||||
function envelope(data: unknown) {
|
||||
return { requestId: "mock-policy-ack", data };
|
||||
}
|
||||
|
||||
async function fulfillJson(route: Route, data: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(envelope(data)),
|
||||
});
|
||||
}
|
||||
|
||||
function buildMe(user: MockUserKey) {
|
||||
if (user === "admin") {
|
||||
return {
|
||||
id: "admin-policy-ack",
|
||||
employeeNo: "A001",
|
||||
name: "Admin",
|
||||
phone: "mock-phone-admin",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-admin",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["admin"],
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 60 },
|
||||
wecomBinding: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: "employee-policy-ack",
|
||||
employeeNo: "E001",
|
||||
name: "Employee",
|
||||
phone: "mock-phone-employee",
|
||||
email: "[email protected]",
|
||||
departmentId: "dept-employee",
|
||||
directManagerId: null,
|
||||
status: "active",
|
||||
notificationPushEnabled: true,
|
||||
roleCodes: ["employee"],
|
||||
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 60 },
|
||||
wecomBinding: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAnnouncementForUser(item: MockAnnouncement, currentUser: MockUserKey) {
|
||||
const me = buildMe(currentUser);
|
||||
const acknowledged = item.acknowledgedEmployeeIds.has(me.id);
|
||||
const overdue = Boolean(item.acknowledgementDueAt && new Date(item.acknowledgementDueAt).getTime() < Date.now());
|
||||
return {
|
||||
...item,
|
||||
acknowledgedEmployeeIds: undefined,
|
||||
acknowledgement: {
|
||||
status: !item.requiresAcknowledgement ? "not_required" : acknowledged ? "acknowledged" : overdue ? "overdue" : "pending",
|
||||
acknowledgedAt: acknowledged ? "2026-05-28T03:00:00.000Z" : null,
|
||||
overdue: !acknowledged && overdue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mapAnnouncementForAdmin(item: MockAnnouncement) {
|
||||
const employees = [
|
||||
{ id: "employee-policy-ack", employeeNo: "E001", name: "Employee", status: "pending" },
|
||||
{ id: "employee-policy-ack-2", employeeNo: "E002", name: "员工二", status: "pending" },
|
||||
];
|
||||
const pendingEmployees = item.requiresAcknowledgement
|
||||
? employees.filter((employee) => !item.acknowledgedEmployeeIds.has(employee.id))
|
||||
: [];
|
||||
return {
|
||||
...item,
|
||||
acknowledgedEmployeeIds: undefined,
|
||||
acknowledgementSummary: {
|
||||
required: item.requiresAcknowledgement,
|
||||
totalActiveEmployees: item.requiresAcknowledgement ? employees.length : 0,
|
||||
acknowledgedCount: item.requiresAcknowledgement ? employees.length - pendingEmployees.length : 0,
|
||||
pendingCount: pendingEmployees.length,
|
||||
overdueCount: 0,
|
||||
},
|
||||
pendingEmployees,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockPolicyAckApis(page: Page) {
|
||||
let currentUser: MockUserKey = "employee";
|
||||
const announcements: MockAnnouncement[] = [];
|
||||
|
||||
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/auth/capabilities") {
|
||||
await fulfillJson(route, {
|
||||
emailDelivery: { ready: true, statusReasons: [] },
|
||||
emailLoginCode: { enabled: true },
|
||||
passwordReset: { enabled: true },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/sso/providers") {
|
||||
await fulfillJson(route, []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/wecom/config") {
|
||||
await fulfillJson(route, { enabled: false, entryEnabled: false, oauthEnabled: false, status: "disabled", statusReasons: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/login") {
|
||||
const payload = request.postDataJSON() as { identifier?: string };
|
||||
currentUser = payload.identifier === ADMIN.identifier ? "admin" : "employee";
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "set-cookie": "has_session=1; Path=/; SameSite=Lax" },
|
||||
body: JSON.stringify(envelope({ accessToken: `mock-policy-${currentUser}`, expiresIn: 900, mustChangePassword: false })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/auth/refresh") {
|
||||
await fulfillJson(route, { accessToken: `mock-policy-${currentUser}`, expiresIn: 900, mustChangePassword: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/me") {
|
||||
await fulfillJson(route, buildMe(currentUser));
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/system/navigation") {
|
||||
await fulfillJson(route, {
|
||||
workspace: {
|
||||
id: "workspace-policy-ack",
|
||||
persisted: true,
|
||||
profile: "small_team_internal",
|
||||
orgMode: "single_company",
|
||||
primaryEntryMode: "password",
|
||||
notificationStrategy: "in_app",
|
||||
devicePriority: "desktop",
|
||||
moduleVisibility: {},
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
},
|
||||
sections: {
|
||||
main: [{ key: "announcements", href: "/announcements", label: "公告", icon: "campaign", placement: "main", permissionCodes: [], featureFlagKey: null }],
|
||||
admin: currentUser === "admin"
|
||||
? [{ key: "announcementsAdmin", href: "/system/announcements", label: "公告管理", icon: "campaign", placement: "admin", permissionCodes: ["system.settings"], featureFlagKey: null }]
|
||||
: [],
|
||||
backup: [],
|
||||
},
|
||||
recommendedRolePresets: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/portal/home") {
|
||||
await fulfillJson(route, {
|
||||
workspace: { id: "workspace-policy-ack", persisted: true, profile: "small_team_internal" },
|
||||
greeting: { name: buildMe(currentUser).name },
|
||||
cards: [],
|
||||
pendingTasks: { count: 0, href: "/workflow/tasks" },
|
||||
myApplications: [],
|
||||
riskSummary: null,
|
||||
announcements: [],
|
||||
quickLinks: [],
|
||||
todayPresence: null,
|
||||
teamPresence: null,
|
||||
weeklyReport: null,
|
||||
weeklySummary: null,
|
||||
todoSummary: null,
|
||||
projectSummary: null,
|
||||
peopleLifecycle: null,
|
||||
adoptionSummary: null,
|
||||
recentActivities: [],
|
||||
teamStatusBoard: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/notifications") {
|
||||
await fulfillJson(route, { items: [], unreadCount: 0, page: 1, pageSize: 20, total: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/announcements" && request.method() === "GET") {
|
||||
const manage = url.searchParams.get("manage") === "1";
|
||||
await fulfillJson(route, {
|
||||
items: announcements.map((item) => manage ? mapAnnouncementForAdmin(item) : mapAnnouncementForUser(item, currentUser)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === "/api/announcements" && request.method() === "POST") {
|
||||
const payload = request.postDataJSON() as {
|
||||
title: string;
|
||||
summary: string;
|
||||
linkUrl?: string | null;
|
||||
priority?: number;
|
||||
category?: "general" | "policy";
|
||||
requiresAcknowledgement?: boolean;
|
||||
acknowledgementStatement?: string | null;
|
||||
acknowledgementDueAt?: string | null;
|
||||
};
|
||||
const created: MockAnnouncement = {
|
||||
id: `policy-ann-${announcements.length + 1}`,
|
||||
title: payload.title,
|
||||
summary: payload.summary,
|
||||
linkUrl: payload.linkUrl ?? null,
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
enabled: true,
|
||||
priority: payload.priority ?? 100,
|
||||
category: payload.category ?? "general",
|
||||
requiresAcknowledgement: Boolean(payload.requiresAcknowledgement),
|
||||
acknowledgementStatement: payload.acknowledgementStatement ?? null,
|
||||
acknowledgementDueAt: payload.acknowledgementDueAt ?? null,
|
||||
createdAt: "2026-05-28T02:00:00.000Z",
|
||||
updatedAt: "2026-05-28T02:00:00.000Z",
|
||||
acknowledgedEmployeeIds: new Set(),
|
||||
};
|
||||
announcements.push(created);
|
||||
await fulfillJson(route, { item: mapAnnouncementForAdmin(created) }, 201);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/acknowledge") && request.method() === "POST") {
|
||||
const segments = path.split("/");
|
||||
const id = segments[segments.length - 2];
|
||||
const announcement = announcements.find((item) => item.id === id);
|
||||
if (announcement) {
|
||||
announcement.acknowledgedEmployeeIds.add(buildMe(currentUser).id);
|
||||
}
|
||||
await fulfillJson(route, { item: { id: "ack-1", announcementId: id, status: "acknowledged", acknowledgedAt: "2026-05-28T03:00:00.000Z" } }, 201);
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
}
|
||||
|
||||
async function clearBrowserSession(page: Page) {
|
||||
await page.context().clearCookies();
|
||||
await page.evaluate(() => {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
}
|
||||
|
||||
test("制度签收公告:管理员创建、员工签收、管理员看到统计变化", async ({ page }) => {
|
||||
await mockPolicyAckApis(page);
|
||||
const title = `自动化制度签收 ${Date.now()}`;
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/system/announcements");
|
||||
await page.getByRole("tab", { name: "制度签收" }).click();
|
||||
await page.getByPlaceholder("例如:费用报销制度签收").fill(title);
|
||||
await page.getByPlaceholder("写清楚适用范围、入口和处理要求。").fill("请全员阅读并确认制度签收流程。");
|
||||
await page.locator('input[type="date"]').fill("2026-06-03");
|
||||
await page.getByRole("button", { name: "创建制度签收公告" }).click();
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
await expect(page.getByText("已签 0/2 · 待签 2")).toBeVisible();
|
||||
|
||||
await clearBrowserSession(page);
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/announcements");
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
await expect(page.getByText("待签收")).toBeVisible();
|
||||
await page.getByLabel("我已阅读并理解该制度内容,确认按公司制度执行。").check();
|
||||
await page.getByRole("button", { name: "确认签收" }).click();
|
||||
await expect(page.getByText("已签收")).toBeVisible();
|
||||
await expect(page.getByText(/完成本人签收/)).toBeVisible();
|
||||
|
||||
await clearBrowserSession(page);
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/system/announcements");
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
await expect(page.getByText("已签 1/2 · 待签 1")).toBeVisible();
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户