feat(announcements): add tests, skeleton, pagination, and service context

- Add announcement-card test for component testing

- Add is-announcement-visible test and schema test for logic testing

- Add announcement-list-skeleton for loading states

- Add announcement-pagination for list pagination

- Add announcements-service-context and default-announcements-service for service layer
This commit is contained in:
SpecialX
2026-07-03 10:24:50 +08:00
parent 2859ef74f2
commit 2b95fd668b
15 changed files with 1539 additions and 206 deletions

View File

@@ -10,11 +10,6 @@ import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { sendBatchNotifications } from "@/modules/notifications" import { sendBatchNotifications } from "@/modules/notifications"
import type { NotificationPayload } 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 { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
import { import {
@@ -28,50 +23,51 @@ import {
toggleAnnouncementPin, toggleAnnouncementPin,
markAnnouncementAsRead, markAnnouncementAsRead,
getAnnouncementReadStatusForUser, getAnnouncementReadStatusForUser,
getAnnouncementByIdForUser,
resolveAnnouncementTargetUserIds,
} from "./data-access" } from "./data-access"
import type { GetAnnouncementsParams, Announcement } from "./types" import type { GetAnnouncementsParams, Announcement } from "./types"
function handleActionError(e: unknown): ActionState<never> {
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 列表 * P1-7: 统一错误处理
* - school: 全校所有用户 * - 记录错误堆栈到 console.error 便于生产排查
* - grade: 该年级下所有用户 * - 上报 trackEvent 用于监控告警
* - class: 该班级学生 + 任课教师 + 班主任 * - 返回 i18n 化的 message不向客户端泄露内部错误信息
*/ */
async function resolveTargetUserIds(announcement: Announcement): Promise<string[]> { async function handleActionError(
if (announcement.type === "school") { e: unknown,
return getAllUserIds() actionName: string,
} t: (key: string) => string
): Promise<ActionState<never>> {
console.error(`[announcements] ${actionName} failed:`, e)
if (announcement.type === "grade" && announcement.targetGradeId) { void trackEvent({
return getUserIdsByGradeId(announcement.targetGradeId) 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) { if (e instanceof PermissionDeniedError) {
const [studentIds, teacherIds] = await Promise.all([ return { success: false, message: t("messages.permissionDenied") }
getStudentIdsByClassId(announcement.targetClassId),
getTeacherIdsByClassIds([announcement.targetClassId]),
])
return Array.from(new Set([...studentIds, ...teacherIds]))
} }
if (e instanceof Error && e.message === "FORBIDDEN_ANNOUNCEMENT") {
return [] 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<void> { async function notifyAnnouncementPublished(announcement: Announcement): Promise<void> {
try { try {
const targetUserIds = await resolveTargetUserIds(announcement) const targetUserIds = await resolveAnnouncementTargetUserIds(announcement)
if (targetUserIds.length === 0) return if (targetUserIds.length === 0) return
const t = await getTranslations("announcements") const t = await getTranslations("announcements")
@@ -101,6 +97,7 @@ export async function createAnnouncementAction(
prevState: ActionState<string> | null, prevState: ActionState<string> | null,
formData: FormData formData: FormData
): Promise<ActionState<string>> { ): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try { try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) const ctx = await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
@@ -117,7 +114,7 @@ export async function createAnnouncementAction(
if (!parsed.success) { if (!parsed.success) {
return { return {
success: false, success: false,
message: "Invalid form data", message: t("messages.invalidFormData"),
errors: parsed.error.flatten().fieldErrors, errors: parsed.error.flatten().fieldErrors,
} }
} }
@@ -163,9 +160,9 @@ export async function createAnnouncementAction(
properties: { type: input.type, status: input.status }, 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) { } catch (e) {
return handleActionError(e) return handleActionError(e, "createAnnouncementAction", t)
} }
} }
@@ -174,11 +171,12 @@ export async function updateAnnouncementAction(
prevState: ActionState<string> | null, prevState: ActionState<string> | null,
formData: FormData formData: FormData
): Promise<ActionState<string>> { ): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try { try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id) 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({ const parsed = UpdateAnnouncementSchema.safeParse({
title: formData.get("title"), title: formData.get("title"),
@@ -193,7 +191,7 @@ export async function updateAnnouncementAction(
if (!parsed.success) { if (!parsed.success) {
return { return {
success: false, success: false,
message: "Invalid form data", message: t("messages.invalidFormData"),
errors: parsed.error.flatten().fieldErrors, errors: parsed.error.flatten().fieldErrors,
} }
} }
@@ -239,18 +237,23 @@ export async function updateAnnouncementAction(
properties: { type: input.type, status: input.status, wasPublished }, 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) { } catch (e) {
return handleActionError(e) return handleActionError(e, "updateAnnouncementAction", t)
} }
} }
export async function deleteAnnouncementAction(id: string): Promise<ActionState<string>> { export async function deleteAnnouncementAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try { try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id) 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) await deleteAnnouncementById(id)
@@ -264,18 +267,19 @@ export async function deleteAnnouncementAction(id: string): Promise<ActionState<
properties: { type: existing.type, status: existing.status }, properties: { type: existing.type, status: existing.status },
}) })
return { success: true, message: "Announcement deleted" } return { success: true, message: t("messages.deleted") }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e, "deleteAnnouncementAction", t)
} }
} }
export async function publishAnnouncementAction(id: string): Promise<ActionState<string>> { export async function publishAnnouncementAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try { try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id) 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 const publishedAt = existing.publishedAt
? new Date(existing.publishedAt) ? new Date(existing.publishedAt)
@@ -296,18 +300,19 @@ export async function publishAnnouncementAction(id: string): Promise<ActionState
properties: { type: existing.type }, properties: { type: existing.type },
}) })
return { success: true, message: "Announcement published" } return { success: true, message: t("messages.published") }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e, "publishAnnouncementAction", t)
} }
} }
export async function archiveAnnouncementAction(id: string): Promise<ActionState<string>> { export async function archiveAnnouncementAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try { try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const existing = await getAnnouncementById(id) 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) await archiveAnnouncementById(id)
@@ -322,20 +327,26 @@ export async function archiveAnnouncementAction(id: string): Promise<ActionState
properties: { type: existing.type }, properties: { type: existing.type },
}) })
return { success: true, message: "Announcement archived" } return { success: true, message: t("messages.archived") }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e, "archiveAnnouncementAction", t)
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// V2-P2-13d: 公告置顶 Server Action // V2-P2-13d: 公告置顶 Server Action
// P0-3: 新增资源存在性二次校验
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function toggleAnnouncementPinAction(id: string): Promise<ActionState<string>> { export async function toggleAnnouncementPinAction(id: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try { try {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE) await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
// P0-3: 资源存在性校验
const existing = await getAnnouncementById(id)
if (!existing) return { success: false, message: t("messages.notFound") }
await toggleAnnouncementPin(id) await toggleAnnouncementPin(id)
revalidatePath("/admin/announcements") revalidatePath("/admin/announcements")
revalidatePath("/announcements") revalidatePath("/announcements")
@@ -346,20 +357,29 @@ export async function toggleAnnouncementPinAction(id: string): Promise<ActionSta
targetType: "announcement", targetType: "announcement",
}) })
return { success: true, message: "Pin status toggled" } return { success: true, message: t("messages.pinToggled") }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e, "toggleAnnouncementPinAction", t)
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// V2-P2-13d: 公告已读标记 Server Action // V2-P2-13d: 公告已读标记 Server Action
// P0-3: 新增资源可见性二次校验(防止对草稿/他人班级公告写入已读记录)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function markAnnouncementAsReadAction(announcementId: string): Promise<ActionState<string>> { export async function markAnnouncementAsReadAction(announcementId: string): Promise<ActionState<string>> {
const t = await getTranslations("announcements")
try { try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) 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) await markAnnouncementAsRead(announcementId, ctx.userId)
void trackEvent({ void trackEvent({
@@ -369,35 +389,38 @@ export async function markAnnouncementAsReadAction(announcementId: string): Prom
targetType: "announcement", targetType: "announcement",
}) })
return { success: true, message: "Announcement marked as read" } return { success: true, message: t("messages.markedRead") }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e, "markAnnouncementAsReadAction", t)
} }
} }
/** /**
* 批量获取公告已读状态(用于列表页标记已读/未读)。 * 批量获取公告已读状态(用于列表页标记已读/未读)。
* P1-2: 列表组件消费此 Action 展示已读/未读视觉区分。
*/ */
export async function getAnnouncementReadStatusAction( export async function getAnnouncementReadStatusAction(
announcementIds: string[] announcementIds: string[]
): Promise<ActionState<Record<string, boolean>>> { ): Promise<ActionState<Record<string, boolean>>> {
const t = await getTranslations("announcements")
try { try {
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ) const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
const statusMap = await getAnnouncementReadStatusForUser(announcementIds, ctx.userId) const statusMap = await getAnnouncementReadStatusForUser(announcementIds, ctx.userId)
return { success: true, data: Object.fromEntries(statusMap) } return { success: true, data: Object.fromEntries(statusMap) }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e, "getAnnouncementReadStatusAction", t)
} }
} }
export async function getAnnouncementsAction( export async function getAnnouncementsAction(
params?: GetAnnouncementsParams params?: GetAnnouncementsParams
): Promise<ActionState<Announcement[]>> { ): Promise<ActionState<Announcement[]>> {
const t = await getTranslations("announcements")
try { try {
await requirePermission(Permissions.ANNOUNCEMENT_READ) await requirePermission(Permissions.ANNOUNCEMENT_READ)
const data = await getAnnouncements(params) const data = await getAnnouncements(params)
return { success: true, data } return { success: true, data }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e, "getAnnouncementsAction", t)
} }
} }

