refactor: 收口每日任务 ViewModel

This commit is contained in:
2026-06-03 16:49:54 +08:00
parent 3783f0d2af
commit 06fabd3eab
6 changed files with 283 additions and 75 deletions

View File

@@ -58,6 +58,14 @@
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。 - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md` - 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`
## 2026-06-03 Profile Task ViewModel 收口
- 背景:`RpgEntryHomeView.tsx` 同时持有每日任务卡片和任务中心弹窗的任务选择、进度 clamp、奖励兜底、状态标签和按钮文案导致任务展示规则和 JSX 缠在一起。
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,把 `selectProfileTaskCenterTasks``selectProfileTaskCardTask``buildProfileTaskCardSummary``buildProfileTaskProgressLabel``getProfileTaskStatusLabel``getProfileTaskClaimButtonLabel` 收口为每日任务 ViewModel Interface。任务中心仍只展示一条 claimable / incomplete 优先任务任务卡按可操作、claimed、非 disabled 的顺序兜底。
- 影响范围RPG 首页“每日任务”卡片、任务中心弹窗、后续任务状态和任务展示文案调整。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md`
## 2026-06-03 最近创作只复用创作模板入口 ## 2026-06-03 最近创作只复用创作模板入口
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。

View File

@@ -47,6 +47,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 公开作品分类、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
## 推荐阅读顺序 ## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。

View File

@@ -0,0 +1,29 @@
# 【前端架构】Profile Task ViewModel 收口计划
## 背景
`RpgEntryHomeView.tsx` 的“每日任务”卡片与任务弹窗共用同一批展示规则:任务优先级、可领取 / 未完成选择、进度 clamp、奖励兜底、状态标签和按钮文案。原先这些规则散在巨型页面 **Implementation**UI JSX 既要渲染,又要知道任务状态排序和兜底口径。
## 决策
新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,作为每日任务展示模型 **Module**。该 **Module****Interface** 收口为:
- `selectProfileTaskCenterTasks(tasks)`:统一任务中心只展示一条可操作任务,按 claimable / incomplete 优先级并保持原始顺序。
- `selectProfileTaskCardTask(tasks)`:统一任务卡兜底顺序,先可操作,再 claimed再非 disabled。
- `buildProfileTaskCardSummary(center)`:统一任务卡的奖励、阈值、进度百分比与动作文案。
- `buildProfileTaskProgressLabel(task)``getProfileTaskStatusLabel(status)``getProfileTaskClaimButtonLabel(task, isClaiming)`:统一任务弹窗中的进度、状态和按钮文案。
`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留弹窗、按钮和点击处理。每日任务展示规则的 **Locality** 转移到 ViewModel **Module** 与纯测试,后续新增任务状态或修改展示优先级不再穿透 UI。
## 约定
- 任务中心只露出当前最需要用户处理的一条任务。
- 任务进度必须按 `0..threshold` clamp避免异常后端进度撑破卡片进度条。
- `pause` / `claim` 等副作用仍留在页面和后端 clientViewModel 只做展示派生。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -71,7 +71,6 @@ import type {
ProfileReferralInviteCenterResponse, ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary, ProfileSaveArchiveSummary,
ProfileTaskCenterResponse, ProfileTaskCenterResponse,
ProfileTaskItem,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse, RedeemProfileRewardCodeResponse,
WechatMiniProgramPayParams, WechatMiniProgramPayParams,
@@ -135,6 +134,13 @@ import {
import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive';
import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
buildProfileTaskCardSummary,
buildProfileTaskProgressLabel,
getProfileTaskClaimButtonLabel,
getProfileTaskStatusLabel,
selectProfileTaskCenterTasks,
} from './rpgEntryProfileTaskViewModel';
import { import {
buildPlatformRankingEntries, buildPlatformRankingEntries,
buildPublicCategoryGroups, buildPublicCategoryGroups,
@@ -277,66 +283,8 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
ProfileTaskItem['status'],
number
> = {
claimable: 2,
incomplete: 1,
disabled: 0,
claimed: -1,
};
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
const PROFILE_QR_SCAN_INTERVAL_MS = 360; const PROFILE_QR_SCAN_INTERVAL_MS = 360;
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
return tasks
.map((task, index) => ({ task, index }))
.filter(
({ task }) =>
task.status === 'claimable' || task.status === 'incomplete',
)
.sort(
(left, right) =>
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
left.index - right.index,
)
.slice(0, 1)
.map(({ task }) => task);
}
function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
return (
selectProfileTaskCenterTasks(tasks)[0] ??
tasks.find((task) => task.status === 'claimed') ??
tasks.find((task) => task.status !== 'disabled') ??
null
);
}
function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
const task = selectProfileTaskCardTask(center?.tasks ?? []);
const threshold = Math.max(1, task?.threshold ?? 1);
const progressCount = Math.min(task?.progressCount ?? 0, threshold);
const rewardPoints =
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
const actionLabel =
task?.status === 'claimable'
? '领取'
: task?.status === 'claimed'
? '已完成'
: '去完成';
return {
actionLabel,
progressCount,
progressPercent: Math.round((progressCount / threshold) * 100),
rewardPoints,
threshold,
};
}
type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
type BarcodeDetectorLike = { type BarcodeDetectorLike = {
@@ -2861,13 +2809,6 @@ function WalletLedgerModal({
); );
} }
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
incomplete: '未完成',
claimable: '可领取',
claimed: '已领取',
disabled: '已停用',
};
function ProfileTaskCenterModal({ function ProfileTaskCenterModal({
center, center,
isLoading, isLoading,
@@ -2947,7 +2888,7 @@ function ProfileTaskCenterModal({
{tasks.map((task) => { {tasks.map((task) => {
const isClaimable = task.status === 'claimable'; const isClaimable = task.status === 'claimable';
const isClaiming = claimingTaskId === task.taskId; const isClaiming = claimingTaskId === task.taskId;
const progressLabel = `${Math.min(task.progressCount, task.threshold)}/${task.threshold}`; const progressLabel = buildProfileTaskProgressLabel(task);
return ( return (
<div <div
@@ -2968,7 +2909,7 @@ function ProfileTaskCenterModal({
+{task.rewardPoints} +{task.rewardPoints}
</div> </div>
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]"> <div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
{PROFILE_TASK_STATUS_LABELS[task.status]} {getProfileTaskStatusLabel(task.status)}
</div> </div>
</div> </div>
</div> </div>
@@ -2978,13 +2919,7 @@ function ProfileTaskCenterModal({
onClick={() => onClaim(task.taskId)} onClick={() => onClaim(task.taskId)}
className="platform-primary-button mt-3 w-full rounded-2xl px-4 py-2.5 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50" className="platform-primary-button mt-3 w-full rounded-2xl px-4 py-2.5 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
> >
{isClaiming {getProfileTaskClaimButtonLabel(task, isClaiming)}
? '领取中'
: task.status === 'claimed'
? '已领取'
: isClaimable
? '领取'
: '未完成'}
</button> </button>
</div> </div>
); );

View File

@@ -0,0 +1,127 @@
import { expect, test } from 'vitest';
import type {
ProfileTaskCenterResponse,
ProfileTaskItem,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildProfileTaskCardSummary,
buildProfileTaskProgressLabel,
getProfileTaskClaimButtonLabel,
getProfileTaskStatusLabel,
selectProfileTaskCardTask,
selectProfileTaskCenterTasks,
} from './rpgEntryProfileTaskViewModel';
function buildTask(
overrides: Partial<ProfileTaskItem> = {},
): ProfileTaskItem {
return {
taskId: 'task-1',
title: '游玩一次',
description: '完成一次游戏',
eventKey: 'work_play_start',
cycle: 'daily',
threshold: 1,
progressCount: 0,
rewardPoints: 10,
status: 'incomplete',
dayKey: 20260603,
claimedAt: null,
updatedAt: '2026-06-03T00:00:00.000Z',
...overrides,
};
}
function buildCenter(
tasks: ProfileTaskItem[],
): ProfileTaskCenterResponse {
return {
dayKey: 20260603,
walletBalance: 12,
tasks,
updatedAt: '2026-06-03T00:00:00.000Z',
};
}
test('profile task ViewModel selects one actionable task by status priority and original order', () => {
const firstIncomplete = buildTask({
taskId: 'incomplete-1',
status: 'incomplete',
});
const secondIncomplete = buildTask({
taskId: 'incomplete-2',
status: 'incomplete',
});
const claimable = buildTask({
taskId: 'claimable-1',
status: 'claimable',
});
expect(
selectProfileTaskCenterTasks([
firstIncomplete,
secondIncomplete,
claimable,
]),
).toEqual([claimable]);
expect(selectProfileTaskCenterTasks([firstIncomplete, secondIncomplete])).toEqual(
[firstIncomplete],
);
});
test('profile task ViewModel falls back from card task to claimed and enabled tasks', () => {
const claimed = buildTask({ taskId: 'claimed-1', status: 'claimed' });
const disabled = buildTask({ taskId: 'disabled-1', status: 'disabled' });
const incomplete = buildTask({
taskId: 'incomplete-1',
status: 'incomplete',
});
expect(selectProfileTaskCardTask([disabled, claimed])).toBe(claimed);
expect(selectProfileTaskCardTask([disabled, incomplete])).toBe(incomplete);
expect(selectProfileTaskCardTask([disabled])).toBeNull();
});
test('profile task ViewModel builds card summary with reward fallback and clamped progress', () => {
expect(buildProfileTaskCardSummary(null)).toEqual({
actionLabel: '去完成',
progressCount: 0,
progressPercent: 0,
rewardPoints: 10,
threshold: 1,
});
expect(
buildProfileTaskCardSummary(
buildCenter([
buildTask({
progressCount: 5,
rewardPoints: 25,
status: 'claimable',
threshold: 3,
}),
]),
),
).toEqual({
actionLabel: '领取',
progressCount: 3,
progressPercent: 100,
rewardPoints: 25,
threshold: 3,
});
});
test('profile task ViewModel exposes task labels for the modal', () => {
const task = buildTask({ progressCount: -1, threshold: 0 });
expect(getProfileTaskStatusLabel('claimable')).toBe('可领取');
expect(buildProfileTaskProgressLabel(task)).toBe('0/1');
expect(getProfileTaskClaimButtonLabel(task, true)).toBe('领取中');
expect(getProfileTaskClaimButtonLabel(buildTask({ status: 'claimed' }), false)).toBe(
'已领取',
);
expect(
getProfileTaskClaimButtonLabel(buildTask({ status: 'claimable' }), false),
).toBe('领取');
});

View File

@@ -0,0 +1,107 @@
import type {
ProfileTaskCenterResponse,
ProfileTaskItem,
} from '../../../packages/shared/src/contracts/runtime';
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
ProfileTaskItem['status'],
number
> = {
claimable: 2,
incomplete: 1,
disabled: 0,
claimed: -1,
};
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
incomplete: '未完成',
claimable: '可领取',
claimed: '已领取',
disabled: '已停用',
};
export type ProfileTaskCardSummary = {
actionLabel: string;
progressCount: number;
progressPercent: number;
rewardPoints: number;
threshold: number;
};
export function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
return tasks
.map((task, index) => ({ task, index }))
.filter(
({ task }) =>
task.status === 'claimable' || task.status === 'incomplete',
)
.sort(
(left, right) =>
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
PROFILE_TASK_STATUS_PRIORITY_RANK[left.task.status] ||
left.index - right.index,
)
.slice(0, 1)
.map(({ task }) => task);
}
export function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
return (
selectProfileTaskCenterTasks(tasks)[0] ??
tasks.find((task) => task.status === 'claimed') ??
tasks.find((task) => task.status !== 'disabled') ??
null
);
}
export function getProfileTaskStatusLabel(status: ProfileTaskItem['status']) {
return PROFILE_TASK_STATUS_LABELS[status];
}
export function buildProfileTaskProgressLabel(task: ProfileTaskItem) {
const threshold = Math.max(1, task.threshold);
const progressCount = Math.min(Math.max(0, task.progressCount), threshold);
return `${progressCount}/${threshold}`;
}
export function getProfileTaskClaimButtonLabel(
task: ProfileTaskItem,
isClaiming: boolean,
) {
if (isClaiming) {
return '领取中';
}
if (task.status === 'claimed') {
return '已领取';
}
return task.status === 'claimable' ? '领取' : '未完成';
}
export function buildProfileTaskCardSummary(
center: ProfileTaskCenterResponse | null,
): ProfileTaskCardSummary {
const task = selectProfileTaskCardTask(center?.tasks ?? []);
const threshold = Math.max(1, task?.threshold ?? 1);
const progressCount = Math.min(
Math.max(0, task?.progressCount ?? 0),
threshold,
);
const rewardPoints =
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
const actionLabel =
task?.status === 'claimable'
? '领取'
: task?.status === 'claimed'
? '已完成'
: '去完成';
return {
actionLabel,
progressCount,
progressPercent: Math.round((progressCount / threshold) * 100),
rewardPoints,
threshold,
};
}