mirror of
https://github.com/m1ngsama/FUJI.git
synced 2025-12-24 10:51:27 +00:00
feat: add notification preferences UI to repair admin page
- Add notification preferences API endpoints to OpenAPI types - Create NotificationPreferences component with toggle switches - Integrate notification preferences into repair admin page - Support GET and PUT operations for notification preferences - Add optimistic UI updates with error handling - Include mobile-responsive design
This commit is contained in:
parent
b29b59b4eb
commit
3c3ebe66e9
3 changed files with 302 additions and 0 deletions
157
src/pages/repair/NotificationPreferences.tsx
Normal file
157
src/pages/repair/NotificationPreferences.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Card, CardBody, CardHeader, Switch, Spinner, Button } from "@heroui/react"
|
||||||
|
import { saturdayClient } from "../../utils/client"
|
||||||
|
import type { components } from "../../types/saturday"
|
||||||
|
|
||||||
|
type NotificationPreferenceItem = components["schemas"]["NotificationPreferenceItem"]
|
||||||
|
type UpdateNotificationPreferencesInputBody = components["schemas"]["UpdateNotificationPreferencesInputBody"]
|
||||||
|
|
||||||
|
interface NotificationPreferencesProps {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationPreferences({ token }: NotificationPreferencesProps) {
|
||||||
|
const [preferences, setPreferences] = useState<NotificationPreferenceItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string>("")
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string>("")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPreferences()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const loadPreferences = async () => {
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setErrorMessage("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await saturdayClient.GET("/notification-preferences", {
|
||||||
|
params: {
|
||||||
|
header: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
setErrorMessage("加载通知偏好设置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreferences(data)
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMessage("加载通知偏好设置时出错")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (notificationType: string, newValue: boolean) => {
|
||||||
|
setIsSaving(true)
|
||||||
|
setErrorMessage("")
|
||||||
|
setSuccessMessage("")
|
||||||
|
|
||||||
|
// Optimistically update UI
|
||||||
|
const previousPreferences = [...preferences]
|
||||||
|
setPreferences(preferences.map(pref =>
|
||||||
|
pref.notificationType === notificationType
|
||||||
|
? { ...pref, enabled: newValue }
|
||||||
|
: pref
|
||||||
|
))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the request body with all preferences
|
||||||
|
const requestBody: UpdateNotificationPreferencesInputBody = preferences.reduce((acc, pref) => {
|
||||||
|
const enabled = pref.notificationType === notificationType ? newValue : pref.enabled
|
||||||
|
acc[pref.notificationType] = enabled
|
||||||
|
return acc
|
||||||
|
}, {} as UpdateNotificationPreferencesInputBody)
|
||||||
|
|
||||||
|
const { error } = await saturdayClient.PUT("/notification-preferences", {
|
||||||
|
params: {
|
||||||
|
header: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Revert on error
|
||||||
|
setPreferences(previousPreferences)
|
||||||
|
setErrorMessage("更新通知偏好设置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccessMessage("通知偏好设置已更新")
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
setTimeout(() => setSuccessMessage(""), 3000)
|
||||||
|
} catch (err) {
|
||||||
|
// Revert on error
|
||||||
|
setPreferences(previousPreferences)
|
||||||
|
setErrorMessage("更新通知偏好设置时出错")
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardBody className="flex justify-center items-center py-8">
|
||||||
|
<Spinner label="加载中..." />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="flex flex-col items-start gap-2 pb-4">
|
||||||
|
<h3 className="text-lg sm:text-xl font-bold">通知偏好设置</h3>
|
||||||
|
<p className="text-sm text-gray-500">管理您希望接收的通知类型</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="gap-4">
|
||||||
|
{preferences.map((pref) => (
|
||||||
|
<div
|
||||||
|
key={pref.notificationType}
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4 py-2 border-b border-gray-100 last:border-0"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm sm:text-base font-medium">{pref.description}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{pref.notificationType}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
isSelected={pref.enabled}
|
||||||
|
onValueChange={(value) => handleToggle(pref.notificationType, value)}
|
||||||
|
isDisabled={isSaving}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mt-2">
|
||||||
|
<p className="text-sm text-red-800">{errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mt-2">
|
||||||
|
<p className="text-sm text-green-800">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preferences.length === 0 && !errorMessage && (
|
||||||
|
<div className="text-center py-4 text-gray-500">
|
||||||
|
<p className="text-sm">暂无可用的通知偏好设置</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import type { PublicMember } from "../../store/member"
|
||||||
import type { UserInfoResponse } from "@logto/browser"
|
import type { UserInfoResponse } from "@logto/browser"
|
||||||
import { getAvailableEventActions, type EventAction, type IdentityContext } from "./EventAction"
|
import { getAvailableEventActions, type EventAction, type IdentityContext } from "./EventAction"
|
||||||
import { ExportExcelModal } from "./ExportEventDialog"
|
import { ExportExcelModal } from "./ExportEventDialog"
|
||||||
|
import NotificationPreferences from "./NotificationPreferences"
|
||||||
|
|
||||||
type PublicEvent = components["schemas"]["PublicEvent"]
|
type PublicEvent = components["schemas"]["PublicEvent"]
|
||||||
|
|
||||||
|
|
@ -531,6 +532,14 @@ export default function App() {
|
||||||
: <></>
|
: <></>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Notification Preferences Section */}
|
||||||
|
{token && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<NotificationPreferences token={token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="my-8 flex flex-col gap-4">
|
<div className="my-8 flex flex-col gap-4">
|
||||||
{/* Mobile Cards Layout */}
|
{/* Mobile Cards Layout */}
|
||||||
<div className="block sm:hidden">
|
<div className="block sm:hidden">
|
||||||
|
|
|
||||||
136
src/types/saturday.d.ts
vendored
136
src/types/saturday.d.ts
vendored
|
|
@ -403,6 +403,24 @@ export interface paths {
|
||||||
patch?: never
|
patch?: never
|
||||||
trace?: never
|
trace?: never
|
||||||
}
|
}
|
||||||
|
"/notification-preferences": {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: never
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
/** Get notification preferences */
|
||||||
|
get: operations["get-notification-preferences"]
|
||||||
|
/** Update notification preferences */
|
||||||
|
put: operations["update-notification-preferences"]
|
||||||
|
post?: never
|
||||||
|
delete?: never
|
||||||
|
options?: never
|
||||||
|
head?: never
|
||||||
|
patch?: never
|
||||||
|
trace?: never
|
||||||
|
}
|
||||||
"/ping": {
|
"/ping": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
|
@ -664,6 +682,23 @@ export interface components {
|
||||||
Int64: number
|
Int64: number
|
||||||
Valid: boolean
|
Valid: boolean
|
||||||
}
|
}
|
||||||
|
"NotificationPreferenceItem": {
|
||||||
|
/**
|
||||||
|
* @description Description of the notification type
|
||||||
|
* @example 通知所有新创建的维修工单
|
||||||
|
*/
|
||||||
|
description: string
|
||||||
|
/**
|
||||||
|
* @description Whether this notification type is enabled
|
||||||
|
* @example true
|
||||||
|
*/
|
||||||
|
enabled: boolean
|
||||||
|
/**
|
||||||
|
* @description Notification type identifier
|
||||||
|
* @example new_event_created
|
||||||
|
*/
|
||||||
|
notificationType: string
|
||||||
|
}
|
||||||
"PingResponse": {
|
"PingResponse": {
|
||||||
/**
|
/**
|
||||||
* Format: uri
|
* Format: uri
|
||||||
|
|
@ -732,6 +767,29 @@ export interface components {
|
||||||
qq?: string
|
qq?: string
|
||||||
size?: string
|
size?: string
|
||||||
}
|
}
|
||||||
|
"UpdateNotificationPreferencesInputBody": {
|
||||||
|
/**
|
||||||
|
* Format: uri
|
||||||
|
* @description A URL to the JSON Schema for this object.
|
||||||
|
* @example https://api.nbtca.space/schemas/UpdateNotificationPreferencesInputBody.json
|
||||||
|
*/
|
||||||
|
readonly $schema?: string
|
||||||
|
/**
|
||||||
|
* @description Enable/disable notifications for events assigned to me
|
||||||
|
* @example true
|
||||||
|
*/
|
||||||
|
event_assigned_to_me: boolean
|
||||||
|
/**
|
||||||
|
* @description Enable/disable notifications for event status changes
|
||||||
|
* @example true
|
||||||
|
*/
|
||||||
|
event_status_changed: boolean
|
||||||
|
/**
|
||||||
|
* @description Enable/disable notifications for new events created
|
||||||
|
* @example true
|
||||||
|
*/
|
||||||
|
new_event_created: boolean
|
||||||
|
}
|
||||||
"UpdateMemberAvatarInputBody": {
|
"UpdateMemberAvatarInputBody": {
|
||||||
/**
|
/**
|
||||||
* Format: uri
|
* Format: uri
|
||||||
|
|
@ -2108,6 +2166,84 @@ export interface operations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"get-notification-preferences": {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: {
|
||||||
|
/** @description Bearer token or JWT token */
|
||||||
|
Authorization?: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody?: never
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
"X-Limit"?: number | null
|
||||||
|
"X-Offset"?: number | null
|
||||||
|
"X-Page"?: number | null
|
||||||
|
"X-Total-Count"?: number | null
|
||||||
|
"X-Total-Pages"?: number | null
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["NotificationPreferenceItem"][] | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @description Error */
|
||||||
|
default: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ErrorModel"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"update-notification-preferences": {
|
||||||
|
parameters: {
|
||||||
|
query?: never
|
||||||
|
header?: {
|
||||||
|
/** @description Bearer token or JWT token */
|
||||||
|
Authorization?: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
cookie?: never
|
||||||
|
}
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateNotificationPreferencesInputBody"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
"X-Limit"?: number | null
|
||||||
|
"X-Offset"?: number | null
|
||||||
|
"X-Page"?: number | null
|
||||||
|
"X-Total-Count"?: number | null
|
||||||
|
"X-Total-Pages"?: number | null
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
"application/json": string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** @description Error */
|
||||||
|
default: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown
|
||||||
|
}
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ErrorModel"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
"ping": {
|
"ping": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never
|
query?: never
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue