diff --git a/apps/web/src/app/(dashboard)/admin/users/page.tsx b/apps/web/src/app/(dashboard)/admin/users/page.tsx index c356780..2dc3409 100644 --- a/apps/web/src/app/(dashboard)/admin/users/page.tsx +++ b/apps/web/src/app/(dashboard)/admin/users/page.tsx @@ -1,202 +1,315 @@ 'use client'; -import { useState, useEffect } from 'react'; -import api from '@/lib/api'; - -interface User { - id: string; - email: string | null; - phone: string | null; - username: string; - firstName: string | null; - lastName: string | null; - avatar: string | null; - isActive: boolean; - emailVerified: boolean; - phoneVerified: boolean; - lastLoginAt: Date | null; - createdAt: Date; -} - -interface Pagination { - page: number; - limit: number; - total: number; - totalPages: number; -} +import { useState } from 'react'; +import { useUsers, useUpdateUser, useDeleteUser } from '@/hooks/use-users'; +import { User, UserListResponse } from '@/lib/user-api'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; export default function UsersManagementPage() { - const [users, setUsers] = useState([]); - const [pagination, setPagination] = useState({ - page: 1, - limit: 20, - total: 0, - totalPages: 0, - }); + const [page, setPage] = useState(1); + const [limit] = useState(20); const [search, setSearch] = useState(''); - const [loading, setLoading] = useState(false); + const [editingUser, setEditingUser] = useState<{ + id: string; + username: string; + firstName: string; + lastName: string; + email: string; + phone: string; + } | null>(null); - useEffect(() => { - const fetchUsers = async () => { - try { - setLoading(true); - const response = await api.get('/users', { - params: { - page: pagination.page, - limit: pagination.limit, - search: search || undefined, - }, - }); - setUsers(response.data.data.users); - setPagination(response.data.data.pagination); - } catch (err) { - console.error('Failed to fetch users:', err); - } finally { - setLoading(false); + const { data, isLoading } = useUsers(page, limit, search || undefined); + const updateUser = useUpdateUser(); + const deleteUser = useDeleteUser(); + + const responseData = data?.data?.data as UserListResponse | undefined; + const users: User[] = responseData?.users ?? []; + const pagination = responseData?.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 }; + + const handleToggleActive = (userId: string, currentActive: boolean) => { + updateUser.mutate( + { id: userId, data: { isActive: !currentActive } }, + { + onSuccess: () => { + alert(currentActive ? '已禁用用户' : '已启用用户'); + }, + onError: () => { + alert('操作失败,请重试'); + }, } - }; + ); + }; - fetchUsers(); - }, [pagination.page, search]); + const handleDelete = (userId: string) => { + if (!confirm('确定要删除该用户吗?此操作不可撤销。')) return; + deleteUser.mutate(userId, { + onSuccess: () => { + alert('用户已删除'); + }, + onError: () => { + alert('删除失败,请重试'); + }, + }); + }; - const handleDelete = async (userId: string) => { - if (!confirm('Are you sure you want to delete this user?')) return; + const handleEditSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!editingUser) return; - try { - await api.delete(`/users/${userId}`); - setUsers(users.filter((u) => u.id !== userId)); - } catch (err) { - console.error('Failed to delete user:', err); - alert('Failed to delete user'); - } + updateUser.mutate( + { + id: editingUser.id, + data: { + username: editingUser.username, + firstName: editingUser.firstName || undefined, + lastName: editingUser.lastName || undefined, + email: editingUser.email || undefined, + phone: editingUser.phone || undefined, + }, + }, + { + onSuccess: () => { + setEditingUser(null); + alert('用户信息已更新'); + }, + onError: () => { + alert('更新失败,请重试'); + }, + } + ); }; return (
-

User Management

+

用户管理

setSearch(e.target.value)} - placeholder="Search users..." + onChange={(e) => { + setSearch(e.target.value); + setPage(1); + }} + placeholder="搜索用户..." className="flex-1 px-3 py-2 border rounded-md" />
-
- - - - - - - - - - - - - {loading ? ( - - - - ) : users.length === 0 ? ( - - - - ) : ( - users.map((user) => ( - - - - - - - + + +
+
- User - - Email - - Phone - - Status - - Created - - Actions -
- Loading... -
- No users found -
-
-
- {user.avatar ? ( - - ) : ( - - {user.firstName?.[0] || user.username[0]} - - )} -
- {user.username} -
-
{user.email || '-'}{user.phone || '-'} - - {user.isActive ? 'Active' : 'Inactive'} - - - {new Date(user.createdAt).toLocaleDateString()} - - -
+ + + + + + + + - )) - )} - -
+ 用户 + + 邮箱 + + 手机 + + 状态 + + 创建时间 + + 操作 +
-
+ + + {isLoading ? ( + + + 加载中... + + + ) : users.length === 0 ? ( + + + 暂无用户数据 + + + ) : ( + users.map((user) => ( + + +
+
+ {user.avatar ? ( + + ) : ( + + {user.firstName?.[0] || user.username[0]} + + )} +
+ {user.username} +
+ + {user.email || '-'} + {user.phone || '-'} + + + + + {new Date(user.createdAt).toLocaleDateString('zh-CN')} + + + + + + + )) + )} + + +
+ + {pagination.totalPages > 1 && (

- Showing {(pagination.page - 1) * pagination.limit + 1} to{' '} - {Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '} - users + 显示第 {(pagination.page - 1) * pagination.limit + 1} -{' '} + {Math.min(pagination.page * pagination.limit, pagination.total)} 条,共{' '} + {pagination.total} 条

- - - Page {pagination.page} of {pagination.totalPages} + 上一页 + + + 第 {pagination.page} / {pagination.totalPages} 页 - + 下一页 + +
+
+ )} + + {editingUser && ( +
+
+

编辑用户

+
+
+ + + setEditingUser({ ...editingUser, username: e.target.value }) + } + className="w-full px-3 py-2 border rounded-md" + /> +
+
+
+ + + setEditingUser({ ...editingUser, lastName: e.target.value }) + } + className="w-full px-3 py-2 border rounded-md" + /> +
+
+ + + setEditingUser({ ...editingUser, firstName: e.target.value }) + } + className="w-full px-3 py-2 border rounded-md" + /> +
+
+
+ + + setEditingUser({ ...editingUser, email: e.target.value }) + } + className="w-full px-3 py-2 border rounded-md" + /> +
+
+ + + setEditingUser({ ...editingUser, phone: e.target.value }) + } + className="w-full px-3 py-2 border rounded-md" + /> +
+
+ + +
+
)} diff --git a/apps/web/src/app/(dashboard)/notifications/page.tsx b/apps/web/src/app/(dashboard)/notifications/page.tsx new file mode 100644 index 0000000..2e7b380 --- /dev/null +++ b/apps/web/src/app/(dashboard)/notifications/page.tsx @@ -0,0 +1,244 @@ +'use client'; + +import { useState } from 'react'; +import { Info, AlertTriangle, XCircle, CheckCircle, Trash2, Bell, Settings } from 'lucide-react'; +import Link from 'next/link'; +import { + useNotifications, + useUnreadCount, + useMarkAsRead, + useMarkAllAsRead, + useDeleteNotification, +} from '@/hooks/use-notifications'; +import type { Notification, NotificationChannelType, NotificationType } from '@/lib/notification-api'; + +const typeIconMap: Record = { + info: , + warning: , + error: , + success: , +}; + +const channelOptions: { label: string; value: NotificationChannelType | '' }[] = [ + { label: '全部', value: '' }, + { label: '站内', value: 'in-app' }, + { label: '邮件', value: 'email' }, + { label: '短信', value: 'sms' }, + { label: '企微', value: 'wecom' }, + { label: '推送', value: 'push' }, +]; + +const typeOptions: { label: string; value: NotificationType | '' }[] = [ + { label: '全部', value: '' }, + { label: '信息', value: 'info' }, + { label: '警告', value: 'warning' }, + { label: '错误', value: 'error' }, + { label: '成功', value: 'success' }, +]; + +export default function NotificationsPage() { + const [channel, setChannel] = useState(''); + const [type, setType] = useState(''); + const [page, setPage] = useState(1); + const limit = 20; + + const { data: notificationsData, isLoading } = useNotifications( + page, + limit, + channel || undefined, + undefined, + type || undefined, + ); + const { data: unreadData } = useUnreadCount(); + const markAsRead = useMarkAsRead(); + const markAllAsRead = useMarkAllAsRead(); + const deleteNotification = useDeleteNotification(); + + const notifications: Notification[] = notificationsData?.data?.notifications || []; + const total: number = notificationsData?.data?.total || 0; + const unreadCount: number = unreadData?.data?.count || 0; + const totalPages = Math.ceil(total / limit); + + const handleMarkAsRead = (id: string) => { + markAsRead.mutate(id); + }; + + const handleMarkAllAsRead = () => { + markAllAsRead.mutate(); + }; + + const handleDelete = (id: string) => { + deleteNotification.mutate(id); + }; + + const handleChannelChange = (value: NotificationChannelType | '') => { + setChannel(value); + setPage(1); + }; + + const handleTypeChange = (value: NotificationType | '') => { + setType(value); + setPage(1); + }; + + if (isLoading) { + return
加载中...
; + } + + return ( +
+
+
+

通知中心

+ {unreadCount > 0 && ( + + {unreadCount} 条未读 + + )} +
+
+ + + + 偏好设置 + +
+
+ +
+
+ 渠道: +
+ {channelOptions.map((opt) => ( + + ))} +
+
+
+ 类型: +
+ {typeOptions.map((opt) => ( + + ))} +
+
+
+ +
+ {notifications.length === 0 ? ( +
+ +

暂无通知

+
+ ) : ( + notifications.map((notification) => ( +
+
+ {typeIconMap[notification.type]} +
+
+
+

+ {notification.title} +

+ {!notification.readAt && ( + + )} +
+

+ {notification.content} +

+
+ {new Date(notification.createdAt).toLocaleString('zh-CN')} + + {notification.channel === 'in-app' ? '站内' : + notification.channel === 'email' ? '邮件' : + notification.channel === 'sms' ? '短信' : + notification.channel === 'wecom' ? '企微' : + notification.channel === 'push' ? '推送' : notification.channel} + +
+
+
+ {!notification.readAt && ( + + )} + +
+
+ )) + )} +
+ + {totalPages > 1 && ( +
+ + + 第 {page} / {totalPages} 页 + + +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/notifications/preferences/page.tsx b/apps/web/src/app/(dashboard)/notifications/preferences/page.tsx new file mode 100644 index 0000000..5ce7892 --- /dev/null +++ b/apps/web/src/app/(dashboard)/notifications/preferences/page.tsx @@ -0,0 +1,243 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; +import { + useNotificationPreferences, + useUpdateNotificationPreferences, +} from '@/hooks/use-notifications'; +import type { UpdateNotificationPreferenceData, NotificationPreference } from '@/lib/notification-api'; + +const defaultPreferences: UpdateNotificationPreferenceData = { + emailEnabled: true, + smsEnabled: true, + pushEnabled: true, + inAppEnabled: true, + securityEnabled: true, + marketingEnabled: false, + systemEnabled: true, + quietStartHour: undefined, + quietEndHour: undefined, + timezone: 'Asia/Shanghai', +}; + +function preferenceToForm(p: NotificationPreference): UpdateNotificationPreferenceData { + return { + emailEnabled: p.emailEnabled, + smsEnabled: p.smsEnabled, + pushEnabled: p.pushEnabled, + inAppEnabled: p.inAppEnabled, + securityEnabled: p.securityEnabled, + marketingEnabled: p.marketingEnabled, + systemEnabled: p.systemEnabled, + quietStartHour: p.quietStartHour, + quietEndHour: p.quietEndHour, + timezone: p.timezone, + }; +} + +function PreferencesForm({ preferences }: { preferences?: NotificationPreference }) { + const updatePreferences = useUpdateNotificationPreferences(); + + const [form, setForm] = useState( + preferences ? preferenceToForm(preferences) : defaultPreferences, + ); + + const handleToggle = (key: keyof UpdateNotificationPreferenceData) => { + setForm((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + + const handleSave = () => { + updatePreferences.mutate(form); + }; + + return ( +
+
+

通知渠道

+
+ handleToggle('emailEnabled')} + /> + handleToggle('smsEnabled')} + /> + handleToggle('pushEnabled')} + /> + handleToggle('inAppEnabled')} + /> +
+
+ +
+

通知类型

+
+ handleToggle('securityEnabled')} + /> + handleToggle('marketingEnabled')} + /> + handleToggle('systemEnabled')} + /> +
+
+ +
+

静音时段

+

在指定时段内不会收到通知推送

+
+
+ + +
+
+ + +
+
+
+ + + + {updatePreferences.isSuccess && ( +
+ 设置已保存 +
+ )} + + {updatePreferences.isError && ( +
+ 保存失败,请重试 +
+ )} +
+ ); +} + +export default function NotificationPreferencesPage() { + const { data: preferencesData, isLoading } = useNotificationPreferences(); + + const preferences = preferencesData?.data; + + const formKey = useMemo(() => preferences?.id ?? 'default', [preferences?.id]); + + if (isLoading) { + return
加载中...
; + } + + return ( +
+
+ + + +

通知偏好设置

+
+ + +
+ ); +} + +function ToggleRow({ + label, + description, + enabled, + onToggle, +}: { + label: string; + description: string; + enabled: boolean; + onToggle: () => void; +}) { + return ( +
+
+

{label}

+

{description}

+
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/orders/[id]/page.tsx b/apps/web/src/app/(dashboard)/orders/[id]/page.tsx new file mode 100644 index 0000000..618ccfe --- /dev/null +++ b/apps/web/src/app/(dashboard)/orders/[id]/page.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useOrder, useCancelOrder, useUpdateOrderStatus } from '@/hooks/use-orders'; +import { OrderItem, OrderStatusType } from '@/lib/order-api'; + +const STATUS_LABEL_MAP: Record = { + PENDING: '待支付', + PAID: '已支付', + SHIPPED: '已发货', + COMPLETED: '已完成', + CANCELLED: '已取消', + REFUNDED: '已退款', +}; + +const STATUS_STYLE_MAP: Record = { + PENDING: 'bg-yellow-100 text-yellow-800', + PAID: 'bg-blue-100 text-blue-800', + SHIPPED: 'bg-purple-100 text-purple-800', + COMPLETED: 'bg-green-100 text-green-800', + CANCELLED: 'bg-gray-100 text-gray-800', + REFUNDED: 'bg-red-100 text-red-800', +}; + +const TIMELINE_FIELDS: { status: OrderStatusType; timeField: string; label: string }[] = [ + { status: 'PENDING', timeField: 'createdAt', label: '创建订单' }, + { status: 'PAID', timeField: 'paidAt', label: '支付成功' }, + { status: 'SHIPPED', timeField: 'shippedAt', label: '已发货' }, + { status: 'COMPLETED', timeField: 'completedAt', label: '已完成' }, +]; + +const STATUS_FLOW: OrderStatusType[] = ['PENDING', 'PAID', 'SHIPPED', 'COMPLETED']; + +const NEXT_STATUS_MAP: Partial> = { + PENDING: 'PAID', + PAID: 'SHIPPED', + SHIPPED: 'COMPLETED', +}; + +export default function OrderDetailPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + const { data: orderData, isLoading } = useOrder(id); + const cancelOrder = useCancelOrder(); + const updateStatus = useUpdateOrderStatus(); + + const order = orderData?.data; + + if (isLoading) { + return
加载中...
; + } + + if (!order) { + return
订单不存在
; + } + + const currentStatus = order.status as OrderStatusType; + const nextStatus = NEXT_STATUS_MAP[currentStatus]; + const canCancel = currentStatus === 'PENDING' || currentStatus === 'PAID'; + + const handleCancel = async () => { + if (!confirm('确定要取消该订单吗?')) return; + try { + await cancelOrder.mutateAsync(id); + } catch { + alert('取消订单失败'); + } + }; + + const handleUpdateStatus = async (status: OrderStatusType) => { + try { + await updateStatus.mutateAsync({ id, status }); + } catch { + alert('更新状态失败'); + } + }; + + const isTerminalStatus = currentStatus === 'CANCELLED' || currentStatus === 'REFUNDED'; + + return ( +
+
+ +

订单详情

+
+ +
+
+
+

基本信息

+
+
+ 订单号 +

{order.orderNo}

+
+
+ 状态 +

+ + {STATUS_LABEL_MAP[currentStatus]} + +

+
+
+ 订单金额 +

¥{order.totalAmount.toFixed(2)}

+
+
+ 实付金额 +

¥{order.paidAmount.toFixed(2)}

+
+
+ 优惠金额 +

¥{order.discountAmount.toFixed(2)}

+
+
+ 支付方式 +

{order.paymentMethod || '-'}

+
+
+ 创建时间 +

{new Date(order.createdAt).toLocaleString()}

+
+
+ 备注 +

{order.remark || '-'}

+
+
+
+ +
+

订单商品

+ {order.items && order.items.length > 0 ? ( + + + + + + + + + + + {order.items.map((item: OrderItem) => ( + + + + + + + ))} + +
商品单价数量小计
+
+ {item.productImage && ( + + )} + {item.productName} +
+
¥{item.unitPrice.toFixed(2)}{item.quantity}¥{item.totalAmount.toFixed(2)}
+ ) : ( +

暂无商品信息

+ )} +
+
+ +
+
+

状态时间线

+
+ {TIMELINE_FIELDS.map((step, index) => { + const isReached = !isTerminalStatus && STATUS_FLOW.indexOf(currentStatus) >= index; + const timeValue = order[step.timeField as keyof typeof order] as string | undefined; + + return ( +
+
+
+

+ {step.label} +

+ {timeValue && ( +

{new Date(timeValue).toLocaleString()}

+ )} +
+
+ ); + })} + {(currentStatus === 'CANCELLED' || currentStatus === 'REFUNDED') && ( +
+
+
+

+ {STATUS_LABEL_MAP[currentStatus]} +

+ {(order.cancelledAt) && ( +

{new Date(order.cancelledAt).toLocaleString()}

+ )} +
+
+ )} +
+
+ +
+

操作

+ {nextStatus && ( + + )} + {canCancel && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/orders/page.tsx b/apps/web/src/app/(dashboard)/orders/page.tsx new file mode 100644 index 0000000..c2d277a --- /dev/null +++ b/apps/web/src/app/(dashboard)/orders/page.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useOrders, useCancelOrder } from '@/hooks/use-orders'; +import { Order, OrderStatusType } from '@/lib/order-api'; + +const STATUS_OPTIONS: { label: string; value?: OrderStatusType }[] = [ + { label: '全部' }, + { label: '待支付', value: 'PENDING' }, + { label: '已支付', value: 'PAID' }, + { label: '已发货', value: 'SHIPPED' }, + { label: '已完成', value: 'COMPLETED' }, + { label: '已取消', value: 'CANCELLED' }, + { label: '已退款', value: 'REFUNDED' }, +]; + +const STATUS_LABEL_MAP: Record = { + PENDING: '待支付', + PAID: '已支付', + SHIPPED: '已发货', + COMPLETED: '已完成', + CANCELLED: '已取消', + REFUNDED: '已退款', +}; + +const STATUS_STYLE_MAP: Record = { + PENDING: 'bg-yellow-100 text-yellow-800', + PAID: 'bg-blue-100 text-blue-800', + SHIPPED: 'bg-purple-100 text-purple-800', + COMPLETED: 'bg-green-100 text-green-800', + CANCELLED: 'bg-gray-100 text-gray-800', + REFUNDED: 'bg-red-100 text-red-800', +}; + +export default function OrdersPage() { + const router = useRouter(); + const [page, setPage] = useState(1); + const [statusFilter, setStatusFilter] = useState(); + const limit = 20; + + const { data: ordersData, isLoading } = useOrders(page, limit, statusFilter); + const cancelOrder = useCancelOrder(); + + const orders = ordersData?.data?.orders || []; + const pagination = ordersData?.data?.pagination; + + const handleCancel = async (id: string) => { + if (!confirm('确定要取消该订单吗?')) return; + try { + await cancelOrder.mutateAsync(id); + } catch { + alert('取消订单失败'); + } + }; + + return ( +
+

订单管理

+ +
+ {STATUS_OPTIONS.map((opt) => ( + + ))} +
+ + {isLoading ? ( +
加载中...
+ ) : orders.length === 0 ? ( +
暂无订单
+ ) : ( +
+ + + + + + + + + + + + {orders.map((order: Order) => ( + router.push(`/orders/${order.id}`)} + > + + + + + + + ))} + +
订单号金额状态创建时间操作
{order.orderNo}¥{order.totalAmount.toFixed(2)} + + {STATUS_LABEL_MAP[order.status as OrderStatusType] || order.status} + + + {new Date(order.createdAt).toLocaleString()} + e.stopPropagation()}> + {(order.status === 'PENDING' || order.status === 'PAID') && ( + + )} +
+
+ )} + + {pagination && pagination.totalPages > 1 && ( +
+

+ 第 {(pagination.page - 1) * pagination.limit + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} 条,共 {pagination.total} 条 +

+
+ + + 第 {pagination.page} / {pagination.totalPages} 页 + + +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 211cde1..ec46b3d 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -3,16 +3,19 @@ import { cn } from '@/lib/utils' import { useAppStore } from '@/stores/appStore' import { Button } from '@/components/ui/button' -import { Home, Settings, Users, FileText, Folder } from 'lucide-react' +import { Home, Settings, Users, Folder, ShoppingCart, Bell, CreditCard, Shield } from 'lucide-react' import Link from 'next/link' import { usePathname } from 'next/navigation' const sidebarItems = [ - { icon: Home, label: 'Home', href: '/' }, - { icon: Folder, label: 'Files', href: '/files' }, - { icon: FileText, label: 'Documents', href: '/documents' }, - { icon: Users, label: 'Users', href: '/users' }, - { icon: Settings, label: 'Settings', href: '/settings' }, + { icon: Home, label: '首页', href: '/dashboard' }, + { icon: ShoppingCart, label: '订单管理', href: '/orders' }, + { icon: CreditCard, label: '支付订单', href: '/payments' }, + { icon: Bell, label: '通知中心', href: '/notifications' }, + { icon: Folder, label: '文件管理', href: '/files' }, + { icon: Users, label: '用户管理', href: '/admin/users' }, + { icon: Shield, label: '角色权限', href: '/admin/roles' }, + { icon: Settings, label: '设置', href: '/settings' }, ] export function Sidebar() { @@ -29,7 +32,7 @@ export function Sidebar() {
{sidebarItems.map((item) => { const Icon = item.icon - const isActive = pathname === item.href + const isActive = pathname === item.href || pathname.startsWith(item.href + '/') return (