feat: add taro point rewards operations

This commit is contained in:
Codex
2026-06-30 04:20:11 +08:00
parent 0912a4d3fa
commit 52b958a387
10 changed files with 612 additions and 21 deletions

View File

@@ -15,11 +15,18 @@ import {
} from '@/services/learning';
import {
checkIn,
claimActivityTask,
loadBadges,
loadExamCountdowns,
loadActivityTasks,
loadExchangeItems,
loadNotifications,
loadProfile,
loadScoreEvents,
redeemExchangeItem,
updateNotificationStatus,
type PointActivityTask,
type PointExchangeItem,
type StudentProfile,
type UserNotificationItem,
} from '@/services/profile';
@@ -83,6 +90,31 @@ function sessionStatusClass(status?: string | null) {
type ProfileNotificationStatus = NonNullable<UserNotificationItem['status']>;
function taskTypeLabel(type?: string | null) {
if (type === 'daily_check_in') return '每日签到';
if (type === 'feedback_submit') return '提交反馈';
if (type === 'feedback_resolved') return '反馈采纳';
if (type === 'practice_complete') return '完成练习';
if (type === 'vocabulary_review') return '背单词';
if (type === 'mock_exam_submit') return '提交模考';
return '手动领取';
}
function periodLabel(type?: string | null) {
if (type === 'daily') return '每日';
if (type === 'weekly') return '每周';
if (type === 'monthly') return '每月';
if (type === 'unlimited') return '不限次数';
return '一次性';
}
function exchangeTypeLabel(type?: string | null) {
if (type === 'coupon') return '优惠券';
if (type === 'asset') return '资料';
if (type === 'custom') return '自定义';
return '人工发放';
}
function notificationTypeLabel(type?: string | null) {
if (type === 'feedback_status_updated') return '反馈处理';
if (type === 'feedback_reward_granted') return '反馈奖励';
@@ -117,10 +149,14 @@ export default function StudentProfilePage() {
const [learningStats, setLearningStats] = useState<LearningStats | null>(null);
const [learningTrend, setLearningTrend] = useState<LearningTrendItem[]>([]);
const [practiceHistory, setPracticeHistory] = useState<PracticeHistoryItem[]>([]);
const [activityTasks, setActivityTasks] = useState<PointActivityTask[]>([]);
const [exchangeItems, setExchangeItems] = useState<PointExchangeItem[]>([]);
const [scoreEvents, setScoreEvents] = useState<Record<string, unknown>[]>([]);
const [notifications, setNotifications] = useState<UserNotificationItem[]>([]);
const [notificationSummary, setNotificationSummary] = useState<Record<string, number>>({});
const [notificationFilter, setNotificationFilter] = useState<'' | ProfileNotificationStatus>('unread');
const [notificationBusy, setNotificationBusy] = useState('');
const [pointsBusy, setPointsBusy] = useState('');
const [activationCode, setActivationCode] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
@@ -136,6 +172,9 @@ export default function StudentProfilePage() {
loadLearningStats(30).catch(() => ({ item: undefined })),
loadLearningTrend(14).catch(() => ({ items: [] })),
loadPracticeHistory({ limit: 6 }).catch(() => ({ items: [] })),
loadActivityTasks({ limit: 8 }).catch(() => ({ items: [] })),
loadExchangeItems(8).catch(() => ({ items: [] })),
loadScoreEvents(8).catch(() => ({ items: [] })),
loadNotifications({ status: nextNotificationFilter || undefined, limit: 8 }).catch(() => ({ items: [], summary: {} })),
loadEntitlements().catch(() => ({ summary: {} })),
]).then(([
@@ -148,6 +187,9 @@ export default function StudentProfilePage() {
statsPayload,
trendPayload,
historyPayload,
activityTaskPayload,
exchangeItemPayload,
scoreEventPayload,
notificationPayload,
]) => {
setProfile(profilePayload.item || null);
@@ -160,6 +202,9 @@ export default function StudentProfilePage() {
setLearningStats(statsPayload.item || null);
setLearningTrend(trendPayload.items || []);
setPracticeHistory(historyPayload.items || []);
setActivityTasks(activityTaskPayload.items || []);
setExchangeItems(exchangeItemPayload.items || []);
setScoreEvents(scoreEventPayload.items || []);
setNotifications(notificationPayload.items || []);
setNotificationSummary(notificationPayload.summary || {});
}).catch(nextError => setError(nextError instanceof Error ? nextError.message : '个人中心加载失败'));
@@ -227,6 +272,42 @@ export default function StudentProfilePage() {
}
}
async function claimTask(item: PointActivityTask) {
setPointsBusy(`task:${item.id}`);
setError('');
try {
const payload = await claimActivityTask({
taskId: item.id,
idempotencyKey: `task-${item.id}-${Date.now()}`,
metadata: { source: 'taro-profile' },
});
setMessage(`已领取 ${String(item.rewardPoints || 0)} 积分,当前积分 ${String(payload.item?.score ?? '')}`);
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '任务领取失败');
} finally {
setPointsBusy('');
}
}
async function redeemItem(item: PointExchangeItem) {
setPointsBusy(`exchange:${item.id}`);
setError('');
try {
const payload = await redeemExchangeItem({
itemId: item.id,
idempotencyKey: `exchange-${item.id}-${Date.now()}`,
metadata: { source: 'taro-profile' },
});
setMessage(`兑换已提交,当前积分 ${String(payload.item?.score ?? '')}`);
reload();
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '积分兑换失败');
} finally {
setPointsBusy('');
}
}
const maxAnswers = maxTrendAnswers(learningTrend);
const answerStats = learningStats?.answers;
const reportStats = learningStats?.reports;
@@ -415,6 +496,88 @@ export default function StudentProfilePage() {
) : <View className='empty-state'></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(profile?.score ?? 0)}</Text><Text className='metric-label'></Text></View>
<View className='metric compact-metric'><Text className='metric-value'>{String(activityTasks.filter(item => (item.remainingClaims ?? 0) > 0).length)}</Text><Text className='metric-label'></Text></View>
</View>
{activityTasks.length ? (
<View className='list-stack points-list'>
{activityTasks.map(item => {
const canClaim = item.taskType === 'manual' && (item.remainingClaims ?? 0) > 0;
return (
<View className='list-row' key={item.id}>
<View className='split-row'>
<Text className='row-main'>{item.title || taskTypeLabel(item.taskType)}</Text>
<Text className={`status-badge ${canClaim ? '' : 'warning'}`}>{canClaim ? '可领取' : item.claimedInCurrentPeriod ? '已领取' : '需完成'}</Text>
</View>
<Text className='row-meta'>{taskTypeLabel(item.taskType)} · {periodLabel(item.periodType)} · {String(item.rewardPoints || 0)} </Text>
{item.description ? <Text className='row-meta notification-message'>{item.description}</Text> : null}
<View className='toolbar compact-toolbar'>
{canClaim ? (
<Button className='primary-button' loading={pointsBusy === `task:${item.id}`} onClick={() => void claimTask(item)}></Button>
) : (
<Text className='row-meta notification-action-text'>{item.taskType === 'manual' ? '本周期已达领取上限' : '完成对应学习动作后由后端校验'}</Text>
)}
</View>
</View>
);
})}
</View>
) : <View className='empty-state'></View>}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
{exchangeItems.length ? (
<View className='list-stack'>
{exchangeItems.map(item => {
const stockLeft = item.stockRemaining;
const outOfStock = stockLeft !== null && stockLeft !== undefined && stockLeft <= 0;
const notEnoughScore = Number(profile?.score || 0) < Number(item.costPoints || 0);
return (
<View className='list-row' key={item.id}>
<View className='split-row'>
<Text className='row-main'>{item.title || item.code || '兑换商品'}</Text>
<Text className={`status-badge ${outOfStock || notEnoughScore ? 'warning' : ''}`}>{String(item.costPoints || 0)} </Text>
</View>
<Text className='row-meta'>{exchangeTypeLabel(item.itemType)} · {stockLeft === null || stockLeft === undefined ? '不限' : String(stockLeft)} · {String(item.perUserLimit || 1)} </Text>
{item.description ? <Text className='row-meta notification-message'>{item.description}</Text> : null}
<View className='toolbar compact-toolbar'>
<Button
className={outOfStock || notEnoughScore ? 'secondary-button' : 'primary-button'}
disabled={outOfStock || notEnoughScore}
loading={pointsBusy === `exchange:${item.id}`}
onClick={() => void redeemItem(item)}
>
{outOfStock ? '库存不足' : notEnoughScore ? '积分不足' : '立即兑换'}
</Button>
</View>
</View>
);
})}
</View>
) : <View className='empty-state'></View>}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
{scoreEvents.length ? (
<View className='list-stack'>
{scoreEvents.map((item, index) => (
<View className='list-row' key={String(item.id || index)}>
<View className='split-row'>
<Text className='row-main'>{String(item.eventType || item.type || '积分变动')}</Text>
<Text className={`status-badge ${Number(item.points || 0) < 0 ? 'warning' : ''}`}>{Number(item.points || 0) > 0 ? '+' : ''}{String(item.points || 0)}</Text>
</View>
<Text className='row-meta'> {String(item.balanceAfter ?? '-')} · {dateText(String(item.createdAt || ''))}</Text>
</View>
))}
</View>
) : <View className='empty-state'></View>}
</View>
<View className='section-block'>
<Text className='section-heading'></Text>
<View className='list-stack'>

