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;

View File

@@ -144,6 +144,11 @@
background: #fff;
}
.admin-row.active {
border-color: #93c5fd;
background: #eff6ff;
}
.admin-row-main {
display: block;
color: #111827;

View File

@@ -18,6 +18,7 @@ import {
loadCrmConfig,
loadCrmQueue,
loadTenantMembers,
loadUserNotifications,
updateCommissionSettings,
updateCommissionSettlementProofStatus,
updateCommissionSettlementStatus,
@@ -36,6 +37,7 @@ import {
type CrmConfigItem,
type CrmQueueItem,
type TenantMemberItem,
type UserNotificationAdminItem,
} from '@/services/tenantAdmin';
import '../admin.css';
@@ -79,6 +81,28 @@ function rateToPercent(value: unknown) {
return String((Number(value || 0) * 100).toFixed(2)).replace(/\.00$/, '');
}
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 shortDate(value?: string | null) {
if (!value) return '-';
return value.replace('T', ' ').slice(0, 16);
}
function downloadBase64File(filename: string, contentBase64: string, mimeType: string) {
if (typeof document === 'undefined') return false;
const link = document.createElement('a');
@@ -146,6 +170,8 @@ export default function TenantMarketingPage() {
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
const [settlementProofs, setSettlementProofs] = useState<Record<string, CommissionSettlementProofItem[]>>({});
const [members, setMembers] = useState<TenantMemberItem[]>([]);
const [userNotifications, setUserNotifications] = useState<UserNotificationAdminItem[]>([]);
const [userNotificationSummary, setUserNotificationSummary] = useState<Record<string, number>>({});
const [crmForm, setCrmForm] = useState({
enabled: false,
url: '',
@@ -171,6 +197,11 @@ export default function TenantMarketingPage() {
const [memberRate, setMemberRate] = useState({ userId: '', ratePercent: '' });
const [settlementPay, setSettlementPay] = useState({ paymentMethod: 'offline_bank', paymentAccount: '' });
const [proofForm, setProofForm] = useState({ externalUrl: '', title: '', reviewNote: '' });
const [userNotificationFilter, setUserNotificationFilter] = useState({
status: 'unread' as '' | NonNullable<UserNotificationAdminItem['status']>,
notificationType: '',
userId: '',
});
const [crmStatus, setCrmStatus] = useState('');
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -196,6 +227,7 @@ export default function TenantMarketingPage() {
orderPayload,
settlementPayload,
memberPayload,
notificationPayload,
] = await Promise.all([
loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })),
loadCouponReport({
@@ -218,6 +250,7 @@ export default function TenantMarketingPage() {
loadCommissionOrders({ ...period, limit: 20 }).catch(() => ({ items: [] })),
loadCommissionSettlements({ limit: 20 }).catch(() => ({ items: [] })),
loadTenantMembers({ limit: 100 }).catch(() => ({ items: [] })),
loadUserNotifications({ status: userNotificationFilter.status || undefined, limit: 30 }).catch(() => ({ items: [], summary: {} })),
]);
const nextCrm = crmConfigPayload.item || null;
const nextSettings = commissionSettingsPayload.item || null;
@@ -233,6 +266,8 @@ export default function TenantMarketingPage() {
setCommissionOrders(orderPayload.items || []);
setSettlements(settlementPayload.items || []);
setMembers(memberPayload.items || []);
setUserNotifications(notificationPayload.items || []);
setUserNotificationSummary(notificationPayload.summary || {});
if (nextCrm) {
setCrmForm({
enabled: nextCrm.enabled === true,
@@ -399,6 +434,27 @@ export default function TenantMarketingPage() {
}
}
async function refreshUserNotifications(override: Partial<typeof userNotificationFilter> = {}) {
const nextFilter = { ...userNotificationFilter, ...override };
setUserNotificationFilter(nextFilter);
setBusy('user-notifications');
setError('');
try {
const payload = await loadUserNotifications({
status: nextFilter.status || undefined,
notificationType: nextFilter.notificationType || undefined,
userId: nextFilter.userId.trim() || undefined,
limit: 50,
});
setUserNotifications(payload.items || []);
setUserNotificationSummary(payload.summary || {});
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '用户通知加载失败');
} finally {
setBusy('');
}
}
async function saveCommissionSettings() {
setBusy('commission-settings');
setError('');
@@ -604,6 +660,69 @@ export default function TenantMarketingPage() {
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(commission?.commissionAmountCents)}</Text></View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(userNotificationSummary.unread || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(userNotificationSummary.archived || 0)}</Text></View>
</View>
<View className='admin-form-grid'>
<Input
className='admin-input'
placeholder='按学生用户 ID 筛选,可留空'
value={userNotificationFilter.userId}
onInput={event => setUserNotificationFilter(prev => ({ ...prev, userId: String(event.detail.value || '') }))}
/>
</View>
<View className='admin-actions compact'>
{([
['', '全部状态'],
['unread', '未读'],
['read', '已读'],
['dismissed', '已忽略'],
['archived', '已归档'],
] as Array<[typeof userNotificationFilter.status, string]>).map(([status, label]) => (
<Button
key={status || 'all'}
className={`admin-button ${userNotificationFilter.status === status ? 'active' : ''}`}
onClick={() => void refreshUserNotifications({ status })}
>
{label}
</Button>
))}
</View>
<View className='admin-actions compact'>
{([
['', '全部类型'],
['feedback_status_updated', '反馈处理'],
['feedback_reward_granted', '反馈奖励'],
['badge_granted', '勋章发放'],
['point_exchange_completed', '兑换完成'],
['point_exchange_pending_fulfillment', '待履约兑换'],
] as Array<[string, string]>).map(([type, label]) => (
<Button
key={type || 'all-types'}
className={`admin-button ${userNotificationFilter.notificationType === type ? 'active' : ''}`}
onClick={() => void refreshUserNotifications({ notificationType: type })}
>
{label}
</Button>
))}
<Button className='admin-button primary' loading={busy === 'user-notifications'} onClick={() => void refreshUserNotifications()}></Button>
</View>
<View className='admin-list'>
{userNotifications.map(item => (
<View className={`admin-row ${item.status === 'unread' ? 'active' : ''}`} key={item.id}>
<Text className='admin-row-main'>{item.title || notificationTypeLabel(item.notificationType)} · {notificationStatusLabel(item.status)}</Text>
<Text className='admin-row-meta'>{item.userName || item.userPhone || item.userId || '学生'} · {notificationTypeLabel(item.notificationType)} · {shortDate(item.createdAt)}</Text>
<Text className='admin-row-meta'>{item.message || '暂无消息内容'}</Text>
<Text className='admin-row-meta break-line'> {item.sourceType || '-'} · {item.sourceId || '-'}{item.createdByName ? ` · 操作人 ${item.createdByName}` : ''}</Text>
</View>
))}
</View>
{!userNotifications.length ? <View className='admin-empty'> `notifications:read` </View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-form-grid'>