feat: add student notification center

This commit is contained in:
Codex
2026-06-30 18:02:51 +08:00
parent 727e65b554
commit 400f9bfc72
14 changed files with 307 additions and 14 deletions

View File

@@ -40,10 +40,11 @@ pages/student/vocabulary/index 单词单元、复习计划、认识/再记、
pages/student/handbook/index 手册科目、章节、知识点阅读
pages/student/scoreline/index 分数线列表
pages/student/assets/index 资料列表、预览签名、下载签名
pages/student/notifications/index 独立消息中心、筛选、已读、归档
pages/student/profile/index 个人中心、男女预设头像、会员、订单、签到、激活码、勋章
```
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、题目反馈、视频解析、交卷报告、错题本、收藏夹、男女预设头像选择、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善长题干排版、公式图片混排、支付容器体验和小程序兼容。学生头像不做上传、裁剪或第三方头像同步,个人中心只保存 `avatarPreset=male/female`
这些页面是联调骨架,不是最终视觉稿。当前学生端已覆盖地区选择、刷题、答题卡、后端权威断点续练、本地恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、题目反馈、视频解析、交卷报告、错题本、收藏夹、男女预设头像选择、独立消息中心、会员收银台、订单详情和售后入口第一版;后续应继续参照旧题库样式完善长题干排版、公式图片混排、支付容器体验和小程序兼容。学生头像不做上传、裁剪或第三方头像同步,个人中心只保存 `avatarPreset=male/female`消息中心只允许打开已登记的学生端站内路径,不根据通知 payload 跳外链或后台页面。
学生端商城链路的安全边界:

View File

@@ -16,6 +16,7 @@ export default defineAppConfig({
'pages/student/scoreline/index',
'pages/student/ai-school/index',
'pages/student/assets/index',
'pages/student/notifications/index',
'pages/student/profile/index',
'pages/tenant-admin/workbench/index',
'pages/tenant-admin/dashboard/index',

View File

@@ -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: '会员 / 订单' },
];

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '消息中心',
});

View 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>
);
}

View File

@@ -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'>

View File

@@ -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;