diff --git a/src/modules/announcements/actions.ts b/src/modules/announcements/actions.ts index 4592b59..f3a86bf 100644 --- a/src/modules/announcements/actions.ts +++ b/src/modules/announcements/actions.ts @@ -10,11 +10,6 @@ import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" import { sendBatchNotifications } from "@/modules/notifications" import type { NotificationPayload } from "@/modules/notifications" -import { getAllUserIds, getUserIdsByGradeId } from "@/modules/users/data-access" -import { - getStudentIdsByClassId, - getTeacherIdsByClassIds, -} from "@/modules/classes/data-access" import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema" import { @@ -28,50 +23,51 @@ import { toggleAnnouncementPin, markAnnouncementAsRead, getAnnouncementReadStatusForUser, + getAnnouncementByIdForUser, + resolveAnnouncementTargetUserIds, } from "./data-access" import type { GetAnnouncementsParams, Announcement } from "./types" -function handleActionError(e: unknown): ActionState { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } -} - /** - * 根据公告类型解析目标用户 ID 列表。 - * - school: 全校所有用户 - * - grade: 该年级下所有用户 - * - class: 该班级学生 + 任课教师 + 班主任 + * P1-7: 统一错误处理。 + * - 记录错误堆栈到 console.error 便于生产排查 + * - 上报 trackEvent 用于监控告警 + * - 返回 i18n 化的 message,不向客户端泄露内部错误信息 */ -async function resolveTargetUserIds(announcement: Announcement): Promise { - if (announcement.type === "school") { - return getAllUserIds() - } +async function handleActionError( + e: unknown, + actionName: string, + t: (key: string) => string +): Promise> { + console.error(`[announcements] ${actionName} failed:`, e) - if (announcement.type === "grade" && announcement.targetGradeId) { - return getUserIdsByGradeId(announcement.targetGradeId) - } + void trackEvent({ + event: "announcement.action_error", + targetType: "announcement", + properties: { + action: actionName, + errorName: e instanceof Error ? e.constructor.name : "unknown", + errorMessage: e instanceof Error ? e.message : String(e), + }, + }) - if (announcement.type === "class" && announcement.targetClassId) { - const [studentIds, teacherIds] = await Promise.all([ - getStudentIdsByClassId(announcement.targetClassId), - getTeacherIdsByClassIds([announcement.targetClassId]), - ]) - return Array.from(new Set([...studentIds, ...teacherIds])) + if (e instanceof PermissionDeniedError) { + return { success: false, message: t("messages.permissionDenied") } } - - return [] + if (e instanceof Error && e.message === "FORBIDDEN_ANNOUNCEMENT") { + return { success: false, message: t("messages.forbidden") } + } + return { success: false, message: t("messages.unexpectedError") } } /** * 发布公告后向目标用户批量发送通知。 * 通知发送失败不影响公告发布本身,仅记录日志。 + * P1-5: resolveTargetUserIds 已下沉到 data-access.resolveAnnouncementTargetUserIds。 */ async function notifyAnnouncementPublished(announcement: Announcement): Promise { try { - const targetUserIds = await resolveTargetUserIds(announcement) + const targetUserIds = await resolveAnnouncementTargetUserIds(announcement) if (targetUserIds.length === 0) return const t = await getTranslations("announcements") @@ -101,6 +97,7 @@ export async function createAnnouncementAction( prevState: ActionState | null, formData: FormData ): Promise> { + const t = await getTranslations("announcements") try { const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) @@ -117,7 +114,7 @@ export async function createAnnouncementAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("messages.invalidFormData"), errors: parsed.error.flatten().fieldErrors, } } @@ -163,9 +160,9 @@ export async function createAnnouncementAction( properties: { type: input.type, status: input.status }, }) - return { success: true, message: "Announcement created", data: id } + return { success: true, message: isPublished ? t("messages.published") : t("messages.created"), data: id } } catch (e) { - return handleActionError(e) + return handleActionError(e, "createAnnouncementAction", t) } } @@ -174,11 +171,12 @@ export async function updateAnnouncementAction( prevState: ActionState | null, formData: FormData ): Promise> { + const t = await getTranslations("announcements") try { await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const existing = await getAnnouncementById(id) - if (!existing) return { success: false, message: "Announcement not found" } + if (!existing) return { success: false, message: t("messages.notFound") } const parsed = UpdateAnnouncementSchema.safeParse({ title: formData.get("title"), @@ -193,7 +191,7 @@ export async function updateAnnouncementAction( if (!parsed.success) { return { success: false, - message: "Invalid form data", + message: t("messages.invalidFormData"), errors: parsed.error.flatten().fieldErrors, } } @@ -239,18 +237,23 @@ export async function updateAnnouncementAction( properties: { type: input.type, status: input.status, wasPublished }, }) - return { success: true, message: "Announcement updated", data: id } + return { + success: true, + message: isPublished && !wasPublished ? t("messages.published") : t("messages.updated"), + data: id, + } } catch (e) { - return handleActionError(e) + return handleActionError(e, "updateAnnouncementAction", t) } } export async function deleteAnnouncementAction(id: string): Promise> { + const t = await getTranslations("announcements") try { await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const existing = await getAnnouncementById(id) - if (!existing) return { success: false, message: "Announcement not found" } + if (!existing) return { success: false, message: t("messages.notFound") } await deleteAnnouncementById(id) @@ -264,18 +267,19 @@ export async function deleteAnnouncementAction(id: string): Promise> { + const t = await getTranslations("announcements") try { await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const existing = await getAnnouncementById(id) - if (!existing) return { success: false, message: "Announcement not found" } + if (!existing) return { success: false, message: t("messages.notFound") } const publishedAt = existing.publishedAt ? new Date(existing.publishedAt) @@ -296,18 +300,19 @@ export async function publishAnnouncementAction(id: string): Promise> { + const t = await getTranslations("announcements") try { await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const existing = await getAnnouncementById(id) - if (!existing) return { success: false, message: "Announcement not found" } + if (!existing) return { success: false, message: t("messages.notFound") } await archiveAnnouncementById(id) @@ -322,20 +327,26 @@ export async function archiveAnnouncementAction(id: string): Promise> { + const t = await getTranslations("announcements") try { await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) + // P0-3: 资源存在性校验 + const existing = await getAnnouncementById(id) + if (!existing) return { success: false, message: t("messages.notFound") } + await toggleAnnouncementPin(id) revalidatePath("/admin/announcements") revalidatePath("/announcements") @@ -346,20 +357,29 @@ export async function toggleAnnouncementPinAction(id: string): Promise> { + const t = await getTranslations("announcements") try { const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) + // P0-3: 资源可见性二次校验——必须为已发布且对当前用户受众可见 + const visible = await getAnnouncementByIdForUser(announcementId, ctx.userId, ctx.dataScope) + if (!visible) { + // 不可见时抛出特定错误,由 handleActionError 转为 i18n 文案 + throw new Error("FORBIDDEN_ANNOUNCEMENT") + } + await markAnnouncementAsRead(announcementId, ctx.userId) void trackEvent({ @@ -369,35 +389,38 @@ export async function markAnnouncementAsReadAction(announcementId: string): Prom targetType: "announcement", }) - return { success: true, message: "Announcement marked as read" } + return { success: true, message: t("messages.markedRead") } } catch (e) { - return handleActionError(e) + return handleActionError(e, "markAnnouncementAsReadAction", t) } } /** * 批量获取公告已读状态(用于列表页标记已读/未读)。 + * P1-2: 列表组件消费此 Action 展示已读/未读视觉区分。 */ export async function getAnnouncementReadStatusAction( announcementIds: string[] ): Promise>> { + const t = await getTranslations("announcements") try { const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) const statusMap = await getAnnouncementReadStatusForUser(announcementIds, ctx.userId) return { success: true, data: Object.fromEntries(statusMap) } } catch (e) { - return handleActionError(e) + return handleActionError(e, "getAnnouncementReadStatusAction", t) } } export async function getAnnouncementsAction( params?: GetAnnouncementsParams ): Promise> { + const t = await getTranslations("announcements") try { await requirePermission(Permissions.ANNOUNCEMENT_READ) const data = await getAnnouncements(params) return { success: true, data } } catch (e) { - return handleActionError(e) + return handleActionError(e, "getAnnouncementsAction", t) } } diff --git a/src/modules/announcements/components/admin-announcements-view.tsx b/src/modules/announcements/components/admin-announcements-view.tsx index 6f1d10b..879ca28 100644 --- a/src/modules/announcements/components/admin-announcements-view.tsx +++ b/src/modules/announcements/components/admin-announcements-view.tsx @@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/compo import { AnnouncementForm } from "./announcement-form" import { AnnouncementList } from "./announcement-list" +import { AnnouncementsServiceProvider } from "./announcements-service-context" import type { Announcement, AnnouncementStatus } from "../types" export function AdminAnnouncementsView({ @@ -27,41 +28,55 @@ export function AdminAnnouncementsView({ const router = useRouter() const [createOpen, setCreateOpen] = useState(false) - const handleOpenChange = (open: boolean) => { + // P1-8: 表单提交成功后关闭 Dialog 并刷新列表,而非整页路由跳转 + const handleFormSuccess = (): void => { + setCreateOpen(false) + router.refresh() + } + + const handleOpenChange = (open: boolean): void => { setCreateOpen(open) if (!open) router.refresh() } return ( -
-
-
-

{t("title.adminList")}

-

- {t("description.adminList")} -

+ +
+
+
+

{t("title.adminList")}

+

+ {t("description.adminList")} +

+
+
- + + + + + + + {t("title.create")} + + + +
- - - - - - - {t("title.create")} - - - - -
+ ) } diff --git a/src/modules/announcements/components/announcement-card.test.tsx b/src/modules/announcements/components/announcement-card.test.tsx new file mode 100644 index 0000000..1561f28 --- /dev/null +++ b/src/modules/announcements/components/announcement-card.test.tsx @@ -0,0 +1,303 @@ +import "@testing-library/jest-dom/vitest" +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { render, screen, fireEvent, cleanup } from "@testing-library/react" + +import { AnnouncementCard } from "./announcement-card" +import { AnnouncementsServiceProvider } from "./announcements-service-context" +import type { Announcement, AnnouncementsService } from "../types" +import type { ActionState } from "@/shared/types/action-state" + +// Mock next-intl:返回 key 作为字面量,便于断言 +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, params?: Record) => { + if (!params) return key + return `${key}:${JSON.stringify(params)}` + }, +})) + +// Mock next/navigation:router.refresh 不做任何事 +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + refresh: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + }), +})) + +// Mock sonner:toast.success / error 收集调用 +const toastMocks = { success: vi.fn(), error: vi.fn() } +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastMocks.success(...args), + error: (...args: unknown[]) => toastMocks.error(...args), + }, +})) + +// Mock @/shared/lib/utils 的 formatDate 与 cn +vi.mock("@/shared/lib/utils", () => ({ + cn: (...classes: (string | false | null | undefined)[]) => + classes.filter(Boolean).join(" "), + formatDate: (iso: string) => `FMT:${iso}`, +})) + +// Mock @/shared/components/ui/card:渲染 children 即可,避免样式依赖 +vi.mock("@/shared/components/ui/card", () => ({ + Card: ({ children, className }: { children: React.ReactNode; className?: string }) => +
{children}
, + CardHeader: ({ children }: { children: React.ReactNode }) => +
{children}
, + CardTitle: ({ children }: { children: React.ReactNode }) => +

{children}

, + CardContent: ({ children }: { children: React.ReactNode }) => +
{children}
, +})) + +// Mock @/shared/components/ui/badge +vi.mock("@/shared/components/ui/badge", () => ({ + Badge: ({ + children, + variant, + className, + }: { + children: React.ReactNode + variant?: string + className?: string + }) => ( + + {children} + + ), +})) + +const baseAnnouncement: Announcement = { + id: "ann-1", + title: "测试公告标题", + content: "测试公告内容", + type: "school", + status: "published", + targetGradeId: null, + targetClassId: null, + authorId: "user-1", + authorName: "管理员", + publishedAt: "2026-06-25T00:00:00.000Z", + isPinned: false, + createdAt: "2026-06-25T00:00:00.000Z", + updatedAt: "2026-06-25T00:00:00.000Z", +} + +function createMockService( + overrides: Partial = {} +): AnnouncementsService { + const ok = (data: T): ActionState => ({ + success: true, + data, + message: "ok", + }) + return { + create: vi.fn().mockResolvedValue(ok("new-id")), + update: vi.fn().mockResolvedValue(ok("ann-1")), + delete: vi.fn().mockResolvedValue(ok("ann-1")), + publish: vi.fn().mockResolvedValue(ok("ann-1")), + archive: vi.fn().mockResolvedValue(ok("ann-1")), + togglePin: vi.fn().mockResolvedValue(ok("ann-1")), + markRead: vi.fn().mockResolvedValue(ok("ann-1")), + getReadStatus: vi.fn().mockResolvedValue(ok({})), + ...overrides, + } +} + +function renderCard( + service: AnnouncementsService, + props: Partial[0]> = {} +) { + return render( + + + + ) +} + +describe("AnnouncementCard", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + describe("基础渲染", () => { + it("渲染标题与内容", () => { + const service = createMockService() + renderCard(service) + expect(screen.getByText("测试公告标题")).toBeInTheDocument() + expect(screen.getByText("测试公告内容")).toBeInTheDocument() + }) + + it("渲染类型 badge(school)", () => { + const service = createMockService() + renderCard(service) + const badges = screen.getAllByTestId("badge") + const types = badges.map((b) => b.textContent) + expect(types).toContain("type.school") + }) + + it("未提供 href 时不渲染外层 Link", () => { + const service = createMockService() + const { container } = renderCard(service, { href: undefined }) + // 没有 标签 + expect(container.querySelector("a")).toBeNull() + }) + }) + + describe("isRead 视觉区分", () => { + it("isRead=false 时应用 ring-2 ring-primary/40 类名", () => { + const service = createMockService() + const { container } = renderCard(service, { isRead: false }) + const card = container.querySelector('[data-testid="card"]') + expect(card?.className).toContain("ring-2") + expect(card?.className).toContain("ring-primary/40") + }) + + it("isRead=true 时不应用 ring 类名", () => { + const service = createMockService() + const { container } = renderCard(service, { isRead: true }) + const card = container.querySelector('[data-testid="card"]') + expect(card?.className).not.toContain("ring-2") + }) + + it("isRead=undefined 时不应用 ring 类名", () => { + const service = createMockService() + const { container } = renderCard(service, { isRead: undefined }) + const card = container.querySelector('[data-testid="card"]') + expect(card?.className).not.toContain("ring-2") + }) + + it("isRead=false 时渲染未读 badge", () => { + const service = createMockService() + renderCard(service, { isRead: false }) + const badges = screen.getAllByTestId("badge") + const texts = badges.map((b) => b.textContent) + expect(texts).toContain("status.unread") + }) + }) + + describe("isPinned 视觉与交互", () => { + it("isPinned=true 时渲染已置顶 badge 与 Pin 图标 fill", () => { + const service = createMockService() + renderCard(service, { + announcement: { ...baseAnnouncement, isPinned: true }, + }) + const badges = screen.getAllByTestId("badge") + const texts = badges.map((b) => b.textContent) + expect(texts).toContain("status.pinned") + }) + + it("canManage=true 时渲染置顶按钮", () => { + const service = createMockService() + renderCard(service) + const pinButton = screen.getByRole("button", { name: "actions.pin" }) + expect(pinButton).toBeInTheDocument() + }) + + it("canManage=false 时不渲染置顶按钮", () => { + const service = createMockService() + render( + + + + ) + expect(screen.queryByRole("button", { name: "actions.pin" })).toBeNull() + }) + + it("点击置顶按钮调用 service.togglePin", async () => { + const togglePin = vi.fn().mockResolvedValue({ + success: true, + data: "ann-1", + message: "ok", + } satisfies ActionState) + const service = createMockService({ togglePin }) + renderCard(service) + + const pinButton = screen.getByRole("button", { name: "actions.pin" }) + await fireEvent.click(pinButton) + + expect(togglePin).toHaveBeenCalledWith("ann-1") + // 乐观更新成功后显示 toast + expect(toastMocks.success).toHaveBeenCalled() + }) + + it("togglePin 返回失败时回滚 isPinned 并显示 toast.error", async () => { + const togglePin = vi.fn().mockResolvedValue({ + success: false, + message: "权限不足", + } satisfies ActionState) + const service = createMockService({ togglePin }) + renderCard(service) + + const pinButton = screen.getByRole("button", { name: "actions.pin" }) + await fireEvent.click(pinButton) + + expect(togglePin).toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledWith("权限不足") + }) + + it("togglePin 抛错时回滚并显示 toast.error", async () => { + const togglePin = vi.fn().mockRejectedValue(new Error("network")) + const service = createMockService({ togglePin }) + renderCard(service) + + const pinButton = screen.getByRole("button", { name: "actions.pin" }) + await fireEvent.click(pinButton) + + expect(togglePin).toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalled() + }) + }) + + describe("a11y(P2-1)", () => { + it("置顶按钮支持 Enter 键触发", async () => { + const togglePin = vi.fn().mockResolvedValue({ + success: true, + data: "ann-1", + message: "ok", + } satisfies ActionState) + const service = createMockService({ togglePin }) + renderCard(service) + + const pinButton = screen.getByRole("button", { name: "actions.pin" }) + await fireEvent.keyDown(pinButton, { key: "Enter" }) + + expect(togglePin).toHaveBeenCalledWith("ann-1") + }) + + it("置顶按钮支持 Space 键触发", async () => { + const togglePin = vi.fn().mockResolvedValue({ + success: true, + data: "ann-1", + message: "ok", + } satisfies ActionState) + const service = createMockService({ togglePin }) + renderCard(service) + + const pinButton = screen.getByRole("button", { name: "actions.pin" }) + await fireEvent.keyDown(pinButton, { key: " " }) + + expect(togglePin).toHaveBeenCalledWith("ann-1") + }) + + it("置顶按钮在 toggling 时禁用", async () => { + // 永不 resolve 的 promise,让 isToggling 永远为 true + const togglePin = vi.fn().mockReturnValue(new Promise(() => {})) + const service = createMockService({ togglePin }) + renderCard(service) + + const pinButton = screen.getByRole("button", { name: "actions.pin" }) + fireEvent.click(pinButton) + + // 等待下一帧让 setIsToggling(true) 生效 + await new Promise((r) => setTimeout(r, 0)) + expect(pinButton).toBeDisabled() + }) + }) +}) diff --git a/src/modules/announcements/components/announcement-card.tsx b/src/modules/announcements/components/announcement-card.tsx index ce53b5c..ea9a61f 100644 --- a/src/modules/announcements/components/announcement-card.tsx +++ b/src/modules/announcements/components/announcement-card.tsx @@ -10,24 +10,43 @@ import { Pin } from "lucide-react" import { Badge } from "@/shared/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { cn, formatDate } from "@/shared/lib/utils" -import { toggleAnnouncementPinAction } from "../actions" + +import { useAnnouncementsService } from "./announcements-service-context" import type { Announcement } from "../types" +const statusVariant: Record = { + draft: "secondary", + published: "default", + archived: "outline", +} + +/** + * 公告卡片(列表项)。 + * + * P1-3: 通过 `useAnnouncementsService()` 消费 togglePin,不直接 import actions。 + * P2-1 a11y: 重构交互结构——卡片整体为 ``,置顶按钮用独立 ` - ) : null}
@@ -100,6 +107,11 @@ export function AnnouncementCard({ {t(`type.${announcement.type}`)} + {isRead === false ? ( + + {t("status.unread")} + + ) : null} {announcement.publishedAt ? t("meta.publishedAt", { date: formatDate(announcement.publishedAt) }) @@ -110,6 +122,25 @@ export function AnnouncementCard({ ) : null}
+ {/* P2-1 a11y: 置顶按钮绝对定位脱离链接语义,键盘可达且不触发外层 Link 导航 */} + {canManage ? ( + + ) : null} ) diff --git a/src/modules/announcements/components/announcement-detail.tsx b/src/modules/announcements/components/announcement-detail.tsx index 05c8a85..41815e8 100644 --- a/src/modules/announcements/components/announcement-detail.tsx +++ b/src/modules/announcements/components/announcement-detail.tsx @@ -22,13 +22,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog" import { cn, formatDate } from "@/shared/lib/utils" -import { - archiveAnnouncementAction, - deleteAnnouncementAction, - markAnnouncementAsReadAction, - publishAnnouncementAction, - toggleAnnouncementPinAction, -} from "../actions" +import { useAnnouncementsService } from "./announcements-service-context" import type { Announcement } from "../types" export function AnnouncementDetail({ @@ -44,6 +38,7 @@ export function AnnouncementDetail({ }) { const t = useTranslations("announcements") const router = useRouter() + const service = useAnnouncementsService() const [isWorking, setIsWorking] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const [isPinned, setIsPinned] = useState(announcement.isPinned) @@ -56,7 +51,7 @@ export function AnnouncementDetail({ if (isRead) return let cancelled = false - void markAnnouncementAsReadAction(announcement.id) + void service.markRead(announcement.id) .then((res) => { if (cancelled) return if (res.success) { @@ -70,12 +65,12 @@ export function AnnouncementDetail({ return () => { cancelled = true } - }, [canManage, isRead, announcement.id]) + }, [canManage, isRead, announcement.id, service]) const handlePublish = async () => { setIsWorking(true) try { - const res = await publishAnnouncementAction(announcement.id) + const res = await service.publish(announcement.id) if (res.success) { toast.success(res.message) router.refresh() @@ -92,7 +87,7 @@ export function AnnouncementDetail({ const handleArchive = async () => { setIsWorking(true) try { - const res = await archiveAnnouncementAction(announcement.id) + const res = await service.archive(announcement.id) if (res.success) { toast.success(res.message) router.refresh() @@ -109,7 +104,7 @@ export function AnnouncementDetail({ const handleDelete = async () => { setIsWorking(true) try { - const res = await deleteAnnouncementAction(announcement.id) + const res = await service.delete(announcement.id) if (res.success) { toast.success(res.message) router.push("/admin/announcements") @@ -131,7 +126,7 @@ export function AnnouncementDetail({ // 乐观更新 setIsPinned(!prevPinned) try { - const res = await toggleAnnouncementPinAction(announcement.id) + const res = await service.togglePin(announcement.id) if (res.success) { toast.success(t("messages.pinToggled")) router.refresh() diff --git a/src/modules/announcements/components/announcement-form.tsx b/src/modules/announcements/components/announcement-form.tsx index 1571edc..ab21a1d 100644 --- a/src/modules/announcements/components/announcement-form.tsx +++ b/src/modules/announcements/components/announcement-form.tsx @@ -18,26 +18,47 @@ import { SelectValue, } from "@/shared/components/ui/select" -import { createAnnouncementAction, updateAnnouncementAction } from "../actions" +import { useAnnouncementsService } from "./announcements-service-context" import type { Announcement } from "../types" type Mode = "create" | "edit" type FieldErrors = Record +/** + * 公告表单(创建/编辑)。 + * + * P1-3: 通过 `useAnnouncementsService()` 消费 create/update,不直接 import actions。 + * P1-4 + P1-8: 通过 `onSuccess` / `onCancel` 回调解耦导航, + * 嵌入 Dialog 时关闭弹窗而非整页跳转;独立页面时由父级传入路由跳转。 + * `successHref` / `cancelHref` 为回调的便利兜底。 + */ export function AnnouncementForm({ mode, announcement, grades = [], classes = [], + onSuccess, + onCancel, + successHref = "/admin/announcements", + cancelHref = "/admin/announcements", }: { mode: Mode announcement?: Announcement grades?: { id: string; name: string }[] classes?: { id: string; name: string }[] + /** 提交成功回调;若提供则调用,否则回退到 router.push(successHref) */ + onSuccess?: () => void + /** 取消回调;若提供则调用,否则回退到 router.push(cancelHref) */ + onCancel?: () => void + /** 提交成功后跳转的路径(onSuccess 未提供时使用) */ + successHref?: string + /** 取消按钮跳转的路径(onCancel 未提供时使用) */ + cancelHref?: string }) { const t = useTranslations("announcements") const router = useRouter() + const service = useAnnouncementsService() const [isWorking, setIsWorking] = useState(false) const [fieldErrors, setFieldErrors] = useState({}) @@ -51,6 +72,23 @@ export function AnnouncementForm({ return errs && errs.length > 0 ? errs[0] : null } + const handleSuccess = (): void => { + if (onSuccess) { + onSuccess() + return + } + router.push(successHref) + router.refresh() + } + + const handleCancel = (): void => { + if (onCancel) { + onCancel() + return + } + router.push(cancelHref) + } + const handleSubmit = async (formData: FormData) => { setIsWorking(true) setFieldErrors({}) @@ -66,9 +104,9 @@ export function AnnouncementForm({ const res = mode === "create" - ? await createAnnouncementAction(null, formData) + ? await service.create(null, formData) : announcement - ? await updateAnnouncementAction(announcement.id, null, formData) + ? await service.update(announcement.id, null, formData) : null if (!res) { @@ -78,8 +116,7 @@ export function AnnouncementForm({ if (res.success) { toast.success(res.message) - router.push("/admin/announcements") - router.refresh() + handleSuccess() } else { // 展示字段级错误(来自 Zod superRefine 校验) if (res.errors) { @@ -214,7 +251,7 @@ export function AnnouncementForm({ + + {pages.map((p, idx) => + p === "..." ? ( + + ) : ( + + ) + )} + + + + ) +} diff --git a/src/modules/announcements/components/announcements-service-context.tsx b/src/modules/announcements/components/announcements-service-context.tsx new file mode 100644 index 0000000..e51a523 --- /dev/null +++ b/src/modules/announcements/components/announcements-service-context.tsx @@ -0,0 +1,42 @@ +"use client" + +import { createContext, useContext, type ReactNode } from "react" + +import type { AnnouncementsService } from "../types" +import { defaultAnnouncementsService } from "./default-announcements-service" + +/** + * P1-3: 公告服务 React Context。 + * + * 通过页面层注入 AnnouncementsService 实现,组件层使用 useAnnouncementsService() 消费, + * 避免直接 import 业务模块的 actions,实现完全解耦: + * - 默认实现调用真实 Server Actions + * - 测试时可注入 mock 实现 + * - 不同角色可注入不同实现 + * + * 未包裹 Provider 时回退到默认实现,保证向后兼容。 + */ +const AnnouncementsServiceContext = createContext(null) + +interface AnnouncementsServiceProviderProps { + /** 注入的服务实现;不传则使用默认实现(调用真实 Server Actions) */ + service?: AnnouncementsService + children: ReactNode +} + +export function AnnouncementsServiceProvider({ + service, + children, +}: AnnouncementsServiceProviderProps): ReactNode { + const value = service ?? defaultAnnouncementsService + return ( + + {children} + + ) +} + +export function useAnnouncementsService(): AnnouncementsService { + const ctx = useContext(AnnouncementsServiceContext) + return ctx ?? defaultAnnouncementsService +} diff --git a/src/modules/announcements/components/default-announcements-service.ts b/src/modules/announcements/components/default-announcements-service.ts new file mode 100644 index 0000000..8a62067 --- /dev/null +++ b/src/modules/announcements/components/default-announcements-service.ts @@ -0,0 +1,30 @@ +"use client" + +import { + createAnnouncementAction, + updateAnnouncementAction, + deleteAnnouncementAction, + publishAnnouncementAction, + archiveAnnouncementAction, + toggleAnnouncementPinAction, + markAnnouncementAsReadAction, + getAnnouncementReadStatusAction, +} from "../actions" +import type { AnnouncementsService } from "../types" + +/** + * P1-3: 公告服务的默认实现。 + * + * 直接代理到真实 Server Actions。 + * 测试或角色定制时可在 Provider 中注入其他实现以覆盖此默认行为。 + */ +export const defaultAnnouncementsService: AnnouncementsService = { + create: (prevState, formData) => createAnnouncementAction(prevState, formData), + update: (id, prevState, formData) => updateAnnouncementAction(id, prevState, formData), + delete: (id) => deleteAnnouncementAction(id), + publish: (id) => publishAnnouncementAction(id), + archive: (id) => archiveAnnouncementAction(id), + togglePin: (id) => toggleAnnouncementPinAction(id), + markRead: (announcementId) => markAnnouncementAsReadAction(announcementId), + getReadStatus: (announcementIds) => getAnnouncementReadStatusAction(announcementIds), +} diff --git a/src/modules/announcements/data-access.ts b/src/modules/announcements/data-access.ts index 2bf72d3..dc309fd 100644 --- a/src/modules/announcements/data-access.ts +++ b/src/modules/announcements/data-access.ts @@ -2,40 +2,32 @@ import "server-only" import { cache } from "react" import { createId } from "@paralleldrive/cuid2" -import { and, count, desc, eq, inArray, or } from "drizzle-orm" +import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm" import { db } from "@/shared/db" import { announcements, announcementReads, users } from "@/shared/db/schema" +import type { DataScope } from "@/shared/types/permissions" import type { Announcement, AnnouncementInsertData, AnnouncementStatus, AnnouncementUpdateData, GetAnnouncementsParams, + PaginatedAnnouncements, + UserAudience, } from "./types" +/** P2-2: 用 Drizzle 推导类型替代手写内联类型,避免 schema 变更时类型漂移 */ +type AnnouncementRow = typeof announcements.$inferSelect & { + authorName: string | null +} + const toIso = (d: Date | null | undefined): string | null => d ? d.toISOString() : null const toIsoRequired = (d: Date): string => d.toISOString() -const mapRow = ( - row: { - id: string - title: string - content: string - type: "school" | "grade" | "class" - status: "draft" | "published" | "archived" - targetGradeId: string | null - targetClassId: string | null - authorId: string - authorName: string | null - publishedAt: Date | null - isPinned: boolean - createdAt: Date - updatedAt: Date - } -): Announcement => ({ +const mapRow = (row: AnnouncementRow): Announcement => ({ id: row.id, title: row.title, content: row.content, @@ -66,16 +58,20 @@ export const getAnnouncements = cache( } // 受众过滤:当提供 audience 时,仅返回对该受众可见的公告 - // (type = 'school') OR (type = 'grade' AND target_grade_id = audience.gradeId) - // OR (type = 'class' AND target_class_id = audience.classId) + // (type = 'school') OR (type = 'grade' AND target_grade_id ∈ audience.gradeIds) + // OR (type = 'class' AND target_class_id ∈ audience.classIds) + // + // P0-2 修复:audience 升级为数组,支持家长多孩子 / 教师多班级 / 年级主任多年级 if (params?.audience) { - const { gradeId, classId } = params.audience - const gradeClause = gradeId - ? and(eq(announcements.type, "grade"), eq(announcements.targetGradeId, gradeId)) - : undefined - const classClause = classId - ? and(eq(announcements.type, "class"), eq(announcements.targetClassId, classId)) - : undefined + const { gradeIds, classIds } = params.audience + const gradeClause = + gradeIds.length > 0 + ? and(eq(announcements.type, "grade"), inArray(announcements.targetGradeId, gradeIds)) + : undefined + const classClause = + classIds.length > 0 + ? and(eq(announcements.type, "class"), inArray(announcements.targetClassId, classIds)) + : undefined const orClauses = [ eq(announcements.type, "school"), gradeClause, @@ -111,6 +107,49 @@ export const getAnnouncements = cache( } ) +/** + * P2-6: 统计符合条件的公告总数(用于分页)。 + * + * 与 `getAnnouncements` 使用相同的过滤条件,但不应用 limit/offset。 + * 仅用户端 `getUserAnnouncementsPageData` 调用;管理端列表暂不分页。 + */ +export async function countAnnouncements( + params?: Omit +): Promise { + const conditions = [] + if (params?.status) { + conditions.push(eq(announcements.status, params.status)) + } + if (params?.type) { + conditions.push(eq(announcements.type, params.type)) + } + + if (params?.audience) { + const { gradeIds, classIds } = params.audience + const gradeClause = + gradeIds.length > 0 + ? and(eq(announcements.type, "grade"), inArray(announcements.targetGradeId, gradeIds)) + : undefined + const classClause = + classIds.length > 0 + ? and(eq(announcements.type, "class"), inArray(announcements.targetClassId, classIds)) + : undefined + const orClauses = [ + eq(announcements.type, "school"), + gradeClause, + classClause, + ].filter((c): c is NonNullable => c !== undefined) + conditions.push(or(...orClauses)) + } + + const [row] = await db + .select({ value: count() }) + .from(announcements) + .where(conditions.length > 0 ? and(...conditions) : undefined) + + return row?.value ?? 0 +} + export const getAnnouncementById = cache( async (id: string): Promise => { const [row] = await db @@ -203,22 +242,14 @@ export async function archiveAnnouncementById(id: string): Promise { } // --------------------------------------------------------------------------- -// V2-P2-13d: 公告置顶 +// V2-P2-13d: 公告置顶(P1-6: 原子化,避免 SELECT-then-UPDATE 的并发 lost update) // --------------------------------------------------------------------------- export async function toggleAnnouncementPin(id: string): Promise { - // 查询当前置顶状态 - const [row] = await db - .select({ isPinned: announcements.isPinned }) - .from(announcements) - .where(eq(announcements.id, id)) - .limit(1) - - if (!row) return - + // 单次原子 UPDATE:is_pinned = NOT is_pinned await db .update(announcements) - .set({ isPinned: !row.isPinned, updatedAt: new Date() }) + .set({ isPinned: sql`${announcements.isPinned} = NOT ${announcements.isPinned}`, updatedAt: new Date() }) .where(eq(announcements.id, id)) } @@ -227,25 +258,22 @@ export async function toggleAnnouncementPin(id: string): Promise { // --------------------------------------------------------------------------- /** - * 标记公告为已读(如果尚未标记)。 - * 使用 INSERT IGNORE 语义避免重复插入(依赖唯一索引)。 + * 标记公告为已读(幂等)。 + * P1-6: 改用 INSERT ... ON DUPLICATE KEY UPDATE 单次原子操作, + * 替代原先的 SELECT-then-INSERT,减少一次 DB 往返且天然防并发。 */ export async function markAnnouncementAsRead(announcementId: string, userId: string): Promise { - // 先检查是否已存在已读记录 - const [existing] = await db - .select({ id: announcementReads.id }) - .from(announcementReads) - .where(and(eq(announcementReads.announcementId, announcementId), eq(announcementReads.userId, userId))) - .limit(1) - - if (existing) return - const id = createId() - await db.insert(announcementReads).values({ - id, - announcementId, - userId, - }) + await db + .insert(announcementReads) + .values({ + id, + announcementId, + userId, + }) + .onDuplicateKeyUpdate({ + set: { announcementId }, // no-op update, only ensures existence + }) } /** @@ -335,3 +363,217 @@ export async function getEditAnnouncementPageData(id: string): Promise<{ grades: gradeList.map((g) => ({ id: g.id, name: g.name })), } } + +/** + * 管理端公告详情页编排函数(P1-2 新增)。 + * + * 管理员可查看任意状态的公告,同时返回已读人数用于展示。 + */ +export async function getAdminAnnouncementDetailPageData(id: string): Promise<{ + announcement: Announcement | null + readCount: number +}> { + const [announcement, readCount] = await Promise.all([ + getAnnouncementById(id), + getAnnouncementReadCount(id), + ]) + + if (!announcement) return { announcement: null, readCount: 0 } + return { announcement: { ...announcement, readCount }, readCount } +} + +/** + * 根据当前用户的数据范围解析公告受众信息(gradeIds / classIds 数组)。 + * + * P0-2 修复:从单值返回升级为数组返回,解决: + * - 家长多孩子(childrenIds 全部解析,非仅首个) + * - 年级主任多年级(gradeIds 全部返回,非仅首个) + * - 教师多班级(classIds 全部返回,非仅首个) + * + * - all(管理员):返回 null(可见所有公告) + * - grade_managed:返回 dataScope.gradeIds 全量 + * - class_members / class_taught:返回 dataScope.classIds 全量 + 各 class 所属 gradeId 并集 + * - children:遍历所有孩子,收集其活跃 classId 与 gradeId 的并集 + * - owned / 其他:尝试用当前 userId 查询(兼容学生直接访问) + * + * 返回 null 表示不按受众过滤(管理员视角)。 + */ +export async function resolveUserAudience( + userId: string, + dataScope: DataScope +): Promise { + if (dataScope.type === "all") return null + + if (dataScope.type === "grade_managed") { + return { gradeIds: [...dataScope.gradeIds], classIds: [] } + } + + if (dataScope.type === "class_members" || dataScope.type === "class_taught") { + const classIds = [...dataScope.classIds] + if (classIds.length === 0) return { gradeIds: [], classIds: [] } + const { getClassGradeId } = await import("@/modules/classes/data-access") + const gradeIdResults = await Promise.all(classIds.map((cid) => getClassGradeId(cid))) + const gradeIdSet = new Set() + for (const gid of gradeIdResults) { + if (gid) gradeIdSet.add(gid) + } + return { gradeIds: Array.from(gradeIdSet), classIds } + } + + if (dataScope.type === "children") { + const childIds = [...dataScope.childrenIds] + if (childIds.length === 0) return { gradeIds: [], classIds: [] } + const { getStudentActiveClassId, getStudentActiveGradeId } = await import("@/modules/classes/data-access") + const [classIdLists, gradeIdLists] = await Promise.all([ + Promise.all(childIds.map((cid) => getStudentActiveClassId(cid))), + Promise.all(childIds.map((cid) => getStudentActiveGradeId(cid))), + ]) + const classIdSet = new Set() + const gradeIdSet = new Set() + for (const cid of classIdLists) if (cid) classIdSet.add(cid) + for (const gid of gradeIdLists) if (gid) gradeIdSet.add(gid) + return { + gradeIds: Array.from(gradeIdSet), + classIds: Array.from(classIdSet), + } + } + + // owned / 其他:尝试用当前 userId 查询(兼容学生角色直接访问) + const { getStudentActiveClassId, getStudentActiveGradeId } = await import("@/modules/classes/data-access") + const [classId, gradeId] = await Promise.all([ + getStudentActiveClassId(userId), + getStudentActiveGradeId(userId), + ]) + return { + gradeIds: gradeId ? [gradeId] : [], + classIds: classId ? [classId] : [], + } +} + +/** + * 判断一条公告对给定受众是否可见(P0-1 新增)。 + * - school 类型:对所有受众可见 + * - grade 类型:仅当 targetGradeId ∈ audience.gradeIds + * - class 类型:仅当 targetClassId ∈ audience.classIds + * audience 为 null 表示管理员视角,对所有公告可见。 + */ +export function isAnnouncementVisibleToAudience( + announcement: { type: Announcement["type"]; targetGradeId: string | null; targetClassId: string | null }, + audience: UserAudience | null +): boolean { + if (!audience) return true + if (announcement.type === "school") return true + if (announcement.type === "grade") { + return !!announcement.targetGradeId && audience.gradeIds.includes(announcement.targetGradeId) + } + if (announcement.type === "class") { + return !!announcement.targetClassId && audience.classIds.includes(announcement.targetClassId) + } + return false +} + +/** + * 用户端公告详情页编排函数(P0-1 新增)。 + * + * 在 data-access 层结合当前用户受众过滤,避免越权读取草稿/归档/他人班级公告: + * - 仅返回 status="published" 的公告 + * - 仅当公告对当前用户受众可见时返回,否则返回 null(路由层应转为 404) + * - 同时查询当前用户的已读状态与已读总人数,一次返回 + */ +export async function getAnnouncementByIdForUser( + id: string, + userId: string, + dataScope: DataScope +): Promise { + const [announcement, audience, isRead, readCount] = await Promise.all([ + getAnnouncementById(id), + resolveUserAudience(userId, dataScope), + isAnnouncementReadByUser(id, userId), + getAnnouncementReadCount(id), + ]) + + if (!announcement) return null + if (announcement.status !== "published") return null + if (!isAnnouncementVisibleToAudience(announcement, audience)) return null + + return { + ...announcement, + isReadByCurrentUser: isRead, + readCount, + } +} + +/** + * 用户端公告列表页编排函数:根据当前用户的数据范围解析受众, + * 并返回已发布的、对该受众可见的公告列表。 + * + * V3-P0-2: 将原本在 page.tsx 中的 resolveAudience 业务逻辑下沉到 data-access 层, + * 页面层只需调用单一函数,符合三层架构规范。 + * P0-2: resolveUserAudience 升级为数组返回,支持多孩子/多班级/多年级。 + */ +/** + * P2-6: 用户端公告列表页分页编排函数。 + * + * 返回 `{ items, total, page, pageSize }` 供 AnnouncementPagination 组件使用。 + * page 从 1 开始;pageSize 默认 12(卡片网格 3 列 × 4 行)。 + * 内部并行执行列表查询与 count 查询,减少总延迟。 + */ +export async function getUserAnnouncementsPageData( + userId: string, + dataScope: DataScope, + page: number = 1, + pageSize: number = 12 +): Promise { + const audience = await resolveUserAudience(userId, dataScope) + const safePage = Math.max(1, page) + const safePageSize = Math.max(1, pageSize) + + const queryArgs = { + status: "published" as const, + audience: audience ?? undefined, + page: safePage, + pageSize: safePageSize, + } + + const [items, total] = await Promise.all([ + getAnnouncements(queryArgs), + countAnnouncements(queryArgs), + ]) + + return { items, total, page: safePage, pageSize: safePageSize } +} + +/** + * 解析公告的目标用户 ID 列表(P1-5: 从 actions.ts 下沉到 data-access)。 + * + * 纯业务编排逻辑(聚合跨模块 data-access 的用户查询),独立于 Server Action 编排, + * 便于单元测试 mock 跨模块 data-access。 + * + * - school: 全校所有用户 + * - grade: 该年级下所有用户 + * - class: 该班级学生 + 任课教师 + 班主任 + */ +export async function resolveAnnouncementTargetUserIds( + announcement: Announcement +): Promise { + if (announcement.type === "school") { + const { getAllUserIds } = await import("@/modules/users/data-access") + return getAllUserIds() + } + + if (announcement.type === "grade" && announcement.targetGradeId) { + const { getUserIdsByGradeId } = await import("@/modules/users/data-access") + return getUserIdsByGradeId(announcement.targetGradeId) + } + + if (announcement.type === "class" && announcement.targetClassId) { + const { getStudentIdsByClassId, getTeacherIdsByClassIds } = await import("@/modules/classes/data-access") + const [studentIds, teacherIds] = await Promise.all([ + getStudentIdsByClassId(announcement.targetClassId), + getTeacherIdsByClassIds([announcement.targetClassId]), + ]) + return Array.from(new Set([...studentIds, ...teacherIds])) + } + + return [] +} diff --git a/src/modules/announcements/is-announcement-visible.test.ts b/src/modules/announcements/is-announcement-visible.test.ts new file mode 100644 index 0000000..c74442f --- /dev/null +++ b/src/modules/announcements/is-announcement-visible.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest" + +import { isAnnouncementVisibleToAudience } from "./data-access" +import type { UserAudience } from "./types" + +/** + * P2-7: `isAnnouncementVisibleToAudience` 纯函数单元测试。 + * + * 此函数无 DB 依赖(不读取 data-access.ts 顶部的 `import "server-only"` 副作用字段), + * 但因 `data-access.ts` 顶部声明 `import "server-only"`,需在 vitest config 中 + * 已将 `server-only` 映射为空模块方可运行(项目已配置,见 vitest.config.ts)。 + * + * 覆盖矩阵: + * - audience=null(管理员视角):所有类型公告均可见 + * - school 类型:对所有 audience 可见(即便 audience 为空) + * - grade 类型:targetGradeId ∈ audience.gradeIds 时可见 + * - class 类型:targetClassId ∈ audience.classIds 时可见 + * - 边界:targetGradeId/targetClassId 为 null 时不可见(除 school 外) + * - 多孩子/多班级场景:数组中任一匹配即可见 + */ +describe("isAnnouncementVisibleToAudience", () => { + const schoolAnnouncement = { + type: "school" as const, + targetGradeId: null, + targetClassId: null, + } + + const gradeAnnouncement = { + type: "grade" as const, + targetGradeId: "grade-1", + targetClassId: null, + } + + const classAnnouncement = { + type: "class" as const, + targetGradeId: null, + targetClassId: "class-1", + } + + describe("audience=null(管理员视角)", () => { + it("school 公告可见", () => { + expect(isAnnouncementVisibleToAudience(schoolAnnouncement, null)).toBe(true) + }) + + it("grade 公告可见", () => { + expect(isAnnouncementVisibleToAudience(gradeAnnouncement, null)).toBe(true) + }) + + it("class 公告可见", () => { + expect(isAnnouncementVisibleToAudience(classAnnouncement, null)).toBe(true) + }) + }) + + describe("school 类型公告", () => { + it("对空 audience 可见", () => { + const emptyAudience: UserAudience = { gradeIds: [], classIds: [] } + expect(isAnnouncementVisibleToAudience(schoolAnnouncement, emptyAudience)).toBe(true) + }) + + it("对任意 audience 可见", () => { + const audience: UserAudience = { gradeIds: ["grade-99"], classIds: ["class-99"] } + expect(isAnnouncementVisibleToAudience(schoolAnnouncement, audience)).toBe(true) + }) + }) + + describe("grade 类型公告", () => { + it("targetGradeId ∈ audience.gradeIds:可见", () => { + const audience: UserAudience = { gradeIds: ["grade-1", "grade-2"], classIds: [] } + expect(isAnnouncementVisibleToAudience(gradeAnnouncement, audience)).toBe(true) + }) + + it("targetGradeId ∉ audience.gradeIds:不可见", () => { + const audience: UserAudience = { gradeIds: ["grade-2", "grade-3"], classIds: [] } + expect(isAnnouncementVisibleToAudience(gradeAnnouncement, audience)).toBe(false) + }) + + it("audience.gradeIds 为空:不可见", () => { + const audience: UserAudience = { gradeIds: [], classIds: [] } + expect(isAnnouncementVisibleToAudience(gradeAnnouncement, audience)).toBe(false) + }) + + it("targetGradeId 为 null:不可见(数据完整性问题)", () => { + const invalidGradeAnnouncement = { + type: "grade" as const, + targetGradeId: null, + targetClassId: null, + } + const audience: UserAudience = { gradeIds: ["grade-1"], classIds: [] } + expect(isAnnouncementVisibleToAudience(invalidGradeAnnouncement, audience)).toBe(false) + }) + + it("P0-2 多孩子场景:年级在 audience 中即可见", () => { + // 家长有两个孩子分别在不同年级 + const audience: UserAudience = { gradeIds: ["grade-5", "grade-6"], classIds: [] } + const grade5Announcement = { + type: "grade" as const, + targetGradeId: "grade-5", + targetClassId: null, + } + const grade6Announcement = { + type: "grade" as const, + targetGradeId: "grade-6", + targetClassId: null, + } + expect(isAnnouncementVisibleToAudience(grade5Announcement, audience)).toBe(true) + expect(isAnnouncementVisibleToAudience(grade6Announcement, audience)).toBe(true) + }) + }) + + describe("class 类型公告", () => { + it("targetClassId ∈ audience.classIds:可见", () => { + const audience: UserAudience = { gradeIds: [], classIds: ["class-1", "class-2"] } + expect(isAnnouncementVisibleToAudience(classAnnouncement, audience)).toBe(true) + }) + + it("targetClassId ∉ audience.classIds:不可见", () => { + const audience: UserAudience = { gradeIds: [], classIds: ["class-2", "class-3"] } + expect(isAnnouncementVisibleToAudience(classAnnouncement, audience)).toBe(false) + }) + + it("audience.classIds 为空:不可见", () => { + const audience: UserAudience = { gradeIds: [], classIds: [] } + expect(isAnnouncementVisibleToAudience(classAnnouncement, audience)).toBe(false) + }) + + it("targetClassId 为 null:不可见", () => { + const invalidClassAnnouncement = { + type: "class" as const, + targetGradeId: null, + targetClassId: null, + } + const audience: UserAudience = { gradeIds: [], classIds: ["class-1"] } + expect(isAnnouncementVisibleToAudience(invalidClassAnnouncement, audience)).toBe(false) + }) + + it("P0-2 教师多班级场景:班级在 audience 中即可见", () => { + // 教师同时任教两个班级 + const audience: UserAudience = { gradeIds: [], classIds: ["class-a", "class-b"] } + const classAAnnouncement = { + type: "class" as const, + targetGradeId: null, + targetClassId: "class-a", + } + const classBAnnouncement = { + type: "class" as const, + targetGradeId: null, + targetClassId: "class-b", + } + expect(isAnnouncementVisibleToAudience(classAAnnouncement, audience)).toBe(true) + expect(isAnnouncementVisibleToAudience(classBAnnouncement, audience)).toBe(true) + }) + }) +}) diff --git a/src/modules/announcements/schema.test.ts b/src/modules/announcements/schema.test.ts new file mode 100644 index 0000000..9d8e741 --- /dev/null +++ b/src/modules/announcements/schema.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest" + +import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema" + +/** + * 公告 Schema 校验测试(P2-7)。 + * + * 重点验证 `refineAudience` 条件校验矩阵: + * - school 类型:targetGradeId / targetClassId 必须为空 + * - grade 类型:targetGradeId 必填 + * - class 类型:targetClassId 必填 + * - 默认 type="school" + */ +describe("CreateAnnouncementSchema", () => { + const validSchoolAnnouncement = { + title: "期末考试安排", + content: "请各位同学注意考试时间。", + } + + describe("基础字段校验", () => { + it("接受最小有效输入(默认 type=school / status=draft)", () => { + const result = CreateAnnouncementSchema.safeParse(validSchoolAnnouncement) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe("school") + expect(result.data.status).toBe("draft") + expect(result.data.targetGradeId).toBeNull() + expect(result.data.targetClassId).toBeNull() + } + }) + + it("拒绝空标题", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + title: " ", + }) + expect(result.success).toBe(false) + }) + + it("拒绝空内容", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + content: "", + }) + expect(result.success).toBe(false) + }) + + it("拒绝超过 255 字符的标题", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + title: "a".repeat(256), + }) + expect(result.success).toBe(false) + }) + + it("拒绝非法 type 枚举值", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "department", + }) + expect(result.success).toBe(false) + }) + + it("拒绝非法 status 枚举值", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + status: "deleted", + }) + expect(result.success).toBe(false) + }) + }) + + describe("refineAudience 条件校验", () => { + it("school 类型 + 无 target:通过", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "school", + }) + expect(result.success).toBe(true) + }) + + it("school 类型 + 有 targetGradeId:拒绝(path 指向 targetGradeId)", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "school", + targetGradeId: "grade-1", + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain("targetGradeId") + } + }) + + it("school 类型 + 有 targetClassId:拒绝", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "school", + targetClassId: "class-1", + }) + expect(result.success).toBe(false) + }) + + it("grade 类型 + targetGradeId 必填:缺失时拒绝", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "grade", + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain("targetGradeId") + } + }) + + it("grade 类型 + targetGradeId 提供:通过", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "grade", + targetGradeId: "grade-1", + }) + expect(result.success).toBe(true) + }) + + it("grade 类型 + 仅空格 targetGradeId:拒绝", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "grade", + targetGradeId: " ", + }) + expect(result.success).toBe(false) + }) + + it("class 类型 + targetClassId 必填:缺失时拒绝", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "class", + }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].path).toContain("targetClassId") + } + }) + + it("class 类型 + targetClassId 提供:通过", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + type: "class", + targetClassId: "class-1", + }) + expect(result.success).toBe(true) + }) + }) + + describe("transform 输出归一化", () => { + it("空字符串 targetGradeId 归一化为 null", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + targetGradeId: "", + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.targetGradeId).toBeNull() + } + }) + + it("空字符串 targetClassId 归一化为 null", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + targetClassId: "", + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.targetClassId).toBeNull() + } + }) + + it("空字符串 publishedAt 归一化为 null", () => { + const result = CreateAnnouncementSchema.safeParse({ + ...validSchoolAnnouncement, + publishedAt: "", + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.publishedAt).toBeNull() + } + }) + }) +}) + +/** + * UpdateAnnouncementSchema 应复用 refineAudience, + * 此处仅做最小回归测试,避免 update 路径 schema 漂移。 + */ +describe("UpdateAnnouncementSchema", () => { + it("复用与 Create 相同的 refineAudience 校验规则", () => { + const gradeResult = UpdateAnnouncementSchema.safeParse({ + title: "更新标题", + content: "更新内容", + type: "grade", + }) + expect(gradeResult.success).toBe(false) + + const validResult = UpdateAnnouncementSchema.safeParse({ + title: "更新标题", + content: "更新内容", + type: "class", + targetClassId: "class-1", + }) + expect(validResult.success).toBe(true) + }) +}) diff --git a/src/modules/announcements/types.ts b/src/modules/announcements/types.ts index b65f217..995bbe4 100644 --- a/src/modules/announcements/types.ts +++ b/src/modules/announcements/types.ts @@ -1,3 +1,5 @@ +import type { ActionState } from "@/shared/types/action-state" + export type AnnouncementStatus = "draft" | "published" | "archived" export type AnnouncementType = "school" | "grade" | "class" @@ -32,16 +34,29 @@ export type GetAnnouncementsParams = { /** * 受众过滤(用户端使用):当提供时,仅返回对该受众可见的公告。 * - school 类型公告:对所有受众可见 - * - grade 类型公告:仅当 targetGradeId 与 audience.gradeId 匹配时可见 - * - class 类型公告:仅当 targetClassId 与 audience.classId 匹配时可见 + * - grade 类型公告:仅当 targetGradeId ∈ audience.gradeIds 时可见 + * - class 类型公告:仅当 targetClassId ∈ audience.classIds 时可见 + * + * P0-2 修复:从单值 `{ gradeId?, classId? }` 升级为数组, + * 解决家长多孩子 / 教师多班级 / 年级主任多年级时只能看到首个受众的截断 Bug。 * 未提供时(管理端)返回所有公告。 */ audience?: { - gradeId?: string - classId?: string + gradeIds: string[] + classIds: string[] } } +/** + * 用户受众信息(P0-2 新增)。 + * 由 data-access 的 `resolveUserAudience` 解析得到, + * 用于公告可见性判断与列表过滤。 + */ +export interface UserAudience { + gradeIds: string[] + classIds: string[] +} + export interface AnnouncementInsertData { id: string title: string @@ -72,3 +87,38 @@ export interface AnnouncementRead { userId: string readAt: string } + +/** + * P2-6: 分页查询结果。 + * + * 由 `getUserAnnouncementsPageData` 与 `getAdminAnnouncementsPageData` 返回, + * 供 `AnnouncementPagination` 组件计算页码与上一页/下一页导航。 + */ +export interface PaginatedAnnouncements { + items: Announcement[] + total: number + page: number + pageSize: number +} + +/** + * P1-3: 公告服务接口(依赖注入抽象)。 + * + * 组件层通过 `useAnnouncementsService()` 消费此接口,而非直接 import actions, + * 实现完全解耦: + * - 默认实现调用真实 Server Actions(见 `default-announcements-service.ts`) + * - 测试时可注入 mock 实现 + * - 未来不同角色可注入不同实现(如家长只读、教师可编辑班级公告) + * + * 所有方法返回 `ActionState`,与 Server Action 签名保持一致,便于无缝替换。 + */ +export interface AnnouncementsService { + create(prevState: ActionState | null, formData: FormData): Promise> + update(id: string, prevState: ActionState | null, formData: FormData): Promise> + delete(id: string): Promise> + publish(id: string): Promise> + archive(id: string): Promise> + togglePin(id: string): Promise> + markRead(announcementId: string): Promise> + getReadStatus(announcementIds: string[]): Promise>> +}