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;
|
||||
|
||||
@@ -144,6 +144,11 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.admin-row.active {
|
||||
border-color: #93c5fd;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.admin-row-main {
|
||||
display: block;
|
||||
color: #111827;
|
||||
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user