forked from wangziqi/gongxue-base
feat: configure daily check-in rewards
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
@@ -33,6 +34,56 @@ interface ProfileRow {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface CheckInProfileRow {
|
||||
profileId: string;
|
||||
lastCheckInDate: string | null;
|
||||
score: number;
|
||||
stats: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface CheckInRewardTaskRow {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
rewardPoints: number;
|
||||
claimLimitPerUser: number;
|
||||
periodType: string;
|
||||
metadata: JsonMap;
|
||||
}
|
||||
|
||||
interface CheckInRewardGrant {
|
||||
taskId: string;
|
||||
taskCode: string;
|
||||
title: string;
|
||||
points: number;
|
||||
periodKey: string;
|
||||
ledgerId: string;
|
||||
claimId: string | null;
|
||||
rule: JsonMap;
|
||||
}
|
||||
|
||||
interface ScoreLedgerRow {
|
||||
id: string;
|
||||
eventType: string;
|
||||
points: number;
|
||||
balanceAfter: number;
|
||||
sourceType: string | null;
|
||||
sourceId?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PointActivityClaimRow {
|
||||
id: string;
|
||||
taskId: string;
|
||||
periodKey: string;
|
||||
scoreEventId: string | null;
|
||||
sourceType: string | null;
|
||||
sourceId: string | null;
|
||||
status: string;
|
||||
metadata: JsonMap;
|
||||
claimedAt: string;
|
||||
}
|
||||
|
||||
function jsonBodyValue(value: unknown) {
|
||||
return JSON.stringify(value && typeof value === 'object' ? value : {});
|
||||
}
|
||||
@@ -59,7 +110,7 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
|
||||
function toDateOnly(value: unknown) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return shanghaiDateKey(value);
|
||||
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10);
|
||||
return null;
|
||||
}
|
||||
@@ -90,6 +141,242 @@ function avatarDisplayUrl(preset: AvatarPreset) {
|
||||
return `/assets/avatars/default-${preset}.svg`;
|
||||
}
|
||||
|
||||
function shanghaiDateParts(date = new Date()) {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])) as {
|
||||
year: string;
|
||||
month: string;
|
||||
day: string;
|
||||
};
|
||||
}
|
||||
|
||||
function shanghaiDateKey(date = new Date()) {
|
||||
const parts = shanghaiDateParts(date);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
function shanghaiWeekKey(date = new Date()) {
|
||||
const current = new Date(`${shanghaiDateKey(date)}T00:00:00.000Z`);
|
||||
const day = current.getUTCDay() || 7;
|
||||
current.setUTCDate(current.getUTCDate() + 4 - day);
|
||||
const yearStart = new Date(Date.UTC(current.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil((((current.getTime() - yearStart.getTime()) / 86_400_000) + 1) / 7);
|
||||
return `${current.getUTCFullYear()}-W${String(week).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function checkInTaskPeriodKey(task: CheckInRewardTaskRow, today: string, now = new Date()) {
|
||||
if (task.periodType === 'daily') return today;
|
||||
if (task.periodType === 'weekly') return shanghaiWeekKey(now);
|
||||
if (task.periodType === 'monthly') {
|
||||
const parts = shanghaiDateParts(now);
|
||||
return `${parts.year}-${parts.month}`;
|
||||
}
|
||||
if (task.periodType === 'once') return `once:${task.id}`;
|
||||
return `check_in:${today}`;
|
||||
}
|
||||
|
||||
function metadataNumber(metadata: JsonMap, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
if (!Object.hasOwn(metadata, key)) continue;
|
||||
const parsed = Number(metadata[key]);
|
||||
if (Number.isFinite(parsed)) return Math.trunc(parsed);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function checkInRuleFromMetadata(metadata: JsonMap) {
|
||||
return {
|
||||
requiredStreak: metadataNumber(metadata, ['requiredStreak', 'required_streak', 'streakDay', 'streak_day']),
|
||||
minStreak: metadataNumber(metadata, ['minStreak', 'min_streak']),
|
||||
maxStreak: metadataNumber(metadata, ['maxStreak', 'max_streak']),
|
||||
streakMultipleOf: metadataNumber(metadata, ['streakMultipleOf', 'streak_multiple_of']),
|
||||
};
|
||||
}
|
||||
|
||||
function checkInTaskMatches(task: CheckInRewardTaskRow, streak: number) {
|
||||
const metadata = objectValue(task.metadata);
|
||||
const rule = checkInRuleFromMetadata(metadata);
|
||||
if (rule.requiredStreak !== null && streak !== rule.requiredStreak) return false;
|
||||
if (rule.minStreak !== null && streak < rule.minStreak) return false;
|
||||
if (rule.maxStreak !== null && streak > rule.maxStreak) return false;
|
||||
if (rule.streakMultipleOf !== null && (rule.streakMultipleOf <= 0 || streak % rule.streakMultipleOf !== 0)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadCheckInRewardTasks(client: pg.PoolClient, tenantId: string) {
|
||||
const result = await client.query<CheckInRewardTaskRow>(
|
||||
`
|
||||
select id, code::text, title, reward_points as "rewardPoints",
|
||||
claim_limit_per_user as "claimLimitPerUser",
|
||||
period_type as "periodType", metadata
|
||||
from public.point_activity_tasks
|
||||
where tenant_id = $1
|
||||
and task_type = 'daily_check_in'
|
||||
and status = 'active'
|
||||
and (valid_from is null or valid_from <= now())
|
||||
and (valid_to is null or valid_to >= now())
|
||||
order by sort_order asc, created_at asc
|
||||
limit 50
|
||||
for update
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function grantConfiguredCheckInRewards(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
profileId: string;
|
||||
today: string;
|
||||
now: Date;
|
||||
streak: number;
|
||||
scoreBefore: number;
|
||||
tasks: CheckInRewardTaskRow[];
|
||||
},
|
||||
) {
|
||||
const rewardBreakdown: CheckInRewardGrant[] = [];
|
||||
const ledgers: ScoreLedgerRow[] = [];
|
||||
const claims: PointActivityClaimRow[] = [];
|
||||
let pointsAdded = 0;
|
||||
let scoreCursor = input.scoreBefore;
|
||||
|
||||
for (const task of input.tasks) {
|
||||
if (!checkInTaskMatches(task, input.streak)) continue;
|
||||
const periodKey = checkInTaskPeriodKey(task, input.today, input.now);
|
||||
const claimCounts = await client.query<{ totalCount: string; periodCount: string }>(
|
||||
`
|
||||
select count(*)::text as "totalCount",
|
||||
count(*) filter (where period_key = $4)::text as "periodCount"
|
||||
from public.user_point_activity_claims
|
||||
where tenant_id = $1 and user_id = $2 and task_id = $3 and status = 'claimed'
|
||||
`,
|
||||
[input.tenantId, input.userId, task.id, periodKey],
|
||||
);
|
||||
const periodic = ['daily', 'weekly', 'monthly'].includes(task.periodType);
|
||||
const currentLimitCount = Number(periodic ? claimCounts.rows[0]?.periodCount || 0 : claimCounts.rows[0]?.totalCount || 0);
|
||||
const effectiveLimit = periodic ? 1 : Math.max(1, Number(task.claimLimitPerUser || 1));
|
||||
if (currentLimitCount >= effectiveLimit) continue;
|
||||
|
||||
const points = Number(task.rewardPoints || 0);
|
||||
scoreCursor += points;
|
||||
const metadata = objectValue(task.metadata);
|
||||
const rule = checkInRuleFromMetadata(metadata) as JsonMap;
|
||||
const ledger = await client.query<{
|
||||
id: string;
|
||||
eventType: string;
|
||||
points: number;
|
||||
balanceAfter: number;
|
||||
sourceType: string | null;
|
||||
sourceId: string | null;
|
||||
createdAt: string;
|
||||
}>(
|
||||
`
|
||||
insert into public.user_score_events (
|
||||
tenant_id, user_id, event_type, points, balance_after,
|
||||
source_type, source_id, idempotency_key, metadata
|
||||
)
|
||||
values ($1, $2, 'check_in', $3, $4, 'point_activity_tasks', $5, $6, $7::jsonb)
|
||||
on conflict (tenant_id, idempotency_key) do nothing
|
||||
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
|
||||
source_type as "sourceType", source_id as "sourceId", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId,
|
||||
points,
|
||||
scoreCursor,
|
||||
task.id,
|
||||
`check_in:${task.id}:user:${input.userId}:period:${periodKey}`,
|
||||
JSON.stringify({
|
||||
taskId: task.id,
|
||||
taskCode: task.code,
|
||||
taskType: 'daily_check_in',
|
||||
checkInDate: input.today,
|
||||
streak: input.streak,
|
||||
periodKey,
|
||||
rule,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
if (!ledger.rows[0]) {
|
||||
scoreCursor -= points;
|
||||
continue;
|
||||
}
|
||||
|
||||
const claim = await client.query<PointActivityClaimRow>(
|
||||
`
|
||||
insert into public.user_point_activity_claims (
|
||||
tenant_id, user_id, task_id, period_key, score_event_id,
|
||||
source_type, source_id, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'student_profiles', $6, $7::jsonb)
|
||||
on conflict (tenant_id, user_id, task_id, period_key) do nothing
|
||||
returning id, task_id as "taskId", period_key as "periodKey",
|
||||
score_event_id as "scoreEventId", source_type as "sourceType",
|
||||
source_id as "sourceId", status, metadata, claimed_at as "claimedAt"
|
||||
`,
|
||||
[
|
||||
input.tenantId,
|
||||
input.userId,
|
||||
task.id,
|
||||
periodKey,
|
||||
ledger.rows[0].id,
|
||||
input.profileId,
|
||||
JSON.stringify({
|
||||
source: 'check_in',
|
||||
checkInDate: input.today,
|
||||
streak: input.streak,
|
||||
taskCode: task.code,
|
||||
rule,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
if (!claim.rows[0]) {
|
||||
await client.query(
|
||||
`
|
||||
delete from public.user_score_events
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[input.tenantId, ledger.rows[0].id],
|
||||
);
|
||||
scoreCursor -= points;
|
||||
continue;
|
||||
}
|
||||
|
||||
pointsAdded += points;
|
||||
ledgers.push(ledger.rows[0]);
|
||||
claims.push(claim.rows[0]);
|
||||
rewardBreakdown.push({
|
||||
taskId: task.id,
|
||||
taskCode: task.code,
|
||||
title: task.title,
|
||||
points,
|
||||
periodKey,
|
||||
ledgerId: ledger.rows[0].id,
|
||||
claimId: claim.rows[0]?.id || null,
|
||||
rule,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
pointsAdded,
|
||||
scoreAfter: scoreCursor,
|
||||
rewardBreakdown,
|
||||
ledgers,
|
||||
claims: claims.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export async function profileMeRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
@@ -327,14 +614,11 @@ export async function checkInRoute(ctx: RequestContext) {
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const existing = await client.query<{
|
||||
lastCheckInDate: string | null;
|
||||
score: number;
|
||||
stats: Record<string, unknown>;
|
||||
}>(
|
||||
const now = new Date();
|
||||
const today = shanghaiDateKey(now);
|
||||
const existing = await client.query<CheckInProfileRow>(
|
||||
`
|
||||
select sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats
|
||||
select sp.id as "profileId", sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats
|
||||
from public.student_profiles sp
|
||||
join public.platform_users u on u.id = sp.user_id
|
||||
where sp.tenant_id = $1 and sp.user_id = $2
|
||||
@@ -345,73 +629,114 @@ export async function checkInRoute(ctx: RequestContext) {
|
||||
);
|
||||
const profile = existing.rows[0];
|
||||
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
|
||||
if (profile.lastCheckInDate === today) {
|
||||
if (toDateOnly(profile.lastCheckInDate) === today) {
|
||||
return {
|
||||
checkedIn: false,
|
||||
alreadyCheckedIn: true,
|
||||
pointsAdded: 0,
|
||||
score: Number(profile.score || 0),
|
||||
lastCheckInDate: today,
|
||||
rewardBreakdown: [],
|
||||
};
|
||||
}
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
const yesterdayText = yesterday.toISOString().slice(0, 10);
|
||||
const yesterdayText = shanghaiDateKey(new Date(now.getTime() - 86_400_000));
|
||||
const stats = objectValue(profile.stats);
|
||||
const previousStreak = Number(stats.checkInStreak || 0);
|
||||
const streak = profile.lastCheckInDate === yesterdayText ? previousStreak + 1 : 1;
|
||||
const pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6);
|
||||
const balanceAfter = Number(profile.score || 0) + pointsAdded;
|
||||
const streak = toDateOnly(profile.lastCheckInDate) === yesterdayText ? previousStreak + 1 : 1;
|
||||
const scoreBefore = Number(profile.score || 0);
|
||||
const configuredTasks = await loadCheckInRewardTasks(client, tenantId);
|
||||
const configuredRewards = await grantConfiguredCheckInRewards(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
profileId: profile.profileId,
|
||||
today,
|
||||
now,
|
||||
streak,
|
||||
scoreBefore,
|
||||
tasks: configuredTasks,
|
||||
});
|
||||
let rewardMode = configuredTasks.length ? 'configured_tasks' : 'legacy_formula';
|
||||
let pointsAdded = configuredRewards.pointsAdded;
|
||||
let ledgers = configuredRewards.ledgers;
|
||||
let claims = configuredRewards.claims;
|
||||
let rewardBreakdown: Array<CheckInRewardGrant | Record<string, unknown>> = configuredRewards.rewardBreakdown;
|
||||
let balanceAfter = configuredRewards.scoreAfter;
|
||||
|
||||
if (!configuredTasks.length) {
|
||||
pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6);
|
||||
balanceAfter = scoreBefore + pointsAdded;
|
||||
const ledger = await client.query(
|
||||
`
|
||||
insert into public.user_score_events (
|
||||
tenant_id, user_id, event_type, points, balance_after,
|
||||
source_type, idempotency_key, metadata
|
||||
)
|
||||
values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb)
|
||||
on conflict (tenant_id, idempotency_key) do nothing
|
||||
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
|
||||
source_type as "sourceType", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
pointsAdded,
|
||||
balanceAfter,
|
||||
`check_in:${userId}:${today}`,
|
||||
JSON.stringify({ checkInDate: today, streak, rewardMode }),
|
||||
],
|
||||
);
|
||||
|
||||
if (!ledger.rows[0]) {
|
||||
return {
|
||||
checkedIn: false,
|
||||
alreadyCheckedIn: true,
|
||||
pointsAdded: 0,
|
||||
score: scoreBefore,
|
||||
lastCheckInDate: today,
|
||||
rewardBreakdown: [],
|
||||
};
|
||||
}
|
||||
|
||||
ledgers = ledger.rows;
|
||||
rewardBreakdown = [{
|
||||
rewardType: 'legacy_formula',
|
||||
title: '每日签到',
|
||||
points: pointsAdded,
|
||||
streak,
|
||||
}];
|
||||
}
|
||||
|
||||
const nextStats = {
|
||||
...stats,
|
||||
checkInStreak: streak,
|
||||
lastCheckInPoints: pointsAdded,
|
||||
lastCheckInRewardMode: rewardMode,
|
||||
checkInRewardBreakdown: rewardBreakdown.map(item => ({
|
||||
taskId: 'taskId' in item ? item.taskId : undefined,
|
||||
taskCode: 'taskCode' in item ? item.taskCode : undefined,
|
||||
title: item.title,
|
||||
points: item.points,
|
||||
periodKey: 'periodKey' in item ? item.periodKey : undefined,
|
||||
rewardType: 'rewardType' in item ? item.rewardType : undefined,
|
||||
})),
|
||||
};
|
||||
|
||||
const ledger = await client.query(
|
||||
`
|
||||
insert into public.user_score_events (
|
||||
tenant_id, user_id, event_type, points, balance_after,
|
||||
source_type, idempotency_key, metadata
|
||||
)
|
||||
values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb)
|
||||
on conflict (tenant_id, idempotency_key) do nothing
|
||||
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
|
||||
source_type as "sourceType", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
pointsAdded,
|
||||
balanceAfter,
|
||||
`check_in:${userId}:${today}`,
|
||||
JSON.stringify({ checkInDate: today, streak }),
|
||||
],
|
||||
);
|
||||
|
||||
if (!ledger.rows[0]) {
|
||||
return {
|
||||
checkedIn: false,
|
||||
alreadyCheckedIn: true,
|
||||
pointsAdded: 0,
|
||||
score: Number(profile.score || 0),
|
||||
lastCheckInDate: today,
|
||||
};
|
||||
let score = balanceAfter;
|
||||
if (pointsAdded > 0) {
|
||||
const updatedUser = await client.query<{ score: number }>(
|
||||
`
|
||||
update public.platform_users
|
||||
set score = score + $2,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning score
|
||||
`,
|
||||
[userId, pointsAdded],
|
||||
);
|
||||
score = Number(updatedUser.rows[0]?.score || balanceAfter);
|
||||
}
|
||||
|
||||
const updatedUser = await client.query<{ score: number }>(
|
||||
`
|
||||
update public.platform_users
|
||||
set score = score + $2,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning score
|
||||
`,
|
||||
[userId, pointsAdded],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.student_profiles
|
||||
@@ -423,7 +748,6 @@ export async function checkInRoute(ctx: RequestContext) {
|
||||
[tenantId, userId, today, JSON.stringify(nextStats)],
|
||||
);
|
||||
|
||||
const score = updatedUser.rows[0]?.score || balanceAfter;
|
||||
const autoBadges = [
|
||||
...(await autoGrantBadges(client, {
|
||||
tenantId,
|
||||
@@ -433,21 +757,27 @@ export async function checkInRoute(ctx: RequestContext) {
|
||||
checkInDate: today,
|
||||
checkInStreak: streak,
|
||||
lastCheckInPoints: pointsAdded,
|
||||
rewardBreakdown,
|
||||
rewardMode,
|
||||
score,
|
||||
},
|
||||
})),
|
||||
...(await autoGrantBadges(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
trigger: 'score',
|
||||
evidence: {
|
||||
source: 'check_in',
|
||||
checkInDate: today,
|
||||
checkInStreak: streak,
|
||||
lastCheckInPoints: pointsAdded,
|
||||
score,
|
||||
},
|
||||
})),
|
||||
...(pointsAdded > 0
|
||||
? await autoGrantBadges(client, {
|
||||
tenantId,
|
||||
userId,
|
||||
trigger: 'score',
|
||||
evidence: {
|
||||
source: 'check_in',
|
||||
checkInDate: today,
|
||||
checkInStreak: streak,
|
||||
lastCheckInPoints: pointsAdded,
|
||||
rewardBreakdown,
|
||||
rewardMode,
|
||||
score,
|
||||
},
|
||||
})
|
||||
: []),
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -457,10 +787,11 @@ export async function checkInRoute(ctx: RequestContext) {
|
||||
streak,
|
||||
score,
|
||||
lastCheckInDate: today,
|
||||
ledger: {
|
||||
...ledger.rows[0],
|
||||
balanceAfter: score || ledger.rows[0].balanceAfter,
|
||||
},
|
||||
rewardMode,
|
||||
rewardBreakdown,
|
||||
claims,
|
||||
ledgers,
|
||||
ledger: ledgers[ledgers.length - 1] || null,
|
||||
autoBadges,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -94,6 +94,20 @@ function intText(value: unknown, fallback = '') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function metadataNumberText(metadata: Record<string, unknown> | undefined, key: string) {
|
||||
const value = metadata?.[key];
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? String(Math.trunc(parsed)) : '';
|
||||
}
|
||||
|
||||
function optionalPositiveIntText(value: string) {
|
||||
if (!value.trim()) return undefined;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return Math.max(1, Math.trunc(parsed));
|
||||
}
|
||||
|
||||
function taskTypeLabel(type?: string | null) {
|
||||
if (type === 'daily_check_in') return '每日签到';
|
||||
if (type === 'feedback_submit') return '提交反馈';
|
||||
@@ -207,6 +221,10 @@ interface PointTaskFormState {
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
order: string;
|
||||
requiredStreak: string;
|
||||
minStreak: string;
|
||||
maxStreak: string;
|
||||
streakMultipleOf: string;
|
||||
}
|
||||
|
||||
function defaultPointTaskForm(): PointTaskFormState {
|
||||
@@ -223,6 +241,10 @@ function defaultPointTaskForm(): PointTaskFormState {
|
||||
validFrom: '',
|
||||
validTo: '',
|
||||
order: '0',
|
||||
requiredStreak: '',
|
||||
minStreak: '',
|
||||
maxStreak: '',
|
||||
streakMultipleOf: '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -500,6 +522,10 @@ export default function TenantMarketingPage() {
|
||||
validFrom: item.validFrom ? item.validFrom.slice(0, 10) : '',
|
||||
validTo: item.validTo ? item.validTo.slice(0, 10) : '',
|
||||
order: intText(item.order, '0'),
|
||||
requiredStreak: metadataNumberText(item.metadata, 'requiredStreak'),
|
||||
minStreak: metadataNumberText(item.metadata, 'minStreak'),
|
||||
maxStreak: metadataNumberText(item.metadata, 'maxStreak'),
|
||||
streakMultipleOf: metadataNumberText(item.metadata, 'streakMultipleOf'),
|
||||
});
|
||||
setPointFilter(prev => ({ ...prev, selectedTaskId: item.id }));
|
||||
}
|
||||
@@ -612,6 +638,17 @@ export default function TenantMarketingPage() {
|
||||
setBusy('point-task-save');
|
||||
setError('');
|
||||
try {
|
||||
const metadata: Record<string, unknown> = { source: 'taro-tenant-admin' };
|
||||
if (pointTaskForm.taskType === 'daily_check_in') {
|
||||
const requiredStreak = optionalPositiveIntText(pointTaskForm.requiredStreak);
|
||||
const minStreak = optionalPositiveIntText(pointTaskForm.minStreak);
|
||||
const maxStreak = optionalPositiveIntText(pointTaskForm.maxStreak);
|
||||
const streakMultipleOf = optionalPositiveIntText(pointTaskForm.streakMultipleOf);
|
||||
if (requiredStreak !== undefined) metadata.requiredStreak = requiredStreak;
|
||||
if (minStreak !== undefined) metadata.minStreak = minStreak;
|
||||
if (maxStreak !== undefined) metadata.maxStreak = maxStreak;
|
||||
if (streakMultipleOf !== undefined) metadata.streakMultipleOf = streakMultipleOf;
|
||||
}
|
||||
const result = await upsertPointActivityTask({
|
||||
id: pointTaskForm.id || undefined,
|
||||
code: pointTaskForm.code.trim(),
|
||||
@@ -625,7 +662,7 @@ export default function TenantMarketingPage() {
|
||||
validFrom: pointTaskForm.validFrom || null,
|
||||
validTo: pointTaskForm.validTo || null,
|
||||
order: Math.trunc(Number(pointTaskForm.order || 0)),
|
||||
metadata: { source: 'taro-tenant-admin' },
|
||||
metadata,
|
||||
});
|
||||
Taro.showToast({ title: '积分任务已保存', icon: 'success' });
|
||||
if (result.item?.id) setPointFilter(prev => ({ ...prev, selectedTaskId: result.item?.id || prev.selectedTaskId }));
|
||||
@@ -978,6 +1015,14 @@ export default function TenantMarketingPage() {
|
||||
<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>
|
||||
{pointTaskForm.taskType === 'daily_check_in' ? (
|
||||
<View className='admin-form-grid'>
|
||||
<Input className='admin-input' type='number' placeholder='仅第 N 天奖励' value={pointTaskForm.requiredStreak} onInput={event => setPointTaskForm(prev => ({ ...prev, requiredStreak: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' type='number' placeholder='最小连续天数' value={pointTaskForm.minStreak} onInput={event => setPointTaskForm(prev => ({ ...prev, minStreak: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' type='number' placeholder='最大连续天数' value={pointTaskForm.maxStreak} onInput={event => setPointTaskForm(prev => ({ ...prev, maxStreak: String(event.detail.value || '') }))} />
|
||||
<Input className='admin-input' type='number' placeholder='每 N 天循环奖励' value={pointTaskForm.streakMultipleOf} onInput={event => setPointTaskForm(prev => ({ ...prev, streakMultipleOf: String(event.detail.value || '') }))} />
|
||||
</View>
|
||||
) : null}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user