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