View File

@@ -17,6 +17,10 @@ import {
loadCoupons,
loadCrmConfig,
loadCrmQueue,
loadPointActivityClaims,
loadPointActivityTasks,
loadPointExchangeItems,
loadPointExchangeOrders,
loadTenantMembers,
loadUserNotifications,
updateCommissionSettings,
@@ -25,6 +29,8 @@ import {
updateMemberCommissionRate,
upsertCoupon,
upsertCrmConfig,
upsertPointActivityTask,
upsertPointExchangeItem,
type CodeBatchItem,
type CommissionOrderItem,
type CommissionSettlementProofItem,
@@ -36,6 +42,8 @@ import {
type CouponReport,
type CrmConfigItem,
type CrmQueueItem,
type PointActivityTaskItem,
type PointExchangeItem,
type TenantMemberItem,
type UserNotificationAdminItem,
} from '@/services/tenantAdmin';
@@ -81,6 +89,36 @@ function rateToPercent(value: unknown) {
return String((Number(value || 0) * 100).toFixed(2)).replace(/\.00$/, '');
}
function intText(value: unknown, fallback = '') {
if (value === undefined || value === null || value === '') return fallback;
return String(value);
}
function taskTypeLabel(type?: string | null) {
if (type === 'daily_check_in') return '每日签到';
if (type === 'feedback_submit') return '提交反馈';
if (type === 'feedback_resolved') return '反馈采纳';
if (type === 'practice_complete') return '完成练习';
if (type === 'vocabulary_review') return '背单词';
if (type === 'mock_exam_submit') return '提交模考';
return '手动领取';
}
function periodLabel(type?: string | null) {
if (type === 'daily') return '每日';
if (type === 'weekly') return '每周';
if (type === 'monthly') return '每月';
if (type === 'unlimited') return '不限次数';
return '一次性';
}
function exchangeTypeLabel(type?: string | null) {
if (type === 'coupon') return '优惠券';
if (type === 'asset') return '资料';
if (type === 'custom') return '自定义';
return '人工发放';
}
function notificationTypeLabel(type?: string | null) {
if (type === 'feedback_status_updated') return '反馈处理';
if (type === 'feedback_reward_granted') return '反馈奖励';
@@ -156,6 +194,74 @@ function defaultCouponForm(): CouponFormState {
};
}
interface PointTaskFormState {
id: string;
code: string;
title: string;
description: string;
taskType: NonNullable<PointActivityTaskItem['taskType']>;
rewardPoints: string;
claimLimitPerUser: string;
periodType: NonNullable<PointActivityTaskItem['periodType']>;
status: NonNullable<PointActivityTaskItem['status']>;
validFrom: string;
validTo: string;
order: string;
}
function defaultPointTaskForm(): PointTaskFormState {
return {
id: '',
code: '',
title: '',
description: '',
taskType: 'manual',
rewardPoints: '10',
claimLimitPerUser: '1',
periodType: 'once',
status: 'active',
validFrom: '',
validTo: '',
order: '0',
};
}
interface PointExchangeFormState {
id: string;
code: string;
title: string;
description: string;
costPoints: string;
itemType: NonNullable<PointExchangeItem['itemType']>;
couponId: string;
assetId: string;
stockTotal: string;
perUserLimit: string;
status: NonNullable<PointExchangeItem['status']>;
validFrom: string;
validTo: string;
order: string;
}
function defaultPointExchangeForm(): PointExchangeFormState {
return {
id: '',
code: '',
title: '',
description: '',
costPoints: '100',
itemType: 'manual',
couponId: '',
assetId: '',
stockTotal: '',
perUserLimit: '1',
status: 'active',
validFrom: '',
validTo: '',
order: '0',
};
}
export default function TenantMarketingPage() {
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [couponReport, setCouponReport] = useState<CouponReport | null>(null);
@@ -172,6 +278,10 @@ export default function TenantMarketingPage() {
const [members, setMembers] = useState<TenantMemberItem[]>([]);
const [userNotifications, setUserNotifications] = useState<UserNotificationAdminItem[]>([]);
const [userNotificationSummary, setUserNotificationSummary] = useState<Record<string, number>>({});
const [pointTasks, setPointTasks] = useState<PointActivityTaskItem[]>([]);
const [pointClaims, setPointClaims] = useState<Record<string, unknown>[]>([]);
const [pointExchangeItems, setPointExchangeItems] = useState<PointExchangeItem[]>([]);
const [pointExchangeOrders, setPointExchangeOrders] = useState<Record<string, unknown>[]>([]);
const [crmForm, setCrmForm] = useState({
enabled: false,
url: '',
@@ -184,6 +294,8 @@ export default function TenantMarketingPage() {
assignmentPoolText: '',
});
const [couponForm, setCouponForm] = useState<CouponFormState>(() => defaultCouponForm());
const [pointTaskForm, setPointTaskForm] = useState<PointTaskFormState>(() => defaultPointTaskForm());
const [pointExchangeForm, setPointExchangeForm] = useState<PointExchangeFormState>(() => defaultPointExchangeForm());
const [couponFilter, setCouponFilter] = useState({
status: '',
campaignName: '',
@@ -202,6 +314,14 @@ export default function TenantMarketingPage() {
notificationType: '',
userId: '',
});
const [pointFilter, setPointFilter] = useState({
taskStatus: 'active' as '' | NonNullable<PointActivityTaskItem['status']>,
selectedTaskId: '',
exchangeStatus: 'active' as '' | NonNullable<PointExchangeItem['status']>,
selectedExchangeItemId: '',
exchangeOrderStatus: 'pending_fulfillment' as '' | 'completed' | 'pending_fulfillment' | 'cancelled',
userId: '',
});
const [crmStatus, setCrmStatus] = useState('');
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
@@ -228,6 +348,10 @@ export default function TenantMarketingPage() {
settlementPayload,
memberPayload,
notificationPayload,
pointTaskPayload,
pointClaimPayload,
pointExchangePayload,
pointExchangeOrderPayload,
] = await Promise.all([
loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })),
loadCouponReport({
@@ -251,6 +375,15 @@ export default function TenantMarketingPage() {
loadCommissionSettlements({ limit: 20 }).catch(() => ({ items: [] })),
loadTenantMembers({ limit: 100 }).catch(() => ({ items: [] })),
loadUserNotifications({ status: userNotificationFilter.status || undefined, limit: 30 }).catch(() => ({ items: [], summary: {} })),
loadPointActivityTasks({ status: pointFilter.taskStatus || undefined, limit: 50 }).catch(() => ({ items: [] })),
loadPointActivityClaims({ taskId: pointFilter.selectedTaskId || undefined, userId: pointFilter.userId || undefined, limit: 30 }).catch(() => ({ items: [] })),
loadPointExchangeItems({ status: pointFilter.exchangeStatus || undefined, limit: 50 }).catch(() => ({ items: [] })),
loadPointExchangeOrders({
itemId: pointFilter.selectedExchangeItemId || undefined,
userId: pointFilter.userId || undefined,
status: pointFilter.exchangeOrderStatus || undefined,
limit: 30,
}).catch(() => ({ items: [] })),
]);
const nextCrm = crmConfigPayload.item || null;
const nextSettings = commissionSettingsPayload.item || null;
@@ -268,6 +401,10 @@ export default function TenantMarketingPage() {
setMembers(memberPayload.items || []);
setUserNotifications(notificationPayload.items || []);
setUserNotificationSummary(notificationPayload.summary || {});
setPointTasks(pointTaskPayload.items || []);
setPointClaims(pointClaimPayload.items || []);
setPointExchangeItems(pointExchangePayload.items || []);
setPointExchangeOrders(pointExchangeOrderPayload.items || []);
if (nextCrm) {
setCrmForm({
enabled: nextCrm.enabled === true,
@@ -349,6 +486,44 @@ export default function TenantMarketingPage() {
setCouponFilter(prev => ({ ...prev, selectedCouponId: item.id, campaignName: item.campaignName || prev.campaignName }));
}
function editPointTask(item: PointActivityTaskItem) {
setPointTaskForm({
id: item.id,
code: item.code || '',
title: item.title || '',
description: item.description || '',
taskType: item.taskType || 'manual',
rewardPoints: intText(item.rewardPoints, '10'),
claimLimitPerUser: intText(item.claimLimitPerUser, '1'),
periodType: item.periodType || 'once',
status: item.status || 'active',
validFrom: item.validFrom ? item.validFrom.slice(0, 10) : '',
validTo: item.validTo ? item.validTo.slice(0, 10) : '',
order: intText(item.order, '0'),
});
setPointFilter(prev => ({ ...prev, selectedTaskId: item.id }));
}
function editPointExchangeItem(item: PointExchangeItem) {
setPointExchangeForm({
id: item.id,
code: item.code || '',
title: item.title || '',
description: item.description || '',
costPoints: intText(item.costPoints, '100'),
itemType: item.itemType || 'manual',
couponId: item.couponId || '',
assetId: item.assetId || '',
stockTotal: item.stockTotal === null || item.stockTotal === undefined ? '' : String(item.stockTotal),
perUserLimit: intText(item.perUserLimit, '1'),
status: item.status || 'active',
validFrom: item.validFrom ? item.validFrom.slice(0, 10) : '',
validTo: item.validTo ? item.validTo.slice(0, 10) : '',
order: intText(item.order, '0'),
});
setPointFilter(prev => ({ ...prev, selectedExchangeItemId: item.id }));
}
async function saveCoupon() {
if (!couponForm.code.trim()) {
Taro.showToast({ title: '请填写优惠券码', icon: 'none' });
@@ -397,6 +572,106 @@ export default function TenantMarketingPage() {
}
}
async function refreshPoints(override: Partial<typeof pointFilter> = {}) {
const nextFilter = { ...pointFilter, ...override };
setPointFilter(nextFilter);
setBusy('points');
setError('');
try {
const [taskPayload, claimPayload, exchangePayload, orderPayload] = await Promise.all([
loadPointActivityTasks({ status: nextFilter.taskStatus || undefined, limit: 100 }),
loadPointActivityClaims({
taskId: nextFilter.selectedTaskId || undefined,
userId: nextFilter.userId.trim() || undefined,
limit: 50,
}).catch(() => ({ items: [] })),
loadPointExchangeItems({ status: nextFilter.exchangeStatus || undefined, limit: 100 }),
loadPointExchangeOrders({
itemId: nextFilter.selectedExchangeItemId || undefined,
userId: nextFilter.userId.trim() || undefined,
status: nextFilter.exchangeOrderStatus || undefined,
limit: 50,
}).catch(() => ({ items: [] })),
]);
setPointTasks(taskPayload.items || []);
setPointClaims(claimPayload.items || []);
setPointExchangeItems(exchangePayload.items || []);
setPointExchangeOrders(orderPayload.items || []);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '积分运营数据加载失败');
} finally {
setBusy('');
}
}
async function savePointTask() {
if (!pointTaskForm.code.trim() || !pointTaskForm.title.trim()) {
Taro.showToast({ title: '请填写任务编码和名称', icon: 'none' });
return;
}
setBusy('point-task-save');
setError('');
try {
const result = await upsertPointActivityTask({
id: pointTaskForm.id || undefined,
code: pointTaskForm.code.trim(),
title: pointTaskForm.title.trim(),
description: pointTaskForm.description.trim() || null,
taskType: pointTaskForm.taskType,
rewardPoints: Math.max(1, Math.trunc(Number(pointTaskForm.rewardPoints || 1))),
claimLimitPerUser: Math.max(1, Math.trunc(Number(pointTaskForm.claimLimitPerUser || 1))),
periodType: pointTaskForm.periodType,
status: pointTaskForm.status,
validFrom: pointTaskForm.validFrom || null,
validTo: pointTaskForm.validTo || null,
order: Math.trunc(Number(pointTaskForm.order || 0)),
metadata: { source: 'taro-tenant-admin' },
});
Taro.showToast({ title: '积分任务已保存', icon: 'success' });
if (result.item?.id) setPointFilter(prev => ({ ...prev, selectedTaskId: result.item?.id || prev.selectedTaskId }));
await refreshPoints({ selectedTaskId: result.item?.id || pointFilter.selectedTaskId });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '积分任务保存失败');
} finally {
setBusy('');
}
}
async function savePointExchange() {
if (!pointExchangeForm.code.trim() || !pointExchangeForm.title.trim()) {
Taro.showToast({ title: '请填写兑换编码和名称', icon: 'none' });
return;
}
setBusy('point-exchange-save');
setError('');
try {
const result = await upsertPointExchangeItem({
id: pointExchangeForm.id || undefined,
code: pointExchangeForm.code.trim(),
title: pointExchangeForm.title.trim(),
description: pointExchangeForm.description.trim() || null,
costPoints: Math.max(1, Math.trunc(Number(pointExchangeForm.costPoints || 1))),
itemType: pointExchangeForm.itemType,
couponId: pointExchangeForm.couponId.trim() || null,
assetId: pointExchangeForm.assetId.trim() || null,
stockTotal: pointExchangeForm.stockTotal.trim() ? Math.max(0, Math.trunc(Number(pointExchangeForm.stockTotal || 0))) : null,
perUserLimit: Math.max(1, Math.trunc(Number(pointExchangeForm.perUserLimit || 1))),
status: pointExchangeForm.status,
validFrom: pointExchangeForm.validFrom || null,
validTo: pointExchangeForm.validTo || null,
order: Math.trunc(Number(pointExchangeForm.order || 0)),
metadata: { source: 'taro-tenant-admin' },
});
Taro.showToast({ title: '兑换商品已保存', icon: 'success' });
if (result.item?.id) setPointFilter(prev => ({ ...prev, selectedExchangeItemId: result.item?.id || prev.selectedExchangeItemId }));
await refreshPoints({ selectedExchangeItemId: result.item?.id || pointFilter.selectedExchangeItemId });
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '兑换商品保存失败');
} finally {
setBusy('');
}
}
async function saveCrmConfig() {
setBusy('crm');
setError('');
@@ -660,6 +935,144 @@ 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(pointTasks.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointClaims.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointExchangeItems.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointExchangeOrders.length)}</Text></View>
</View>
<View className='admin-form-grid'>
<Input
className='admin-input'
placeholder='按学生用户 ID 查看领取/兑换记录,可留空'
value={pointFilter.userId}
onInput={event => setPointFilter(prev => ({ ...prev, userId: String(event.detail.value || '') }))}
/>
</View>
<View className='admin-actions compact'>
{([
['', '全部任务'],
['active', '启用任务'],
['disabled', '停用任务'],
['archived', '归档任务'],
] as Array<[typeof pointFilter.taskStatus, string]>).map(([status, label]) => (
<Button key={status || 'all-tasks'} className={`admin-button ${pointFilter.taskStatus === status ? 'active' : ''}`} onClick={() => void refreshPoints({ taskStatus: status })}>{label}</Button>
))}
{([
['', '全部商品'],
['active', '启用商品'],
['disabled', '停用商品'],
['archived', '归档商品'],
] as Array<[typeof pointFilter.exchangeStatus, string]>).map(([status, label]) => (
<Button key={status || 'all-exchange-items'} className={`admin-button ${pointFilter.exchangeStatus === status ? 'active' : ''}`} onClick={() => void refreshPoints({ exchangeStatus: status })}>{label}</Button>
))}
<Button className='admin-button primary' loading={busy === 'points'} onClick={() => void refreshPoints()}></Button>
</View>
<View className='admin-form-grid'>
<Input className='admin-input' placeholder='任务编码,例如 daily-share' value={pointTaskForm.code} onInput={event => setPointTaskForm(prev => ({ ...prev, code: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='任务名称' value={pointTaskForm.title} onInput={event => setPointTaskForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='任务说明' value={pointTaskForm.description} onInput={event => setPointTaskForm(prev => ({ ...prev, description: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='奖励积分' value={pointTaskForm.rewardPoints} onInput={event => setPointTaskForm(prev => ({ ...prev, rewardPoints: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='单用户领取上限' value={pointTaskForm.claimLimitPerUser} onInput={event => setPointTaskForm(prev => ({ ...prev, claimLimitPerUser: String(event.detail.value || '') }))} />
</View>
<View className='admin-actions compact'>
{(['manual', 'practice_complete', 'vocabulary_review', 'mock_exam_submit', 'daily_check_in', 'feedback_submit', 'feedback_resolved'] as const).map(type => (
<Button key={type} className={`admin-button ${pointTaskForm.taskType === type ? 'active' : ''}`} onClick={() => setPointTaskForm(prev => ({ ...prev, taskType: type }))}>{taskTypeLabel(type)}</Button>
))}
</View>
<View className='admin-actions compact'>
{(['once', 'daily', 'weekly', 'monthly', 'unlimited'] as const).map(type => (
<Button key={type} className={`admin-button ${pointTaskForm.periodType === type ? 'active' : ''}`} onClick={() => setPointTaskForm(prev => ({ ...prev, periodType: type }))}>{periodLabel(type)}</Button>
))}
{(['active', 'disabled', 'archived'] as const).map(status => (
<Button key={status} className={`admin-button ${pointTaskForm.status === status ? 'active' : ''}`} onClick={() => setPointTaskForm(prev => ({ ...prev, status }))}>{status}</Button>
))}
<Button className='admin-button primary' loading={busy === 'point-task-save'} onClick={savePointTask}></Button>
<Button className='admin-button' onClick={() => setPointTaskForm(defaultPointTaskForm())}></Button>
</View>
<View className='admin-list'>
{pointTasks.slice(0, 8).map(item => (
<View className={`admin-row ${pointFilter.selectedTaskId === item.id ? 'active' : ''}`} key={item.id}>
<Text className='admin-row-main'>{item.title || item.code} · {String(item.rewardPoints || 0)} </Text>
<Text className='admin-row-meta'>{taskTypeLabel(item.taskType)} · {periodLabel(item.periodType)} · {item.status || 'active'} · {String(item.claimCount || 0)} / {String(item.claimUserCount || 0)} </Text>
{item.description ? <Text className='admin-row-meta'>{item.description}</Text> : null}
<View className='admin-row-actions'>
<Button className='admin-mini-button primary' onClick={() => editPointTask(item)}></Button>
<Button className='admin-mini-button' onClick={() => void refreshPoints({ selectedTaskId: item.id })}></Button>
</View>
</View>
))}
</View>
{!pointTasks.length ? <View className='admin-empty'> `marketing:points:read` </View> : null}
<View className='admin-form-grid'>
<Input className='admin-input' placeholder='兑换编码,例如 coupon-100' value={pointExchangeForm.code} onInput={event => setPointExchangeForm(prev => ({ ...prev, code: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='兑换名称' value={pointExchangeForm.title} onInput={event => setPointExchangeForm(prev => ({ ...prev, title: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='兑换说明' value={pointExchangeForm.description} onInput={event => setPointExchangeForm(prev => ({ ...prev, description: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='消耗积分' value={pointExchangeForm.costPoints} onInput={event => setPointExchangeForm(prev => ({ ...prev, costPoints: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='优惠券 ID类型为优惠券时必填' value={pointExchangeForm.couponId} onInput={event => setPointExchangeForm(prev => ({ ...prev, couponId: String(event.detail.value || '') }))} />
<Input className='admin-input' placeholder='资源 ID类型为资料时必填' value={pointExchangeForm.assetId} onInput={event => setPointExchangeForm(prev => ({ ...prev, assetId: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='总库存,留空不限' value={pointExchangeForm.stockTotal} onInput={event => setPointExchangeForm(prev => ({ ...prev, stockTotal: String(event.detail.value || '') }))} />
<Input className='admin-input' type='number' placeholder='单用户限购次数' value={pointExchangeForm.perUserLimit} onInput={event => setPointExchangeForm(prev => ({ ...prev, perUserLimit: String(event.detail.value || '') }))} />
</View>
<View className='admin-actions compact'>
{(['manual', 'coupon', 'asset', 'custom'] as const).map(type => (
<Button key={type} className={`admin-button ${pointExchangeForm.itemType === type ? 'active' : ''}`} onClick={() => setPointExchangeForm(prev => ({ ...prev, itemType: type }))}>{exchangeTypeLabel(type)}</Button>
))}
{(['active', 'disabled', 'archived'] as const).map(status => (
<Button key={status} className={`admin-button ${pointExchangeForm.status === status ? 'active' : ''}`} onClick={() => setPointExchangeForm(prev => ({ ...prev, status }))}>{status}</Button>
))}
<Button className='admin-button primary' loading={busy === 'point-exchange-save'} onClick={savePointExchange}></Button>
<Button className='admin-button' onClick={() => setPointExchangeForm(defaultPointExchangeForm())}></Button>
</View>
<View className='admin-list'>
{pointExchangeItems.slice(0, 8).map(item => (
<View className={`admin-row ${pointFilter.selectedExchangeItemId === item.id ? 'active' : ''}`} key={item.id}>
<Text className='admin-row-main'>{item.title || item.code} · {String(item.costPoints || 0)} </Text>
<Text className='admin-row-meta'>{exchangeTypeLabel(item.itemType)} · {item.status || 'active'} · {item.stockRemaining === null || item.stockRemaining === undefined ? '不限' : String(item.stockRemaining)} · {String(item.orderCount || 0)}</Text>
{item.description ? <Text className='admin-row-meta'>{item.description}</Text> : null}
<View className='admin-row-actions'>
<Button className='admin-mini-button primary' onClick={() => editPointExchangeItem(item)}></Button>
<Button className='admin-mini-button' onClick={() => void refreshPoints({ selectedExchangeItemId: item.id })}></Button>
</View>
</View>
))}
</View>
{!pointExchangeItems.length ? <View className='admin-empty'> `marketing:points:read` </View> : null}
<View className='admin-actions compact'>
{(['', 'completed', 'pending_fulfillment', 'cancelled'] as const).map(status => (
<Button key={status || 'all-orders'} className={`admin-button ${pointFilter.exchangeOrderStatus === status ? 'active' : ''}`} onClick={() => void refreshPoints({ exchangeOrderStatus: status })}>{status || '全部兑换单'}</Button>
))}
</View>
<View className='admin-list'>
{pointExchangeOrders.slice(0, 10).map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.itemTitle || item.itemCode || '兑换订单')} · {String(item.status || '-')}</Text>
<Text className='admin-row-meta'>{String(item.userName || item.userPhone || item.userId || '学生')} · {String(item.costPoints || 0)} · {shortDate(String(item.exchangedAt || item.createdAt || ''))}</Text>
<Text className='admin-row-meta break-line'> {String(item.couponCode || item.couponRedemptionId || '-')} · {String(item.assetId || '-')}</Text>
</View>
))}
</View>
{!pointExchangeOrders.length ? <View className='admin-empty'></View> : null}
<View className='admin-list'>
{pointClaims.slice(0, 8).map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.taskTitle || item.taskCode || '积分任务')} · {String(item.rewardPoints || 0)} </Text>
<Text className='admin-row-meta'>{String(item.userName || item.userPhone || item.userId || '学生')} · {String(item.periodKey || '-')} · {shortDate(String(item.claimedAt || item.createdAt || ''))}</Text>
<Text className='admin-row-meta break-line'> {String(item.sourceType || '-')} · {String(item.sourceId || '-')}</Text>
</View>
))}
</View>
{!pointClaims.length ? <View className='admin-empty'></View> : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-grid'>