forked from wangziqi/gongxue-base
feat: surface notifications in taro admin and profile
This commit is contained in:
@@ -13,7 +13,16 @@ import {
|
||||
type LearningTrendItem,
|
||||
type PracticeHistoryItem,
|
||||
} from '@/services/learning';
|
||||
import { checkIn, loadBadges, loadExamCountdowns, loadProfile, type StudentProfile } from '@/services/profile';
|
||||
import {
|
||||
checkIn,
|
||||
loadBadges,
|
||||
loadExamCountdowns,
|
||||
loadNotifications,
|
||||
loadProfile,
|
||||
updateNotificationStatus,
|
||||
type StudentProfile,
|
||||
type UserNotificationItem,
|
||||
} from '@/services/profile';
|
||||
import '../student.css';
|
||||
|
||||
function percent(value?: number | null) {
|
||||
@@ -72,6 +81,31 @@ function sessionStatusClass(status?: string | null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
type ProfileNotificationStatus = NonNullable<UserNotificationItem['status']>;
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
export default function StudentProfilePage() {
|
||||
const [profile, setProfile] = useState<StudentProfile | null>(null);
|
||||
const [plans, setPlans] = useState<SvipPlan[]>([]);
|
||||
@@ -83,11 +117,15 @@ export default function StudentProfilePage() {
|
||||
const [learningStats, setLearningStats] = useState<LearningStats | null>(null);
|
||||
const [learningTrend, setLearningTrend] = useState<LearningTrendItem[]>([]);
|
||||
const [practiceHistory, setPracticeHistory] = useState<PracticeHistoryItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<UserNotificationItem[]>([]);
|
||||
const [notificationSummary, setNotificationSummary] = useState<Record<string, number>>({});
|
||||
const [notificationFilter, setNotificationFilter] = useState<'' | ProfileNotificationStatus>('unread');
|
||||
const [notificationBusy, setNotificationBusy] = useState('');
|
||||
const [activationCode, setActivationCode] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function reload() {
|
||||
function reload(nextNotificationFilter = notificationFilter) {
|
||||
Promise.all([
|
||||
loadProfile().catch(() => ({ item: null })),
|
||||
loadSvipPlans().catch(() => ({ items: [] })),
|
||||
@@ -98,6 +136,7 @@ export default function StudentProfilePage() {
|
||||
loadLearningStats(30).catch(() => ({ item: undefined })),
|
||||
loadLearningTrend(14).catch(() => ({ items: [] })),
|
||||
loadPracticeHistory({ limit: 6 }).catch(() => ({ items: [] })),
|
||||
loadNotifications({ status: nextNotificationFilter || undefined, limit: 8 }).catch(() => ({ items: [], summary: {} })),
|
||||
loadEntitlements().catch(() => ({ summary: {} })),
|
||||
]).then(([
|
||||
profilePayload,
|
||||
@@ -109,6 +148,7 @@ export default function StudentProfilePage() {
|
||||
statsPayload,
|
||||
trendPayload,
|
||||
historyPayload,
|
||||
notificationPayload,
|
||||
]) => {
|
||||
setProfile(profilePayload.item || null);
|
||||
setPlans(planPayload.items || []);
|
||||
@@ -120,6 +160,8 @@ export default function StudentProfilePage() {
|
||||
setLearningStats(statsPayload.item || null);
|
||||
setLearningTrend(trendPayload.items || []);
|
||||
setPracticeHistory(historyPayload.items || []);
|
||||
setNotifications(notificationPayload.items || []);
|
||||
setNotificationSummary(notificationPayload.summary || {});
|
||||
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '个人中心加载失败'));
|
||||
}
|
||||
|
||||
@@ -154,6 +196,37 @@ export default function StudentProfilePage() {
|
||||
Taro.redirectTo({ url: '/pages/student/login/index' });
|
||||
}
|
||||
|
||||
async function reloadNotifications(nextFilter = notificationFilter) {
|
||||
setNotificationBusy('reload');
|
||||
try {
|
||||
const payload = await loadNotifications({ status: nextFilter || undefined, limit: 12 });
|
||||
setNotifications(payload.items || []);
|
||||
setNotificationSummary(payload.summary || {});
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '消息加载失败');
|
||||
} finally {
|
||||
setNotificationBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
async function changeNotificationFilter(nextFilter: '' | ProfileNotificationStatus) {
|
||||
setNotificationFilter(nextFilter);
|
||||
await reloadNotifications(nextFilter);
|
||||
}
|
||||
|
||||
async function markNotification(item: UserNotificationItem, status: 'read' | 'dismissed' | 'archived') {
|
||||
setNotificationBusy(`${item.id}:${status}`);
|
||||
try {
|
||||
await updateNotificationStatus({ notificationIds: [item.id], status });
|
||||
setMessage(status === 'read' ? '消息已标记为已读' : status === 'archived' ? '消息已归档' : '消息已忽略');
|
||||
await reloadNotifications(notificationFilter);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '消息状态更新失败');
|
||||
} finally {
|
||||
setNotificationBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
const maxAnswers = maxTrendAnswers(learningTrend);
|
||||
const answerStats = learningStats?.answers;
|
||||
const reportStats = learningStats?.reports;
|
||||
@@ -294,6 +367,54 @@ export default function StudentProfilePage() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>消息中心</Text>
|
||||
<View className='grid-two'>
|
||||
<View className='metric compact-metric'><Text className='metric-value'>{String(notificationSummary.unread || 0)}</Text><Text className='metric-label'>未读消息</Text></View>
|
||||
<View className='metric compact-metric'><Text className='metric-value'>{String((notificationSummary.read || 0) + (notificationSummary.archived || 0))}</Text><Text className='metric-label'>已处理消息</Text></View>
|
||||
</View>
|
||||
<View className='toolbar wrap compact-toolbar notification-toolbar'>
|
||||
{([
|
||||
['', '全部'],
|
||||
['unread', '未读'],
|
||||
['read', '已读'],
|
||||
['archived', '归档'],
|
||||
] as Array<['' | ProfileNotificationStatus, string]>).map(([value, label]) => (
|
||||
<Button
|
||||
key={value || 'all'}
|
||||
className={`secondary-button ${notificationFilter === value ? 'active' : ''}`}
|
||||
loading={notificationBusy === 'reload' && notificationFilter === value}
|
||||
onClick={() => void changeNotificationFilter(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
{notifications.length ? (
|
||||
<View className='list-stack'>
|
||||
{notifications.map(item => (
|
||||
<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'>
|
||||
{item.status !== 'read' ? (
|
||||
<Button className='secondary-button' loading={notificationBusy === `${item.id}:read`} onClick={() => void markNotification(item, 'read')}>已读</Button>
|
||||
) : null}
|
||||
{item.status !== 'archived' ? (
|
||||
<Button className='secondary-button' loading={notificationBusy === `${item.id}:archived`} onClick={() => void markNotification(item, 'archived')}>归档</Button>
|
||||
) : null}
|
||||
{item.actionLabel ? <Text className='row-meta notification-action-text'>{item.actionLabel}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : <View className='empty-state'>暂无消息。反馈处理、勋章发放和积分兑换结果会出现在这里。</View>}
|
||||
</View>
|
||||
|
||||
<View className='section-block'>
|
||||
<Text className='section-heading'>会员套餐</Text>
|
||||
<View className='list-stack'>
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.secondary-button.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
border: 1px solid #fecaca;
|
||||
background: #fff1f2;
|
||||
@@ -143,6 +149,10 @@
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.notification-row.active {
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.split-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -222,6 +232,16 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.notification-action-text {
|
||||
align-self: center;
|
||||
color: #1d4ed8;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -337,6 +357,10 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.notification-toolbar {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.compact-toolbar .primary-button,
|
||||
.compact-toolbar .secondary-button {
|
||||
min-width: 104px;
|
||||
|
||||
Reference in New Issue
Block a user