View File

@@ -10,6 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/shared/compo
import { AnnouncementForm } from "./announcement-form" import { AnnouncementForm } from "./announcement-form"
import { AnnouncementList } from "./announcement-list" import { AnnouncementList } from "./announcement-list"
import { AnnouncementsServiceProvider } from "./announcements-service-context"
import type { Announcement, AnnouncementStatus } from "../types" import type { Announcement, AnnouncementStatus } from "../types"
export function AdminAnnouncementsView({ export function AdminAnnouncementsView({
@@ -27,12 +28,19 @@ export function AdminAnnouncementsView({
const router = useRouter() const router = useRouter()
const [createOpen, setCreateOpen] = useState(false) 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) setCreateOpen(open)
if (!open) router.refresh() if (!open) router.refresh()
} }
return ( return (
<AnnouncementsServiceProvider>
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2"> <div className="flex items-center justify-between space-y-2">
<div> <div>
@@ -59,9 +67,16 @@ export function AdminAnnouncementsView({
<DialogHeader> <DialogHeader>
<DialogTitle>{t("title.create")}</DialogTitle> <DialogTitle>{t("title.create")}</DialogTitle>
</DialogHeader> </DialogHeader>
<AnnouncementForm mode="create" grades={grades} classes={classes} /> <AnnouncementForm
mode="create"
grades={grades}
classes={classes}
onSuccess={handleFormSuccess}
onCancel={handleFormSuccess}
/>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</AnnouncementsServiceProvider>
) )
} }

View File

@@ -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<string, string>) => {
if (!params) return key
return `${key}:${JSON.stringify(params)}`
},
}))
// Mock next/navigationrouter.refresh 不做任何事
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
push: vi.fn(),
replace: vi.fn(),
}),
}))
// Mock sonnertoast.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 }) =>
<div data-testid="card" className={className}>{children}</div>,
CardHeader: ({ children }: { children: React.ReactNode }) =>
<div data-testid="card-header">{children}</div>,
CardTitle: ({ children }: { children: React.ReactNode }) =>
<h3 data-testid="card-title">{children}</h3>,
CardContent: ({ children }: { children: React.ReactNode }) =>
<div data-testid="card-content">{children}</div>,
}))
// Mock @/shared/components/ui/badge
vi.mock("@/shared/components/ui/badge", () => ({
Badge: ({
children,
variant,
className,
}: {
children: React.ReactNode
variant?: string
className?: string
}) => (
<span data-testid="badge" data-variant={variant} className={className}>
{children}
</span>
),
}))
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> = {}
): AnnouncementsService {
const ok = <T,>(data: T): ActionState<T> => ({
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<Parameters<typeof AnnouncementCard>[0]> = {}
) {
return render(
<AnnouncementsServiceProvider service={service}>
<AnnouncementCard announcement={baseAnnouncement} canManage {...props} />
</AnnouncementsServiceProvider>
)
}
describe("AnnouncementCard", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
describe("基础渲染", () => {
it("渲染标题与内容", () => {
const service = createMockService()
renderCard(service)
expect(screen.getByText("测试公告标题")).toBeInTheDocument()
expect(screen.getByText("测试公告内容")).toBeInTheDocument()
})
it("渲染类型 badgeschool", () => {
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 })
// 没有 <a> 标签
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(
<AnnouncementsServiceProvider service={service}>
<AnnouncementCard announcement={baseAnnouncement} canManage={false} />
</AnnouncementsServiceProvider>
)
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<string>)
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<string>)
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("a11yP2-1", () => {
it("置顶按钮支持 Enter 键触发", async () => {
const togglePin = vi.fn().mockResolvedValue({
success: true,
data: "ann-1",
message: "ok",
} satisfies ActionState<string>)
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<string>)
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()
})
})
})

