forked from wangziqi/gongxue-base
feat: add student notification center
This commit is contained in:
@@ -28,6 +28,7 @@ export default function StudentHomePage() {
|
||||
{ name: '分数线', path: '/pages/student/scoreline/index', meta: '院校趋势' },
|
||||
{ name: 'AI择校', path: '/pages/student/ai-school/index', meta: 'SVIP报告' },
|
||||
{ name: '资料', path: '/pages/student/assets/index', meta: 'PDF 预览' },
|
||||
{ name: '消息', path: '/pages/student/notifications/index', meta: '通知 / 待办' },
|
||||
{ name: '个人中心', path: '/pages/student/profile/index', meta: '会员 / 订单' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '消息中心',
|
||||
});
|
||||
263
apps/taro/src/pages/student/notifications/index.tsx
Normal file
263
apps/taro/src/pages/student/notifications/index.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
import {
|
||||
loadNotifications,
|
||||
updateNotificationStatus,
|
||||
type UserNotificationItem,
|
||||
} from '@/services/profile';
|
||||
import '../student.css';
|
||||
|
||||
type NotificationStatus = NonNullable<UserNotificationItem['status']>;
|
||||
type StatusFilter = '' | NotificationStatus;
|
||||
|
||||
const statusFilters: Array<[StatusFilter, string]> = [
|
||||
['', '全部'],
|
||||
['unread', '未读'],
|
||||
['read', '已读'],
|
||||
['archived', '归档'],
|
||||
];
|
||||
|
||||
const typeFilters = [
|
||||
['', '全部类型'],
|
||||
['feedback_status_updated', '反馈处理'],
|
||||
['feedback_reward_granted', '反馈奖励'],
|
||||
['badge_granted', '勋章'],
|
||||
['point_exchange_completed', '兑换完成'],
|
||||
['point_exchange_pending_fulfillment', '待履约'],
|
||||
];
|
||||
|
||||
const allowedStudentActionPaths = new Set([
|
||||
'/pages/student/home/index',
|
||||
'/pages/student/region/index',
|
||||
'/pages/student/catalog/index',
|
||||
'/pages/student/practice/index',
|
||||
'/pages/student/review/index',
|
||||
'/pages/student/reports/index',
|
||||
'/pages/student/video/index',
|
||||
'/pages/student/checkout/index',
|
||||
'/pages/student/order-detail/index',
|
||||
'/pages/student/vocabulary/index',
|
||||
'/pages/student/handbook/index',
|
||||
'/pages/student/scoreline/index',
|
||||
'/pages/student/ai-school/index',
|
||||
'/pages/student/assets/index',
|
||||
'/pages/student/notifications/index',
|
||||
'/pages/student/profile/index',
|
||||
]);
|
||||
|
||||
function dateText(value?: string | null) {
|
||||
if (!value) return '暂无时间';
|
||||
return value.slice(0, 16).replace('T', ' ');
|
||||
}
|
||||
|
||||
function notificationTypeLabel(type?: string | null) {
|
||||
if (type === 'feedback_status_updated') return '反馈处理';
|
||||
if (type === 'feedback_reward_granted') return '反馈奖励';
|
||||
if (type === 'badge_granted') return '勋章发放';
|
||||
if (type === 'point_exchange_completed') return '兑换完成';
|
||||
if (type === 'point_exchange_pending_fulfillment') return '待履约兑换';
|
||||
return '系统通知';
|
||||
}
|
||||
|
||||
function notificationStatusLabel(status?: string | null) {
|
||||
if (status === 'unread') return '未读';
|
||||
if (status === 'read') return '已读';
|
||||
if (status === 'dismissed') return '已忽略';
|
||||
if (status === 'archived') return '已归档';
|
||||
return '通知';
|
||||
}
|
||||
|
||||
function notificationStatusClass(status?: string | null, severity?: string | null) {
|
||||
if (severity === 'error') return 'danger';
|
||||
if (status === 'unread' || severity === 'warning') return 'warning';
|
||||
return '';
|
||||
}
|
||||
|
||||
function safeStudentActionPath(value?: string | null) {
|
||||
const path = String(value || '').trim();
|
||||
if (!path) return '';
|
||||
if (path.includes('://') || path.startsWith('//')) return '';
|
||||
if (path.includes('\\') || path.includes('..')) return '';
|
||||
if (!path.startsWith('/pages/student/')) return '';
|
||||
const routePath = path.split('?')[0].split('#')[0];
|
||||
if (!allowedStudentActionPaths.has(routePath)) return '';
|
||||
return path;
|
||||
}
|
||||
|
||||
export default function StudentNotificationsPage() {
|
||||
const [items, setItems] = useState<UserNotificationItem[]>([]);
|
||||
const [summary, setSummary] = useState<Record<string, number>>({});
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('unread');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function reload(nextStatus = statusFilter, nextType = typeFilter) {
|
||||
setBusy('reload');
|
||||
setError('');
|
||||
try {
|
||||
const payload = await loadNotifications({
|
||||
status: nextStatus || undefined,
|
||||
notificationType: nextType || undefined,
|
||||
limit: 30,
|
||||
});
|
||||
setItems(payload.items || []);
|
||||
setSummary(payload.summary || {});
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '消息加载失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
async function changeStatusFilter(nextStatus: StatusFilter) {
|
||||
setStatusFilter(nextStatus);
|
||||
await reload(nextStatus, typeFilter);
|
||||
}
|
||||
|
||||
async function changeTypeFilter(nextType: string) {
|
||||
setTypeFilter(nextType);
|
||||
await reload(statusFilter, nextType);
|
||||
}
|
||||
|
||||
async function markNotification(item: UserNotificationItem, status: 'read' | 'dismissed' | 'archived') {
|
||||
setBusy(`${item.id}:${status}`);
|
||||
setError('');
|
||||
try {
|
||||
await updateNotificationStatus({ notificationIds: [item.id], status });
|
||||
setMessage(status === 'read' ? '消息已标记为已读' : status === 'archived' ? '消息已归档' : '消息已忽略');
|
||||
await reload(statusFilter, typeFilter);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '消息状态更新失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function markVisibleRead() {
|
||||
const unreadIds = items.filter(item => item.status === 'unread').map(item => item.id);
|
||||
if (!unreadIds.length) {
|
||||
setMessage('当前列表没有未读消息');
|
||||
return;
|
||||
}
|
||||
setBusy('bulk-read');
|
||||
setError('');
|
||||
try {
|
||||
await updateNotificationStatus({ notificationIds: unreadIds, status: 'read' });
|
||||
setMessage(`已标记 ${String(unreadIds.length)} 条消息`);
|
||||
await reload(statusFilter, typeFilter);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '批量标记失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function openAction(item: UserNotificationItem) {
|
||||
const actionPath = safeStudentActionPath(item.actionPath);
|
||||
if (!actionPath) {
|
||||
setMessage('暂无可打开内容');
|
||||
return;
|
||||
}
|
||||
if (item.status === 'unread') {
|
||||
try {
|
||||
await updateNotificationStatus({ notificationIds: [item.id], status: 'read' });
|
||||
} catch {
|
||||
// The detail page can still open if marking read fails; the next reload will reconcile state.
|
||||
}
|
||||
}
|
||||
Taro.navigateTo({ url: actionPath });
|
||||
}
|
||||
|
||||
const totalHandled = useMemo(() => (summary.read || 0) + (summary.archived || 0) + (summary.dismissed || 0), [summary]);
|
||||
|
||||
return (
|
||||
<View className='student-page notifications-page'>
|
||||
<View className='student-topbar'>
|
||||
<View className='student-title-block'>
|
||||
<Text className='student-kicker'>Notifications</Text>
|
||||
<Text className='student-title'>消息中心</Text>
|
||||
<Text className='student-subtitle'>反馈、勋章、积分和兑换通知</Text>
|
||||
</View>
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateBack()}>返回</Button>
|
||||
</View>
|
||||
|
||||
<View className='grid-two notification-summary-grid'>
|
||||
<View className='metric compact-metric'><Text className='metric-value'>{String(summary.unread || 0)}</Text><Text className='metric-label'>未读</Text></View>
|
||||
<View className='metric compact-metric'><Text className='metric-value'>{String(totalHandled)}</Text><Text className='metric-label'>已处理</Text></View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<View className='toolbar wrap notification-filterbar'>
|
||||
{statusFilters.map(([value, label]) => (
|
||||
<Button
|
||||
key={value || 'all'}
|
||||
className={`secondary-button ${statusFilter === value ? 'active' : ''}`}
|
||||
loading={busy === 'reload' && statusFilter === value}
|
||||
onClick={() => void changeStatusFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
<Button className='secondary-button' loading={busy === 'bulk-read'} onClick={() => void markVisibleRead()}>全部已读</Button>
|
||||
<Button className='secondary-button' loading={busy === 'reload'} onClick={() => void reload()}>刷新</Button>
|
||||
</View>
|
||||
|
||||
<View className='toolbar wrap notification-typebar'>
|
||||
{typeFilters.map(([value, label]) => (
|
||||
<Button
|
||||
key={value || 'all-types'}
|
||||
className={`pill-button ${typeFilter === value ? 'active' : ''}`}
|
||||
onClick={() => void changeTypeFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
{items.length ? (
|
||||
<View className='list-stack'>
|
||||
{items.map(item => {
|
||||
const actionPath = safeStudentActionPath(item.actionPath);
|
||||
return (
|
||||
<View className={`list-row notification-row ${item.status === 'unread' ? 'active' : ''}`} key={item.id}>
|
||||
<View className='split-row'>
|
||||
<Text className='row-main'>{item.title || notificationTypeLabel(item.notificationType)}</Text>
|
||||
<Text className={`status-badge ${notificationStatusClass(item.status, item.severity)}`}>{notificationStatusLabel(item.status)}</Text>
|
||||
</View>
|
||||
<Text className='row-meta'>{notificationTypeLabel(item.notificationType)} · {dateText(item.createdAt)}</Text>
|
||||
<Text className='row-meta notification-message'>{item.message || '暂无消息内容'}</Text>
|
||||
<View className='toolbar compact-toolbar notification-actions'>
|
||||
{item.status !== 'read' ? (
|
||||
<Button className='secondary-button' loading={busy === `${item.id}:read`} onClick={() => void markNotification(item, 'read')}>已读</Button>
|
||||
) : null}
|
||||
{item.status !== 'archived' ? (
|
||||
<Button className='secondary-button' loading={busy === `${item.id}:archived`} onClick={() => void markNotification(item, 'archived')}>归档</Button>
|
||||
) : null}
|
||||
{item.status !== 'dismissed' ? (
|
||||
<Button className='secondary-button' loading={busy === `${item.id}:dismissed`} onClick={() => void markNotification(item, 'dismissed')}>忽略</Button>
|
||||
) : null}
|
||||
{actionPath ? (
|
||||
<Button className='primary-button' onClick={() => void openAction(item)}>{item.actionLabel || '打开'}</Button>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无消息。</View>}
|
||||
</View>
|
||||
|
||||
{message ? <Text className='success-text'>{message}</Text> : null}
|
||||
{error ? <Text className='error-text'>{error}</Text> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -522,6 +522,7 @@ export default function StudentProfilePage() {
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
<Button className='secondary-button' onClick={() => Taro.navigateTo({ url: '/pages/student/notifications/index' })}>消息中心</Button>
|
||||
</View>
|
||||
{notifications.length ? (
|
||||
<View className='list-stack'>
|
||||
|
||||
@@ -470,6 +470,28 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.notification-summary-grid {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.notifications-page .notification-filterbar,
|
||||
.notifications-page .notification-typebar {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notifications-page .notification-typebar {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.notifications-page .pill-button {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.notification-actions {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.compact-toolbar .primary-button,
|
||||
.compact-toolbar .secondary-button {
|
||||
min-width: 104px;
|
||||
|
||||
Reference in New Issue
Block a user