feat: surface notifications in taro admin and profile

This commit is contained in:
Codex
2026-06-30 04:08:58 +08:00
parent d72ff499ce
commit 0912a4d3fa
9 changed files with 290 additions and 18 deletions

View File

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

View File

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