feat(notifications): add notification system and wire order/dispute events

This commit is contained in:
zetaloop
2026-02-23 11:05:04 +08:00
parent 2222dccbb7
commit c986539954
7 changed files with 214 additions and 17 deletions
+15 -7
View File
@@ -56,7 +56,7 @@ export default function NotificationsPage() {
const systemNotifs = notifications.filter((notification) => notification.type === "system") const systemNotifs = notifications.filter((notification) => notification.type === "system")
return ( return (
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
@@ -76,13 +76,21 @@ export default function NotificationsPage() {
<TabsTrigger value="system"></TabsTrigger> <TabsTrigger value="system"></TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="all" className="space-y-2 mt-4"> <TabsContent value="all" className="space-y-3 mt-4">
{notifications.map((notification) => ( {notifications.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
</CardContent>
</Card>
) : (
notifications.map((notification) => (
<NotificationItem key={notification.id} notification={notification} /> <NotificationItem key={notification.id} notification={notification} />
))} ))
)}
</TabsContent> </TabsContent>
<TabsContent value="order" className="space-y-2 mt-4"> <TabsContent value="order" className="space-y-3 mt-4">
{orderNotifs.length === 0 ? ( {orderNotifs.length === 0 ? (
<Card> <Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
@@ -94,7 +102,7 @@ export default function NotificationsPage() {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="community" className="space-y-2 mt-4"> <TabsContent value="community" className="space-y-3 mt-4">
{communityNotifs.length === 0 ? ( {communityNotifs.length === 0 ? (
<Card> <Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
@@ -106,7 +114,7 @@ export default function NotificationsPage() {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="system" className="space-y-2 mt-4"> <TabsContent value="system" className="space-y-3 mt-4">
{systemNotifs.length === 0 ? ( {systemNotifs.length === 0 ? (
<Card> <Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground"> <CardContent className="py-8 text-center text-sm text-muted-foreground">
+22 -5
View File
@@ -17,7 +17,15 @@ import type { UserRole } from "@/lib/types"
import { useAuthStore } from "@/store/auth" import { useAuthStore } from "@/store/auth"
export default function SettingsPage() { export default function SettingsPage() {
const { currentRole, verifiedRoles, switchRole, user, updateProfile } = useAuthStore() const {
currentRole,
verifiedRoles,
switchRole,
user,
updateProfile,
notificationPrefs,
setNotificationPref,
} = useAuthStore()
const [nickname, setNickname] = useState(user?.nickname ?? "") const [nickname, setNickname] = useState(user?.nickname ?? "")
const [bio, setBio] = useState(user?.bio ?? "") const [bio, setBio] = useState(user?.bio ?? "")
const [avatar, setAvatar] = useState(user?.avatar ?? "") const [avatar, setAvatar] = useState(user?.avatar ?? "")
@@ -26,7 +34,7 @@ export default function SettingsPage() {
const isRoleVerified = (role: UserRole) => verifiedRoles.includes(role) const isRoleVerified = (role: UserRole) => verifiedRoles.includes(role)
return ( return (
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<Card> <Card>
@@ -176,7 +184,10 @@ export default function SettingsPage() {
<p className="text-sm font-medium"></p> <p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
<Switch defaultChecked /> <Switch
checked={notificationPrefs.order}
onCheckedChange={(checked) => setNotificationPref("order", checked)}
/>
</div> </div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -184,7 +195,10 @@ export default function SettingsPage() {
<p className="text-sm font-medium"></p> <p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
<Switch defaultChecked /> <Switch
checked={notificationPrefs.community}
onCheckedChange={(checked) => setNotificationPref("community", checked)}
/>
</div> </div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -192,7 +206,10 @@ export default function SettingsPage() {
<p className="text-sm font-medium"></p> <p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground"></p> <p className="text-xs text-muted-foreground"></p>
</div> </div>
<Switch /> <Switch
checked={notificationPrefs.system}
onCheckedChange={(checked) => setNotificationPref("system", checked)}
/>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+28
View File
@@ -1,5 +1,33 @@
import { allow, deny } from "@/lib/policy/assert"
import type { Notification } from "@/lib/types"
import { useAuthStore } from "@/store/auth"
import { useNotificationStore } from "@/store/notifications" import { useNotificationStore } from "@/store/notifications"
export function listNotifications() { export function listNotifications() {
return useNotificationStore.getState().notifications return useNotificationStore.getState().notifications
} }
function isNotificationEnabled(type: Notification["type"]) {
const prefs = useAuthStore.getState().notificationPrefs
if (type === "order") return prefs.order
if (type === "community") return prefs.community
return prefs.system
}
export function addNotification(input: {
type: Notification["type"]
title: string
content: string
link?: string
}) {
if (!isNotificationEnabled(input.type)) {
return deny("IDEMPOTENT_NOOP", "该类通知已关闭")
}
useNotificationStore.getState().addNotification(input)
return allow()
}
export function markNotificationAsRead(notificationId: string) {
useNotificationStore.getState().markAsRead(notificationId)
}
+26 -2
View File
@@ -1,17 +1,31 @@
import { create } from "zustand" import { create } from "zustand"
import type { User, UserRole, VerificationStatus } from "@/lib/types" import type { User, UserRole, VerificationStatus } from "@/lib/types"
interface NotificationPrefs {
order: boolean
community: boolean
system: boolean
}
const defaultNotificationPrefs: NotificationPrefs = {
order: true,
community: true,
system: false,
}
interface AuthState { interface AuthState {
isAuthenticated: boolean isAuthenticated: boolean
currentRole: UserRole currentRole: UserRole
verifiedRoles: UserRole[] verifiedRoles: UserRole[]
verificationStatus: Partial<Record<UserRole, VerificationStatus>> verificationStatus: Partial<Record<UserRole, VerificationStatus>>
verificationReasons: Partial<Record<UserRole, string>> verificationReasons: Partial<Record<UserRole, string>>
notificationPrefs: NotificationPrefs
user: User | null user: User | null
switchRole: (role: UserRole) => void switchRole: (role: UserRole) => void
submitVerification: (role: UserRole) => void submitVerification: (role: UserRole) => void
approveVerification: (role: UserRole) => void approveVerification: (role: UserRole) => void
rejectVerification: (role: UserRole, reason: string) => void rejectVerification: (role: UserRole, reason: string) => void
setNotificationPref: (type: keyof NotificationPrefs, enabled: boolean) => void
updateProfile: (patch: { nickname?: string; bio?: string; avatar?: string }) => void updateProfile: (patch: { nickname?: string; bio?: string; avatar?: string }) => void
login: (user: User, verifiedRoles?: UserRole[]) => void login: (user: User, verifiedRoles?: UserRole[]) => void
logout: () => void logout: () => void
@@ -23,6 +37,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
verifiedRoles: ["consumer"], verifiedRoles: ["consumer"],
verificationStatus: { consumer: "approved" }, verificationStatus: { consumer: "approved" },
verificationReasons: {}, verificationReasons: {},
notificationPrefs: defaultNotificationPrefs,
user: null, user: null,
switchRole: (role) => { switchRole: (role) => {
const { verifiedRoles } = get() const { verifiedRoles } = get()
@@ -84,6 +99,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}, },
} }
}), }),
setNotificationPref: (type, enabled) =>
set((state) => ({
notificationPrefs: {
...state.notificationPrefs,
[type]: enabled,
},
})),
updateProfile: (patch) => updateProfile: (patch) =>
set((state) => { set((state) => {
if (!state.user) return state if (!state.user) return state
@@ -98,7 +120,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
}), }),
login: (user, verifiedRoles = ["consumer"]) => login: (user, verifiedRoles = ["consumer"]) =>
set({ set((state) => ({
isAuthenticated: true, isAuthenticated: true,
user, user,
currentRole: user.role, currentRole: user.role,
@@ -111,7 +133,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
{}, {},
), ),
verificationReasons: {}, verificationReasons: {},
}), notificationPrefs: state.notificationPrefs,
})),
logout: () => logout: () =>
set({ set({
isAuthenticated: false, isAuthenticated: false,
@@ -119,6 +142,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
verifiedRoles: ["consumer"], verifiedRoles: ["consumer"],
verificationStatus: { consumer: "approved" }, verificationStatus: { consumer: "approved" },
verificationReasons: {}, verificationReasons: {},
notificationPrefs: defaultNotificationPrefs,
user: null, user: null,
}), }),
})) }))
+27
View File
@@ -6,6 +6,8 @@ import type { Actor } from "@/lib/policy/actor"
import type { PolicyDecision } from "@/lib/policy/decision" import type { PolicyDecision } from "@/lib/policy/decision"
import { mockDisputes } from "@/lib/mock" import { mockDisputes } from "@/lib/mock"
import type { Dispute } from "@/lib/types" import type { Dispute } from "@/lib/types"
import { useAuthStore } from "@/store/auth"
import { useNotificationStore } from "@/store/notifications"
import { useOrderStore } from "@/store/orders" import { useOrderStore } from "@/store/orders"
type DisputeTimelineType = "created" | "response" | "reviewing" | "resolved" | "appealed" type DisputeTimelineType = "created" | "response" | "reviewing" | "resolved" | "appealed"
@@ -120,6 +122,19 @@ function asRecord(dispute: Dispute): DisputeRecord {
} }
} }
function notifyDispute(orderId: string, title: string, content: string) {
if (!useAuthStore.getState().notificationPrefs.order) {
return
}
useNotificationStore.getState().addNotification({
type: "order",
title,
content,
link: `/dispute/${orderId}`,
})
}
export const useDisputeStore = create<DisputeState>((set, get) => { export const useDisputeStore = create<DisputeState>((set, get) => {
const scheduleProgress = (disputeId: string) => { const scheduleProgress = (disputeId: string) => {
clearProgressTimers(disputeId) clearProgressTimers(disputeId)
@@ -148,11 +163,14 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
}, DISPUTE_TO_REVIEWING_MS) }, DISPUTE_TO_REVIEWING_MS)
const toResolved = setTimeout(() => { const toResolved = setTimeout(() => {
let resolvedOrderId: string | null = null
set((state) => ({ set((state) => ({
disputes: state.disputes.map((dispute) => { disputes: state.disputes.map((dispute) => {
if (dispute.id !== disputeId) return dispute if (dispute.id !== disputeId) return dispute
if (dispute.status !== "reviewing" && dispute.status !== "appealed") return dispute if (dispute.status !== "reviewing" && dispute.status !== "appealed") return dispute
resolvedOrderId = dispute.orderId
return { return {
...dispute, ...dispute,
status: "resolved", status: "resolved",
@@ -169,6 +187,11 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
} }
}), }),
})) }))
if (resolvedOrderId) {
notifyDispute(resolvedOrderId, "争议已处理", "平台已给出争议处理结果")
}
clearProgressTimers(disputeId) clearProgressTimers(disputeId)
}, DISPUTE_TO_RESOLVED_MS) }, DISPUTE_TO_RESOLVED_MS)
@@ -275,6 +298,8 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
}), }),
})) }))
notifyDispute(dispute.orderId, "争议收到回应", "对方已提交争议回应材料")
return allow() return allow()
}, },
submitAppeal: (disputeId, actorId, reason) => { submitAppeal: (disputeId, actorId, reason) => {
@@ -322,6 +347,8 @@ export const useDisputeStore = create<DisputeState>((set, get) => {
}), }),
})) }))
notifyDispute(dispute.orderId, "争议已申诉", "申诉已提交,平台将继续复核")
scheduleProgress(disputeId) scheduleProgress(disputeId)
return allow() return allow()
}, },
+33
View File
@@ -1,14 +1,47 @@
import { create } from "zustand" import { create } from "zustand"
import { generateId } from "@/lib/id"
import { mockNotifications } from "@/lib/mock" import { mockNotifications } from "@/lib/mock"
import type { Notification } from "@/lib/types" import type { Notification } from "@/lib/types"
interface CreateNotificationInput {
type: Notification["type"]
title: string
content: string
link?: string
}
interface NotificationState { interface NotificationState {
notifications: Notification[] notifications: Notification[]
addNotification: (input: CreateNotificationInput) => Notification
markAsRead: (notificationId: string) => void
markAllAsRead: () => void markAllAsRead: () => void
} }
export const useNotificationStore = create<NotificationState>((set) => ({ export const useNotificationStore = create<NotificationState>((set) => ({
notifications: mockNotifications, notifications: mockNotifications,
addNotification: (input) => {
const notification: Notification = {
id: generateId("notif"),
type: input.type,
title: input.title,
content: input.content,
link: input.link,
read: false,
createdAt: new Date().toISOString(),
}
set((state) => ({
notifications: [notification, ...state.notifications],
}))
return notification
},
markAsRead: (notificationId) =>
set((state) => ({
notifications: state.notifications.map((notification) =>
notification.id === notificationId ? { ...notification, read: true } : notification,
),
})),
markAllAsRead: () => markAllAsRead: () =>
set((state) => ({ set((state) => ({
notifications: state.notifications.map((notification) => ({ ...notification, read: true })), notifications: state.notifications.map((notification) => ({ ...notification, read: true })),
+62 -2
View File
@@ -7,7 +7,9 @@ import type { Actor } from "@/lib/policy/actor"
import type { PolicyDecision } from "@/lib/policy/decision" import type { PolicyDecision } from "@/lib/policy/decision"
import { mockOrders } from "@/lib/mock" import { mockOrders } from "@/lib/mock"
import type { Order, OrderStatus, PlayerService } from "@/lib/types" import type { Order, OrderStatus, PlayerService } from "@/lib/types"
import { useAuthStore } from "@/store/auth"
import { useChatStore } from "@/store/chat" import { useChatStore } from "@/store/chat"
import { useNotificationStore } from "@/store/notifications"
import { useWalletStore } from "@/store/wallet" import { useWalletStore } from "@/store/wallet"
interface CreateOrderInput { interface CreateOrderInput {
@@ -191,6 +193,55 @@ function scheduleOrderTimeout(orderId: string, status: OrderStatus) {
orderTimeouts.set(orderId, timer) orderTimeouts.set(orderId, timer)
} }
function notifyOrderStatus(order: Order) {
if (!useAuthStore.getState().notificationPrefs.order) {
return
}
const mapping: Partial<Record<OrderStatus, { title: string; content: string }>> = {
pending_accept: {
title: "订单待接单",
content: `${order.service.title} 已支付,等待接单`,
},
in_progress: {
title: "订单已接单",
content: `${order.playerName} 已开始服务`,
},
pending_close: {
title: "订单发起结单",
content: `订单 ${order.id} 等待确认结单`,
},
pending_review: {
title: "订单待评价",
content: "服务已结束,可提交双向评价",
},
completed: {
title: "订单已完成",
content: `订单 ${order.id} 已完成`,
},
cancelled: {
title: "订单已取消",
content: `订单 ${order.id} 已取消`,
},
disputed: {
title: "订单进入争议",
content: "已发起争议,等待平台处理",
},
}
const payload = mapping[order.status]
if (!payload) {
return
}
useNotificationStore.getState().addNotification({
type: "order",
title: payload.title,
content: payload.content,
link: `/order/${order.id}`,
})
}
export const useOrderStore = create<OrderState>((set, get) => { export const useOrderStore = create<OrderState>((set, get) => {
const applyTransition = ( const applyTransition = (
orderId: string, orderId: string,
@@ -226,7 +277,13 @@ export const useOrderStore = create<OrderState>((set, get) => {
} }
if (previousOrder.status !== "completed" && updatedOrder.status === "completed") { if (previousOrder.status !== "completed" && updatedOrder.status === "completed") {
useWalletStore.getState().addIncome(updatedOrder.id, updatedOrder.totalPrice) useWalletStore
.getState()
.addIncome(updatedOrder.id, updatedOrder.totalPrice, updatedOrder.shopId)
}
if (previousOrder.status !== updatedOrder.status) {
notifyOrderStatus(updatedOrder)
} }
const shouldRefund = const shouldRefund =
@@ -299,6 +356,7 @@ export const useOrderStore = create<OrderState>((set, get) => {
})) }))
useChatStore.getState().ensureOrderSession(order) useChatStore.getState().ensureOrderSession(order)
notifyOrderStatus(order)
return { decision: allow(), order } return { decision: allow(), order }
}, },
payOrder: (orderId, actor) => applyTransition(orderId, "PAY", actor), payOrder: (orderId, actor) => applyTransition(orderId, "PAY", actor),
@@ -323,7 +381,9 @@ export const useOrderStore = create<OrderState>((set, get) => {
if (previousOrder && updatedOrder) { if (previousOrder && updatedOrder) {
if (previousOrder.status !== "completed" && updatedOrder.status === "completed") { if (previousOrder.status !== "completed" && updatedOrder.status === "completed") {
useWalletStore.getState().addIncome(updatedOrder.id, updatedOrder.totalPrice) useWalletStore
.getState()
.addIncome(updatedOrder.id, updatedOrder.totalPrice, updatedOrder.shopId)
} }
if (previousOrder.status === "pending_accept" && updatedOrder.status === "cancelled") { if (previousOrder.status === "pending_accept" && updatedOrder.status === "cancelled") {