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:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
303
src/modules/announcements/components/announcement-card.test.tsx
Normal file
303
src/modules/announcements/components/announcement-card.test.tsx
Normal 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/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 }) =>
|
||||||
|
<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("渲染类型 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 })
|
||||||
|
// 没有 <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("a11y(P2-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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}` : "?")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建详情链接:优先使用 detailHrefPrefix(Server 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>
|
||||||
)
|
)
|
||||||
|
|||||||
116
src/modules/announcements/components/announcement-pagination.tsx
Normal file
116
src/modules/announcements/components/announcement-pagination.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
// 查询当前置顶状态
|
// 单次原子 UPDATE:is_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 []
|
||||||
|
}
|
||||||
|
|||||||
153
src/modules/announcements/is-announcement-visible.test.ts
Normal file
153
src/modules/announcements/is-announcement-visible.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
210
src/modules/announcements/schema.test.ts
Normal file
210
src/modules/announcements/schema.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>>>
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user