diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 752aec27..7aefb3a2 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -58,6 +58,14 @@ - 验证方式:`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`。 +## 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 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 175f634a..26f8e492 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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/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),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md b/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md new file mode 100644 index 00000000..88193054 --- /dev/null +++ b/docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md @@ -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` 等副作用仍留在页面和后端 client;ViewModel 只做展示派生。 + +## 验证 + +- `npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 1511bb8f..812c595a 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -71,7 +71,6 @@ import type { ProfileReferralInviteCenterResponse, ProfileSaveArchiveSummary, ProfileTaskCenterResponse, - ProfileTaskItem, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, WechatMiniProgramPayParams, @@ -135,6 +134,13 @@ import { import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; +import { + buildProfileTaskCardSummary, + buildProfileTaskProgressLabel, + getProfileTaskClaimButtonLabel, + getProfileTaskStatusLabel, + selectProfileTaskCenterTasks, +} from './rpgEntryProfileTaskViewModel'; import { buildPlatformRankingEntries, 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_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; 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; -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 ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type BarcodeDetectorLike = { @@ -2861,13 +2809,6 @@ function WalletLedgerModal({ ); } -const PROFILE_TASK_STATUS_LABELS: Record = { - incomplete: '未完成', - claimable: '可领取', - claimed: '已领取', - disabled: '已停用', -}; - function ProfileTaskCenterModal({ center, isLoading, @@ -2947,7 +2888,7 @@ function ProfileTaskCenterModal({ {tasks.map((task) => { const isClaimable = task.status === 'claimable'; const isClaiming = claimingTaskId === task.taskId; - const progressLabel = `${Math.min(task.progressCount, task.threshold)}/${task.threshold}`; + const progressLabel = buildProfileTaskProgressLabel(task); return (
- {PROFILE_TASK_STATUS_LABELS[task.status]} + {getProfileTaskStatusLabel(task.status)}
@@ -2978,13 +2919,7 @@ function ProfileTaskCenterModal({ 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" > - {isClaiming - ? '领取中' - : task.status === 'claimed' - ? '已领取' - : isClaimable - ? '领取' - : '未完成'} + {getProfileTaskClaimButtonLabel(task, isClaiming)} ); diff --git a/src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts new file mode 100644 index 00000000..b71ddb97 --- /dev/null +++ b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts @@ -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 { + 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('领取'); +}); diff --git a/src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts new file mode 100644 index 00000000..5987b671 --- /dev/null +++ b/src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts @@ -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 = { + 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, + }; +}