From 5d0264db920c8a1e8518035e617419cd665b268c Mon Sep 17 00:00:00 2001 From: hailin Date: Tue, 23 Dec 2025 22:46:33 -0800 Subject: [PATCH] =?UTF-8?q?feat(admin-web):=20=E6=B7=BB=E5=8A=A0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建通知管理 API 服务 (notificationService.ts) - 添加通知列表页面,支持创建/编辑/删除/启用禁用 - 添加侧边栏"通知管理"菜单入口 - 支持按类型筛选通知 - 表单支持设置发布时间和过期时间 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../notifications/notifications.module.scss | 268 ++++++++++ .../app/(dashboard)/notifications/page.tsx | 457 ++++++++++++++++++ .../src/components/layout/Sidebar/Sidebar.tsx | 1 + .../src/infrastructure/api/endpoints.ts | 9 + .../src/services/notificationService.ts | 141 ++++++ 5 files changed, 876 insertions(+) create mode 100644 frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss create mode 100644 frontend/admin-web/src/app/(dashboard)/notifications/page.tsx create mode 100644 frontend/admin-web/src/services/notificationService.ts diff --git a/frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss b/frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss new file mode 100644 index 00000000..286038d1 --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/notifications/notifications.module.scss @@ -0,0 +1,268 @@ +@use '@/styles/variables' as *; + +.notifications { + display: flex; + flex-direction: column; + gap: 24px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__title { + font-size: 24px; + font-weight: 600; + color: $text-primary; + } + + &__actions { + display: flex; + gap: 12px; + } + + &__card { + background: $bg-card; + border-radius: 12px; + padding: 24px; + box-shadow: $shadow-card; + } + + &__filters { + display: flex; + gap: 12px; + margin-bottom: 20px; + } + + &__select { + padding: 8px 12px; + border: 1px solid $border-color; + border-radius: 8px; + font-size: 14px; + background: white; + min-width: 140px; + + &:focus { + outline: none; + border-color: $primary-color; + } + } + + &__list { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__loading, + &__empty, + &__error { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + padding: 60px 20px; + color: $text-secondary; + font-size: 14px; + } + + &__error { + color: $error-color; + } + + &__item { + background: white; + border: 1px solid $border-color; + border-radius: 10px; + padding: 16px 20px; + transition: all 0.2s; + + &:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + } + + &--disabled { + opacity: 0.6; + background: #fafafa; + } + } + + &__itemHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + &__itemMeta { + display: flex; + gap: 8px; + align-items: center; + } + + &__tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + + &--blue { + background: #e6f4ff; + color: #1677ff; + } + + &--green { + background: #f6ffed; + color: #52c41a; + } + + &--yellow { + background: #fffbe6; + color: #faad14; + } + + &--purple { + background: #f9f0ff; + color: #722ed1; + } + + &--orange { + background: #fff7e6; + color: #fa8c16; + } + + &--gray { + background: #f5f5f5; + color: #8c8c8c; + } + } + + &__priority, + &__target { + font-size: 12px; + color: $text-secondary; + padding: 2px 6px; + background: #f5f5f5; + border-radius: 4px; + } + + &__disabled { + font-size: 12px; + color: $error-color; + padding: 2px 6px; + background: #fff1f0; + border-radius: 4px; + } + + &__itemActions { + display: flex; + gap: 8px; + } + + &__actionBtn { + padding: 4px 12px; + font-size: 13px; + color: $text-secondary; + background: transparent; + border: 1px solid $border-color; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + color: $primary-color; + border-color: $primary-color; + } + + &--danger { + &:hover { + color: $error-color; + border-color: $error-color; + } + } + } + + &__itemTitle { + font-size: 16px; + font-weight: 600; + color: $text-primary; + margin-bottom: 8px; + } + + &__itemContent { + font-size: 14px; + color: $text-secondary; + line-height: 1.6; + margin-bottom: 12px; + white-space: pre-wrap; + } + + &__itemFooter { + display: flex; + gap: 20px; + font-size: 12px; + color: $text-tertiary; + } + + // 表单样式 + &__form { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__formRow { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + + &__formGroup { + display: flex; + flex-direction: column; + gap: 6px; + + label { + font-size: 14px; + font-weight: 500; + color: $text-primary; + } + + input, + select, + textarea { + padding: 10px 12px; + border: 1px solid $border-color; + border-radius: 8px; + font-size: 14px; + transition: border-color 0.2s; + + &:focus { + outline: none; + border-color: $primary-color; + } + + &::placeholder { + color: $text-tertiary; + } + } + + textarea { + resize: vertical; + min-height: 100px; + } + } + + &__formHint { + font-size: 12px; + color: $text-tertiary; + } + + &__modalFooter { + display: flex; + justify-content: flex-end; + gap: 12px; + } +} diff --git a/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx b/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx new file mode 100644 index 00000000..2176bc1b --- /dev/null +++ b/frontend/admin-web/src/app/(dashboard)/notifications/page.tsx @@ -0,0 +1,457 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { Modal, toast, Button } from '@/components/common'; +import { PageContainer } from '@/components/layout'; +import { cn } from '@/utils/helpers'; +import { formatDateTime } from '@/utils/formatters'; +import { + notificationService, + NotificationItem, + NotificationType, + NOTIFICATION_TYPE_OPTIONS, + NOTIFICATION_PRIORITY_OPTIONS, + TARGET_TYPE_OPTIONS, +} from '@/services/notificationService'; +import styles from './notifications.module.scss'; + +// 获取类型标签样式 +const getTypeStyle = (type: NotificationType) => { + const option = NOTIFICATION_TYPE_OPTIONS.find((o) => o.value === type); + return option?.color || 'gray'; +}; + +// 获取类型标签文本 +const getTypeLabel = (type: NotificationType) => { + const option = NOTIFICATION_TYPE_OPTIONS.find((o) => o.value === type); + return option?.label || type; +}; + +// 获取优先级标签 +const getPriorityLabel = (priority: string) => { + const option = NOTIFICATION_PRIORITY_OPTIONS.find((o) => o.value === priority); + return option?.label || priority; +}; + +// 获取目标用户标签 +const getTargetLabel = (target: string) => { + const option = TARGET_TYPE_OPTIONS.find((o) => o.value === target); + return option?.label || target; +}; + +/** + * 通知管理页面 + */ +export default function NotificationsPage() { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [typeFilter, setTypeFilter] = useState(''); + + // 弹窗状态 + const [showModal, setShowModal] = useState(false); + const [editingNotification, setEditingNotification] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState(null); + + // 表单状态 + const [formData, setFormData] = useState({ + title: '', + content: '', + type: 'SYSTEM' as NotificationType, + priority: 'NORMAL', + targetType: 'ALL', + imageUrl: '', + linkUrl: '', + publishedAt: '', + expiresAt: '', + }); + + // 加载通知列表 + const loadNotifications = useCallback(async () => { + try { + setLoading(true); + setError(null); + const data = await notificationService.getNotifications({ + type: typeFilter || undefined, + limit: 100, + }); + setNotifications(data); + } catch (err) { + setError((err as Error).message || '加载失败'); + } finally { + setLoading(false); + } + }, [typeFilter]); + + useEffect(() => { + loadNotifications(); + }, [loadNotifications]); + + // 打开创建弹窗 + const handleCreate = () => { + setEditingNotification(null); + setFormData({ + title: '', + content: '', + type: 'SYSTEM', + priority: 'NORMAL', + targetType: 'ALL', + imageUrl: '', + linkUrl: '', + publishedAt: '', + expiresAt: '', + }); + setShowModal(true); + }; + + // 打开编辑弹窗 + const handleEdit = (notification: NotificationItem) => { + setEditingNotification(notification); + setFormData({ + title: notification.title, + content: notification.content, + type: notification.type, + priority: notification.priority, + targetType: notification.targetType, + imageUrl: notification.imageUrl || '', + linkUrl: notification.linkUrl || '', + publishedAt: notification.publishedAt ? notification.publishedAt.slice(0, 16) : '', + expiresAt: notification.expiresAt ? notification.expiresAt.slice(0, 16) : '', + }); + setShowModal(true); + }; + + // 关闭弹窗 + const handleCloseModal = () => { + setShowModal(false); + setEditingNotification(null); + }; + + // 提交表单 + const handleSubmit = async () => { + if (!formData.title.trim()) { + toast.error('请输入通知标题'); + return; + } + if (!formData.content.trim()) { + toast.error('请输入通知内容'); + return; + } + + try { + const payload = { + title: formData.title.trim(), + content: formData.content.trim(), + type: formData.type, + priority: formData.priority, + targetType: formData.targetType, + imageUrl: formData.imageUrl.trim() || undefined, + linkUrl: formData.linkUrl.trim() || undefined, + publishedAt: formData.publishedAt ? new Date(formData.publishedAt).toISOString() : undefined, + expiresAt: formData.expiresAt ? new Date(formData.expiresAt).toISOString() : undefined, + }; + + if (editingNotification) { + await notificationService.updateNotification(editingNotification.id, payload); + toast.success('通知已更新'); + } else { + await notificationService.createNotification(payload); + toast.success('通知已创建'); + } + + handleCloseModal(); + loadNotifications(); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 切换启用状态 + const handleToggle = async (notification: NotificationItem) => { + try { + await notificationService.toggleNotification(notification.id, !notification.isEnabled); + toast.success(notification.isEnabled ? '已禁用' : '已启用'); + loadNotifications(); + } catch (err) { + toast.error((err as Error).message || '操作失败'); + } + }; + + // 删除通知 + const handleDelete = async (id: string) => { + try { + await notificationService.deleteNotification(id); + toast.success('通知已删除'); + setDeleteConfirm(null); + loadNotifications(); + } catch (err) { + toast.error((err as Error).message || '删除失败'); + } + }; + + return ( + +
+ {/* 页面标题和操作按钮 */} +
+

通知管理

+
+ +
+
+ + {/* 主内容卡片 */} +
+ {/* 筛选区域 */} +
+ + +
+ + {/* 通知列表 */} +
+ {loading ? ( +
加载中...
+ ) : error ? ( +
+ {error} + +
+ ) : notifications.length === 0 ? ( +
+ 暂无通知,点击"新建通知"创建第一条通知 +
+ ) : ( + notifications.map((notification) => ( +
+
+
+ + {getTypeLabel(notification.type)} + + + {getPriorityLabel(notification.priority)} + + + {getTargetLabel(notification.targetType)} + + {!notification.isEnabled && ( + 已禁用 + )} +
+
+ + + +
+
+

{notification.title}

+

{notification.content}

+
+ 创建时间: {formatDateTime(notification.createdAt)} + {notification.publishedAt && ( + 发布时间: {formatDateTime(notification.publishedAt)} + )} + {notification.expiresAt && ( + 过期时间: {formatDateTime(notification.expiresAt)} + )} +
+
+ )) + )} +
+
+ + {/* 创建/编辑弹窗 */} + + + +
+ } + width={640} + > +
+
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="请输入通知标题" + maxLength={100} + /> +
+ +
+ +