forked from wangziqi/gongxue-base
feat: add taro point rewards operations
This commit is contained in:
@@ -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'>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
Reference in New Issue
Block a user