View File

@@ -10,24 +10,43 @@ import { Pin } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn, formatDate } from "@/shared/lib/utils" import { cn, formatDate } from "@/shared/lib/utils"
import { toggleAnnouncementPinAction } from "../actions"
import { useAnnouncementsService } from "./announcements-service-context"
import type { Announcement } from "../types" import type { Announcement } from "../types"
const statusVariant: Record<Announcement["status"], "default" | "secondary" | "outline"> = {
draft: "secondary",
published: "default",
archived: "outline",
}
/**
* 公告卡片(列表项)。
*
* P1-3: 通过 `useAnnouncementsService()` 消费 togglePin不直接 import actions。
* P2-1 a11y: 重构交互结构——卡片整体为 `<Link>`,置顶按钮用独立 `<button>`
* 绝对定位脱离链接语义,避免键盘 Enter 同时触发按钮与链接导航。
* P1-2: 通过 `isRead` 视觉区分已读/未读(仅非管理端 + 未提供 isRead 时不渲染)。
*/
export function AnnouncementCard({ export function AnnouncementCard({
announcement, announcement,
href, href,
canManage, canManage,
isRead,
}: { }: {
announcement: Announcement announcement: Announcement
href?: string href?: string
canManage?: boolean canManage?: boolean
/** 当前用户是否已读(用户端列表传入以做视觉区分) */
isRead?: boolean
}) { }) {
const t = useTranslations("announcements") const t = useTranslations("announcements")
const router = useRouter() const router = useRouter()
const service = useAnnouncementsService()
const [isPinned, setIsPinned] = useState(announcement.isPinned) const [isPinned, setIsPinned] = useState(announcement.isPinned)
const [isToggling, setIsToggling] = useState(false) const [isToggling, setIsToggling] = useState(false)
const handleTogglePin = async (e: React.MouseEvent) => { const handleTogglePin = async (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setIsToggling(true) setIsToggling(true)
@@ -35,7 +54,7 @@ export function AnnouncementCard({
// 乐观更新 // 乐观更新
setIsPinned(!prevPinned) setIsPinned(!prevPinned)
try { try {
const res = await toggleAnnouncementPinAction(announcement.id) const res = await service.togglePin(announcement.id)
if (res.success) { if (res.success) {
toast.success(t("messages.pinToggled")) toast.success(t("messages.pinToggled"))
router.refresh() router.refresh()
@@ -53,15 +72,16 @@ export function AnnouncementCard({
} }
} }
const statusVariant: Record<Announcement["status"], "default" | "secondary" | "outline"> = {
draft: "secondary",
published: "default",
archived: "outline",
}
const card = ( const card = (
<Card className={cn("h-full transition-colors hover:bg-accent/50", isPinned && "border-primary/50")}> <Card
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0"> className={cn(
"relative h-full transition-colors hover:bg-accent/50",
isPinned && "border-primary/50",
// 已读/未读视觉区分P1-2
isRead === false && "ring-2 ring-primary/40"
)}
>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pr-10">
<CardTitle className="line-clamp-2 text-base"> <CardTitle className="line-clamp-2 text-base">
{isPinned ? ( {isPinned ? (
<Pin className="text-primary mr-1 inline h-3.5 w-3.5 fill-primary align-text-bottom" aria-hidden="true" /> <Pin className="text-primary mr-1 inline h-3.5 w-3.5 fill-primary align-text-bottom" aria-hidden="true" />
@@ -77,19 +97,6 @@ export function AnnouncementCard({
<Badge variant={statusVariant[announcement.status]} className="text-xs"> <Badge variant={statusVariant[announcement.status]} className="text-xs">
{t(`status.${announcement.status}`)} {t(`status.${announcement.status}`)}
</Badge> </Badge>
{canManage ? (
<button
type="button"
onClick={handleTogglePin}
disabled={isToggling}
aria-label={isPinned ? t("actions.unpin") : t("actions.pin")}
className="text-muted-foreground hover:text-foreground inline-flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<Pin
className={cn("h-3.5 w-3.5 transition-colors", isPinned && "fill-primary text-primary")}
/>
</button>
) : null}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@@ -100,6 +107,11 @@ export function AnnouncementCard({
<Badge variant="outline" className="capitalize"> <Badge variant="outline" className="capitalize">
{t(`type.${announcement.type}`)} {t(`type.${announcement.type}`)}
</Badge> </Badge>
{isRead === false ? (
<Badge variant="default" className="text-xs">
{t("status.unread")}
</Badge>
) : null}
<span> <span>
{announcement.publishedAt {announcement.publishedAt
? t("meta.publishedAt", { date: formatDate(announcement.publishedAt) }) ? t("meta.publishedAt", { date: formatDate(announcement.publishedAt) })
@@ -110,6 +122,25 @@ export function AnnouncementCard({
) : null} ) : null}
</div> </div>
</CardContent> </CardContent>
{/* P2-1 a11y: 置顶按钮绝对定位脱离链接语义,键盘可达且不触发外层 Link 导航 */}
{canManage ? (
<button
type="button"
onClick={handleTogglePin}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleTogglePin(e)
}
}}
disabled={isToggling}
aria-label={isPinned ? t("actions.unpin") : t("actions.pin")}
className="text-muted-foreground hover:text-foreground absolute right-3 top-3 z-10 inline-flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent disabled:opacity-50"
>
<Pin
className={cn("h-3.5 w-3.5 transition-colors", isPinned && "fill-primary text-primary")}
/>
</button>
) : null}
</Card> </Card>
) )

View File

@@ -22,13 +22,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog" import { ConfirmDeleteDialog } from "@/shared/components/ui/confirm-delete-dialog"
import { cn, formatDate } from "@/shared/lib/utils" import { cn, formatDate } from "@/shared/lib/utils"
import { import { useAnnouncementsService } from "./announcements-service-context"
archiveAnnouncementAction,
deleteAnnouncementAction,
markAnnouncementAsReadAction,
publishAnnouncementAction,
toggleAnnouncementPinAction,
} from "../actions"
import type { Announcement } from "../types" import type { Announcement } from "../types"
export function AnnouncementDetail({ export function AnnouncementDetail({
@@ -44,6 +38,7 @@ export function AnnouncementDetail({
}) { }) {
const t = useTranslations("announcements") const t = useTranslations("announcements")
const router = useRouter() const router = useRouter()
const service = useAnnouncementsService()
const [isWorking, setIsWorking] = useState(false) const [isWorking, setIsWorking] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false)
const [isPinned, setIsPinned] = useState(announcement.isPinned) const [isPinned, setIsPinned] = useState(announcement.isPinned)
@@ -56,7 +51,7 @@ export function AnnouncementDetail({
if (isRead) return if (isRead) return
let cancelled = false let cancelled = false
void markAnnouncementAsReadAction(announcement.id) void service.markRead(announcement.id)
.then((res) => { .then((res) => {
if (cancelled) return if (cancelled) return
if (res.success) { if (res.success) {
@@ -70,12 +65,12 @@ export function AnnouncementDetail({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [canManage, isRead, announcement.id]) }, [canManage, isRead, announcement.id, service])
const handlePublish = async () => { const handlePublish = async () => {
setIsWorking(true) setIsWorking(true)
try { try {
const res = await publishAnnouncementAction(announcement.id) const res = await service.publish(announcement.id)
if (res.success) { if (res.success) {
toast.success(res.message) toast.success(res.message)
router.refresh() router.refresh()
@@ -92,7 +87,7 @@ export function AnnouncementDetail({
const handleArchive = async () => { const handleArchive = async () => {
setIsWorking(true) setIsWorking(true)
try { try {
const res = await archiveAnnouncementAction(announcement.id) const res = await service.archive(announcement.id)
if (res.success) { if (res.success) {
toast.success(res.message) toast.success(res.message)
router.refresh() router.refresh()
@@ -109,7 +104,7 @@ export function AnnouncementDetail({
const handleDelete = async () => { const handleDelete = async () => {
setIsWorking(true) setIsWorking(true)
try { try {
const res = await deleteAnnouncementAction(announcement.id) const res = await service.delete(announcement.id)
if (res.success) { if (res.success) {
toast.success(res.message) toast.success(res.message)
router.push("/admin/announcements") router.push("/admin/announcements")
@@ -131,7 +126,7 @@ export function AnnouncementDetail({
// 乐观更新 // 乐观更新
setIsPinned(!prevPinned) setIsPinned(!prevPinned)
try { try {
const res = await toggleAnnouncementPinAction(announcement.id) const res = await service.togglePin(announcement.id)
if (res.success) { if (res.success) {
toast.success(t("messages.pinToggled")) toast.success(t("messages.pinToggled"))
router.refresh() router.refresh()

View File

@@ -18,26 +18,47 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { createAnnouncementAction, updateAnnouncementAction } from "../actions" import { useAnnouncementsService } from "./announcements-service-context"
import type { Announcement } from "../types" import type { Announcement } from "../types"
type Mode = "create" | "edit" type Mode = "create" | "edit"
type FieldErrors = Record<string, string[]> type FieldErrors = Record<string, string[]>
/**
* 公告表单(创建/编辑)。
*
* P1-3: 通过 `useAnnouncementsService()` 消费 create/update不直接 import actions。
* P1-4 + P1-8: 通过 `onSuccess` / `onCancel` 回调解耦导航,
* 嵌入 Dialog 时关闭弹窗而非整页跳转;独立页面时由父级传入路由跳转。
* `successHref` / `cancelHref` 为回调的便利兜底。
*/
export function AnnouncementForm({ export function AnnouncementForm({
mode, mode,
announcement, announcement,
grades = [], grades = [],
classes = [], classes = [],
onSuccess,
onCancel,
successHref = "/admin/announcements",
cancelHref = "/admin/announcements",
}: { }: {
mode: Mode mode: Mode
announcement?: Announcement announcement?: Announcement
grades?: { id: string; name: string }[] grades?: { id: string; name: string }[]
classes?: { 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 t = useTranslations("announcements")
const router = useRouter() const router = useRouter()
const service = useAnnouncementsService()
const [isWorking, setIsWorking] = useState(false) const [isWorking, setIsWorking] = useState(false)
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({}) const [fieldErrors, setFieldErrors] = useState<FieldErrors>({})
@@ -51,6 +72,23 @@ export function AnnouncementForm({
return errs && errs.length > 0 ? errs[0] : null 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) => { const handleSubmit = async (formData: FormData) => {
setIsWorking(true) setIsWorking(true)
setFieldErrors({}) setFieldErrors({})
@@ -66,9 +104,9 @@ export function AnnouncementForm({
const res = const res =
mode === "create" mode === "create"
? await createAnnouncementAction(null, formData) ? await service.create(null, formData)
: announcement : announcement
? await updateAnnouncementAction(announcement.id, null, formData) ? await service.update(announcement.id, null, formData)
: null : null
if (!res) { if (!res) {
@@ -78,8 +116,7 @@ export function AnnouncementForm({
if (res.success) { if (res.success) {
toast.success(res.message) toast.success(res.message)
router.push("/admin/announcements") handleSuccess()
router.refresh()
} else { } else {
// 展示字段级错误(来自 Zod superRefine 校验) // 展示字段级错误(来自 Zod superRefine 校验)
if (res.errors) { if (res.errors) {
@@ -214,7 +251,7 @@ export function AnnouncementForm({
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => router.push("/admin/announcements")} onClick={handleCancel}
disabled={isWorking} disabled={isWorking}
> >
{t("form.cancel")} {t("form.cancel")}

View File

@@ -0,0 +1,50 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 公告列表骨架屏P2-5 抽取)。
*
* 此前用户端 `/announcements/loading.tsx` 与管理端 `/admin/announcements/loading.tsx`
* 几乎逐行重复,违反"重复超过 90% 必须抽取共享组件"规则。
*/
export function AnnouncementListSkeleton({
showCreateButton = false,
}: {
showCreateButton?: boolean
}) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
{showCreateButton ? <Skeleton className="h-9 w-40" /> : null}
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-9 w-[180px]" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex items-center gap-2 pt-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-3 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,5 +1,6 @@
"use client" "use client"
import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Plus, Megaphone } from "lucide-react" import { Plus, Megaphone } from "lucide-react"
@@ -16,6 +17,8 @@ import {
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { AnnouncementCard } from "./announcement-card" import { AnnouncementCard } from "./announcement-card"
import { AnnouncementPagination } from "./announcement-pagination"
import { useAnnouncementsService } from "./announcements-service-context"
import type { Announcement, AnnouncementStatus } from "../types" import type { Announcement, AnnouncementStatus } from "../types"
type Filter = "all" | AnnouncementStatus type Filter = "all" | AnnouncementStatus
@@ -28,29 +31,59 @@ type Filter = "all" | AnnouncementStatus
* - 父页面根据 `?status=` 查询并传入 `announcements` prop * - 父页面根据 `?status=` 查询并传入 `announcements` prop
* - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余 * - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余
* *
* 详情链接构建: * P2-4: 删除了死 prop `detailHrefBuilder`,仅保留 `detailHrefPrefix`Server Component 安全)。
* - `detailHrefPrefix`:推荐方式,适用于 Server Component前缀 + id 拼接) * P1-2: 用户端canManage=false时调用 `getReadStatus` 批量获取已读状态,
* - `detailHrefBuilder`:仅适用于 Client Component 之间的调用 * 传入卡片做已读/未读视觉区分。
* P2-6: 新增分页支持。传入 `pagination` prop 时在列表底部渲染 `AnnouncementPagination`
* 通过 `buildPageHref` 回调生成页码 URL与 `?status=` 过滤查询参数合并)。
*/ */
export function AnnouncementList({ export function AnnouncementList({
announcements, announcements,
canManage, canManage,
createHref, createHref,
detailHrefBuilder,
detailHrefPrefix, detailHrefPrefix,
initialStatus, initialStatus,
pagination,
}: { }: {
announcements: Announcement[] announcements: Announcement[]
canManage?: boolean canManage?: boolean
createHref?: string createHref?: string
detailHrefBuilder?: (id: string) => string
detailHrefPrefix?: string detailHrefPrefix?: string
initialStatus?: Filter initialStatus?: Filter
pagination?: {
page: number
pageSize: number
total: number
buildPageHref: (page: number) => string
}
}) { }) {
const t = useTranslations("announcements") const t = useTranslations("announcements")
const router = useRouter() const router = useRouter()
const service = useAnnouncementsService()
const filter: Filter = initialStatus ?? "all" const filter: Filter = initialStatus ?? "all"
// P1-2: 用户端批量获取已读状态
const [readStatus, setReadStatus] = useState<Record<string, boolean>>({})
useEffect(() => {
if (canManage) return
if (announcements.length === 0) return
let cancelled = false
void service
.getReadStatus(announcements.map((a) => a.id))
.then((res) => {
if (cancelled) return
if (res.success && res.data) {
setReadStatus(res.data)
}
})
.catch(() => {
// 静默处理,已读状态不影响主流程
})
return () => {
cancelled = true
}
}, [announcements, canManage, service])
const filterOptions: { value: Filter; label: string }[] = [ const filterOptions: { value: Filter; label: string }[] = [
{ value: "all", label: t("filter.all") }, { value: "all", label: t("filter.all") },
{ value: "published", label: t("filter.published") }, { value: "published", label: t("filter.published") },
@@ -65,14 +98,6 @@ export function AnnouncementList({
router.replace(qs ? `?${qs}` : "?") router.replace(qs ? `?${qs}` : "?")
} }
// 构建详情链接:优先使用 detailHrefPrefixServer Component 安全),
// 其次使用 detailHrefBuilder仅 Client Component 间调用)
const buildDetailHref = (id: string): string | undefined => {
if (detailHrefPrefix) return `${detailHrefPrefix}/${id}`
if (detailHrefBuilder) return detailHrefBuilder(id)
return undefined
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -110,16 +135,27 @@ export function AnnouncementList({
className="h-auto border-none shadow-none" className="h-auto border-none shadow-none"
/> />
) : ( ) : (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{announcements.map((a) => ( {announcements.map((a) => (
<AnnouncementCard <AnnouncementCard
key={a.id} key={a.id}
announcement={a} announcement={a}
href={buildDetailHref(a.id)} href={detailHrefPrefix ? `${detailHrefPrefix}/${a.id}` : undefined}
canManage={canManage} canManage={canManage}
isRead={canManage ? undefined : readStatus[a.id]}
/> />
))} ))}
</div> </div>
{pagination ? (
<AnnouncementPagination
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
buildHref={pagination.buildPageHref}
/>
) : null}
</div>
)} )}
</div> </div>
) )

View File

@@ -0,0 +1,116 @@
"use client"
import { useMemo } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
/**
* P2-6: 公告列表分页组件。
*
* 通过 URL `?page=N` 持久化当前页码RSC 重新渲染模式,与 AnnouncementList 的
* `?status=` 过滤机制一致),不引入客户端路由状态。
*
* 设计要点:
* - 服务端渲染友好:仅渲染 a 链接,无 onClick 路由副作用
* - a11y上一页/下一页按钮使用 aria-label当前页用 aria-current="page"
* - 边界处理total=0 时不渲染分页;首页时上一页禁用;末页时下一页禁用
* - 计算窗口:当总页数 > 7 时显示首末页 + 当前页 ±1 + 省略号,避免溢出
*/
export function AnnouncementPagination({
page,
pageSize,
total,
buildHref,
className,
}: {
page: number
pageSize: number
total: number
buildHref: (page: number) => string
className?: string
}) {
const t = useTranslations("announcements")
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(Math.max(1, page), totalPages)
const pages = useMemo(() => {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1)
}
// 窗口策略:首末页 + 当前页 ±1 + 省略号占位
const result: (number | "...")[] = [1]
const start = Math.max(2, currentPage - 1)
const end = Math.min(totalPages - 1, currentPage + 1)
if (start > 2) result.push("...")
for (let i = start; i <= end; i++) result.push(i)
if (end < totalPages - 1) result.push("...")
result.push(totalPages)
return result
}, [currentPage, totalPages])
if (total === 0) return null
const isFirst = currentPage === 1
const isLast = currentPage === totalPages
return (
<nav
aria-label={t("pagination.label")}
className={cn("flex items-center justify-center gap-1", className)}
>
<Button
variant="outline"
size="icon"
asChild
aria-disabled={isFirst}
aria-label={t("pagination.prev")}
className={cn(isFirst && "pointer-events-none opacity-50")}
>
<Link href={buildHref(Math.max(1, currentPage - 1))} aria-disabled={isFirst}>
<ChevronLeft className="h-4 w-4" />
</Link>
</Button>
{pages.map((p, idx) =>
p === "..." ? (
<span
key={`ellipsis-${idx}`}
className="px-2 text-muted-foreground"
aria-hidden="true"
>
</span>
) : (
<Button
key={p}
variant={p === currentPage ? "default" : "outline"}
size="icon"
asChild
aria-current={p === currentPage ? "page" : undefined}
aria-label={t("pagination.page", { page: p })}
className={cn("min-w-[2.5rem]", p === currentPage && "pointer-events-none")}
>
<Link href={buildHref(p)}>{p}</Link>
</Button>
)
)}
<Button
variant="outline"
size="icon"
asChild
aria-disabled={isLast}
aria-label={t("pagination.next")}
className={cn(isLast && "pointer-events-none opacity-50")}
>
<Link href={buildHref(Math.min(totalPages, currentPage + 1))} aria-disabled={isLast}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
</nav>
)
}

View File

@@ -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<AnnouncementsService | null>(null)
interface AnnouncementsServiceProviderProps {
/** 注入的服务实现;不传则使用默认实现(调用真实 Server Actions */
service?: AnnouncementsService
children: ReactNode
}
export function AnnouncementsServiceProvider({
service,
children,
}: AnnouncementsServiceProviderProps): ReactNode {
const value = service ?? defaultAnnouncementsService
return (
<AnnouncementsServiceContext.Provider value={value}>
{children}
</AnnouncementsServiceContext.Provider>
)
}
export function useAnnouncementsService(): AnnouncementsService {
const ctx = useContext(AnnouncementsServiceContext)
return ctx ?? defaultAnnouncementsService
}

View File

@@ -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),
}

View File

@@ -2,40 +2,32 @@ import "server-only"
import { cache } from "react" import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" 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 { db } from "@/shared/db"
import { announcements, announcementReads, users } from "@/shared/db/schema" import { announcements, announcementReads, users } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import type { import type {
Announcement, Announcement,
AnnouncementInsertData, AnnouncementInsertData,
AnnouncementStatus, AnnouncementStatus,
AnnouncementUpdateData, AnnouncementUpdateData,
GetAnnouncementsParams, GetAnnouncementsParams,
PaginatedAnnouncements,
UserAudience,
} from "./types" } from "./types"
/** P2-2: 用 Drizzle 推导类型替代手写内联类型,避免 schema 变更时类型漂移 */
type AnnouncementRow = typeof announcements.$inferSelect & {
authorName: string | null
}
const toIso = (d: Date | null | undefined): string | null => const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString() const toIsoRequired = (d: Date): string => d.toISOString()
const mapRow = ( const mapRow = (row: AnnouncementRow): Announcement => ({
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 => ({
id: row.id, id: row.id,
title: row.title, title: row.title,
content: row.content, content: row.content,
@@ -66,15 +58,19 @@ export const getAnnouncements = cache(
} }
// 受众过滤:当提供 audience 时,仅返回对该受众可见的公告 // 受众过滤:当提供 audience 时,仅返回对该受众可见的公告
// (type = 'school') OR (type = 'grade' AND target_grade_id = audience.gradeId) // (type = 'school') OR (type = 'grade' AND target_grade_id audience.gradeIds)
// OR (type = 'class' AND target_class_id = audience.classId) // OR (type = 'class' AND target_class_id audience.classIds)
//
// P0-2 修复audience 升级为数组,支持家长多孩子 / 教师多班级 / 年级主任多年级
if (params?.audience) { if (params?.audience) {
const { gradeId, classId } = params.audience const { gradeIds, classIds } = params.audience
const gradeClause = gradeId const gradeClause =
? and(eq(announcements.type, "grade"), eq(announcements.targetGradeId, gradeId)) gradeIds.length > 0
? and(eq(announcements.type, "grade"), inArray(announcements.targetGradeId, gradeIds))
: undefined : undefined
const classClause = classId const classClause =
? and(eq(announcements.type, "class"), eq(announcements.targetClassId, classId)) classIds.length > 0
? and(eq(announcements.type, "class"), inArray(announcements.targetClassId, classIds))
: undefined : undefined
const orClauses = [ const orClauses = [
eq(announcements.type, "school"), eq(announcements.type, "school"),
@@ -111,6 +107,49 @@ export const getAnnouncements = cache(
} }
) )
/**
* P2-6: 统计符合条件的公告总数(用于分页)。
*
* 与 `getAnnouncements` 使用相同的过滤条件,但不应用 limit/offset。
* 仅用户端 `getUserAnnouncementsPageData` 调用;管理端列表暂不分页。
*/
export async function countAnnouncements(
params?: Omit<GetAnnouncementsParams, "page" | "pageSize">
): Promise<number> {
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<typeof c> => 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( export const getAnnouncementById = cache(
async (id: string): Promise<Announcement | null> => { async (id: string): Promise<Announcement | null> => {
const [row] = await db const [row] = await db
@@ -203,22 +242,14 @@ export async function archiveAnnouncementById(id: string): Promise<void> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// V2-P2-13d: 公告置顶 // V2-P2-13d: 公告置顶P1-6: 原子化,避免 SELECT-then-UPDATE 的并发 lost update
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function toggleAnnouncementPin(id: string): Promise<void> { export async function toggleAnnouncementPin(id: string): Promise<void> {
// 查询当前置顶状态 // 单次原子 UPDATEis_pinned = NOT is_pinned
const [row] = await db
.select({ isPinned: announcements.isPinned })
.from(announcements)
.where(eq(announcements.id, id))
.limit(1)
if (!row) return
await db await db
.update(announcements) .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)) .where(eq(announcements.id, id))
} }
@@ -227,25 +258,22 @@ export async function toggleAnnouncementPin(id: string): Promise<void> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* 标记公告为已读(如果尚未标记)。 * 标记公告为已读(幂等)。
* 使用 INSERT IGNORE 语义避免重复插入(依赖唯一索引)。 * P1-6: 改用 INSERT ... ON DUPLICATE KEY UPDATE 单次原子操作,
* 替代原先的 SELECT-then-INSERT减少一次 DB 往返且天然防并发。
*/ */
export async function markAnnouncementAsRead(announcementId: string, userId: string): Promise<void> { export async function markAnnouncementAsRead(announcementId: string, userId: string): Promise<void> {
// 先检查是否已存在已读记录
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() const id = createId()
await db.insert(announcementReads).values({ await db
.insert(announcementReads)
.values({
id, id,
announcementId, announcementId,
userId, 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 })), 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<UserAudience | null> {
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<string>()
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<string>()
const gradeIdSet = new Set<string>()
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<Announcement | null> {
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<PaginatedAnnouncements> {
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<string[]> {
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 []
}

View File

@@ -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)
})
})
})

View File

@@ -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)
})
})

View File

@@ -1,3 +1,5 @@
import type { ActionState } from "@/shared/types/action-state"
export type AnnouncementStatus = "draft" | "published" | "archived" export type AnnouncementStatus = "draft" | "published" | "archived"
export type AnnouncementType = "school" | "grade" | "class" export type AnnouncementType = "school" | "grade" | "class"
@@ -32,16 +34,29 @@ export type GetAnnouncementsParams = {
/** /**
* 受众过滤(用户端使用):当提供时,仅返回对该受众可见的公告。 * 受众过滤(用户端使用):当提供时,仅返回对该受众可见的公告。
* - school 类型公告:对所有受众可见 * - school 类型公告:对所有受众可见
* - grade 类型公告:仅当 targetGradeId audience.gradeId 匹配时可见 * - grade 类型公告:仅当 targetGradeId audience.gradeIds 时可见
* - class 类型公告:仅当 targetClassId audience.classId 匹配时可见 * - class 类型公告:仅当 targetClassId audience.classIds 时可见
*
* P0-2 修复:从单值 `{ gradeId?, classId? }` 升级为数组,
* 解决家长多孩子 / 教师多班级 / 年级主任多年级时只能看到首个受众的截断 Bug。
* 未提供时(管理端)返回所有公告。 * 未提供时(管理端)返回所有公告。
*/ */
audience?: { audience?: {
gradeId?: string gradeIds: string[]
classId?: string classIds: string[]
} }
} }
/**
* 用户受众信息P0-2 新增)。
* 由 data-access 的 `resolveUserAudience` 解析得到,
* 用于公告可见性判断与列表过滤。
*/
export interface UserAudience {
gradeIds: string[]
classIds: string[]
}
export interface AnnouncementInsertData { export interface AnnouncementInsertData {
id: string id: string
title: string title: string
@@ -72,3 +87,38 @@ export interface AnnouncementRead {
userId: string userId: string
readAt: 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<string> | null, formData: FormData): Promise<ActionState<string>>
update(id: string, prevState: ActionState<string> | null, formData: FormData): Promise<ActionState<string>>
delete(id: string): Promise<ActionState<string>>
publish(id: string): Promise<ActionState<string>>
archive(id: string): Promise<ActionState<string>>
togglePin(id: string): Promise<ActionState<string>>
markRead(announcementId: string): Promise<ActionState<string>>
getReadStatus(announcementIds: string[]): Promise<ActionState<Record<string, boolean>>>
}