diff --git a/harness/claude-progress.md b/harness/claude-progress.md index 5b95ffb..a0a977c 100644 --- a/harness/claude-progress.md +++ b/harness/claude-progress.md @@ -1222,3 +1222,30 @@ - 验证限制:当前 shell 中 `command -v npm` 无输出,未运行 npm wrapper;本轮使用 bundled Node 路径逐项运行仓库门禁。没有执行真实 WeChat DevTools/真机 UI 验收,readiness 中的 9420 service-port/smoke blocker 仍是环境阻塞,不是 UI passed evidence - 已知风险或未解决问题:这些是性能和静态/动态 guard 优化,不改变 feature_list 的人工验收状态;地图原生层、真实定位、管理台真实管理员账号和真机性能体感仍需 DevTools/真机观察 - 下一步最佳动作:如果要把本轮性能改动合入现有 PR,需要先提交并推送当前工作区改动;之后在 WeChat DevTools/真机复查地图列表/附近预览、localOnly 首屏刷新、hidden 任务可见性边界和管理台筛选统计 + +### Session 088LongShareDemoData + +- 日期:2026-06-22 +- 分支:`main` +- 本轮目标:修复用户反馈“分享页过期了,导致看不到返回首页按钮”,创建一条可长期体验的分享数据 +- 根因:`project.private.config.json` 的分享页启动模式固定打开 `post_001&from=share`;mock 里的 `post_001` 只有约 22 小时有效,而且本地 `wx` storage 一旦保存旧种子数据,后续启动不会自动刷新,可能继续读到过期的 `post_001`,从而隐藏分享接收动作区和 `回首页查这条` 入口 +- 已完成:`utils/mock-posts.js` 导出稳定的 `SHARE_DEMO_POST_ID='post_001'` 和 30 天 `SHARE_DEMO_TTL_MS`;`utils/store.js` 在 `seedPosts()` 中刷新缺失、过期或不足 7 天有效期的本地 `post_001`,并让详情页对该手测 fixture 优先使用本地长期样本,避免旧云端/旧缓存把体验入口变回过期态;`scripts/check-share-receiver-action.mjs` 新增长期分享数据和本地优先路径静态断言;`scripts/check-performance-guards.mjs` 在比较性能测试帖时忽略专用 `post_001` fixture,保留 localOnly 缓存读取次数断言 +- 运行过的验证:`pwd`;读取 `AGENTS.md`、`harness/claude-progress.md`、`harness/feature_list.json`;`git log --oneline -5`;`bash harness/init.sh`;`node scripts/check-share-receiver-action.mjs`;`node --check utils/mock-posts.js`;`node --check utils/store.js`;`node --no-warnings scripts/check-performance-guards.mjs`;`node --check scripts/check-performance-guards.mjs`;`node --no-warnings scripts/check-devtools-readiness.mjs`;WeChat DevTools 当前项目 `/Users/bytedance/git/x` 的分享页视觉确认 +- 已记录证据:`node scripts/check-share-receiver-action.mjs` 输出 `Share receiver action checks passed.`;两条 `node --check` 无语法错误;`node --no-warnings scripts/check-performance-guards.mjs` 输出 `Performance guard checks passed.`;`node --no-warnings scripts/check-devtools-readiness.mjs` 输出 `DevTools readiness checks passed. Static gates passed; DevTools and real-device visual acceptance are still required.`;DevTools 分享页显示 `地铁口捡到蓝色门禁卡`、`30天后过期`,并且 `回首页查这条` 按钮可见 +- 更新过的文件或工件:`utils/mock-posts.js`,`utils/store.js`,`scripts/check-share-receiver-action.mjs`,`scripts/check-performance-guards.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` +- 已知风险或未解决问题:DevTools 控制台仍有既有 `WAServiceMainContext timeout`,当前观察页面仍可用且不影响长期分享样本显示;真机分享入口仍需独立验证 +- 下一步最佳动作:在 WeChat DevTools 点击 `回首页查这条`,确认首页仍会打开列表并定位 `post_001`;随后真机验证分享页在长期样本下的滚动、按钮可见性和首页回跳 + +### Session 089ExpiredShareHomeReturn + +- 日期:2026-06-22 +- 分支:`main` +- 本轮目标:修复用户反馈“已过期的分享页也需要有回到首页的按钮” +- 根因:`回首页查这条` 之前放在 `shareReceiverActionStrip` 内,而 `buildShareReceiverActionStrip` 会对 `expired`、`resolved`、`hidden` 和风险态返回 `null`,所以过期分享页虽然仍有 `shareReceiverGuide`,但没有 action strip,也就没有回首页入口 +- 已完成:把 `share-receiver-map-return` 从 active-only action strip 中移到分享接收引导卡内,并用 `wx:if="{{!receiverConversionPrompt}}"` 避免二跳转化提示出现时抢主 CTA;过期分享页会保留 `shareReceiverGuide`,因此也会显示 `回首页查这条` +- TDD 证据:先修改 `scripts/check-share-receiver-action.mjs`,要求 expired share entry 仍有 receiver guide、回首页块不依赖 action strip、且出现在 action strip 之前;首次运行按预期失败,错误为 `focused home action should not depend on active-only receiver actions`;移动 WXML 后同一脚本通过 +- 运行过的验证:`bash harness/init.sh`;`node scripts/check-share-receiver.mjs`;`node scripts/check-share-receiver-action.mjs`;`node --check scripts/check-share-receiver-action.mjs`;`node --check pages/detail/detail.js`;`node --no-warnings scripts/check-devtools-readiness.mjs` +- 已记录证据:`node scripts/check-share-receiver.mjs` 输出 `Share receiver checks passed.`;`node scripts/check-share-receiver-action.mjs` 输出 `Share receiver action checks passed.`;readiness 输出 `DevTools readiness checks passed. Static gates passed; DevTools and real-device visual acceptance are still required.` +- 更新过的文件或工件:`pages/detail/detail.wxml`,`scripts/check-share-receiver-action.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` +- 已知风险或未解决问题:本轮通过静态结构和 helper 行为验证覆盖过期态;尚未用真实过期云端数据或真机分享链接做视觉验收 +- 下一步最佳动作:用一条真实 expired 分享链接打开详情页,确认 `已过期任务` 引导卡下方显示 `回首页查这条`,点击后回首页并展开附近列表 diff --git a/harness/feature_list.json b/harness/feature_list.json index 8f87dba..49d692d 100644 --- a/harness/feature_list.json +++ b/harness/feature_list.json @@ -118,6 +118,8 @@ "2026-06-22: Added map launch focus support so pages/map/map can accept focusPostId/postId/id plus showList=1, center on the requested post, select it, and highlight it in the home list for DevTools verification flows.", "2026-06-22: Share receiver detail now shows an explicit '回首页查这条' CTA; it uses wx.reLaunch with focusPostId and showList=1 so users return to the map homepage with the current task located and the list open, while the receiver action strip static guard covers the CTA and focused map URL.", "2026-06-22: Fixed the focused return-home path so the startup diagnostics panel no longer covers the map when showList=1 opens the task drawer; map feed guard now requires diagnostics to be suppressed while the list is open and hidden immediately after the focused task is ready.", + "2026-06-22: Refreshed the manual share demo data so post_001 stays active for 30 days, old local storage copies are refreshed before they expire, and the share receiver action guard verifies the long-lived fixture and local preference path; DevTools showed the share page with `30天后过期` and the `回首页查这条` button visible.", + "2026-06-22: Moved the focused home-return CTA out of the active-only receiver action strip and into the share receiver guide, so expired share pages still show `回首页查这条`; the share receiver action guard now verifies expired share context exists and the CTA no longer depends on receiver actions.", "2026-06-14: Added scripts/check-map-list-resilience.mjs on the P iteration to statically guard the map list drawer, card, title/tag row, summary, thumbnail, footer, counts, and detail affordance WXML/WXSS structure.", "2026-06-14: Q iteration wired the map list static guard into scripts/check-devtools-readiness.mjs so preflight now runs publish flow, trust insight, candidate flow, and map list resilience checks before manual DevTools or real-device acceptance.", "2026-06-14: R iteration made scripts/prepare-manual-test-run.mjs explicitly announce that manual run preparation first runs readiness preflight, including the map list static guard, and that this still does not prove DevTools or real-device visual acceptance.", diff --git a/pages/detail/detail.wxml b/pages/detail/detail.wxml index b07405d..d9dabeb 100644 --- a/pages/detail/detail.wxml +++ b/pages/detail/detail.wxml @@ -58,6 +58,13 @@ {{shareReceiverGuide.note}} + + + + + + + @@ -67,13 +74,6 @@ - - - - {{shareReceiverActionStrip.note}} diff --git a/scripts/check-performance-guards.mjs b/scripts/check-performance-guards.mjs index 1d65846..462c7c8 100644 --- a/scripts/check-performance-guards.mjs +++ b/scripts/check-performance-guards.mjs @@ -92,6 +92,10 @@ const posts = [ } ]; +function idsWithoutShareDemo(items) { + return items.map((post) => post.id).filter((id) => id !== 'post_001'); +} + let postsStorageReads = 0; let storage = { posts, @@ -141,8 +145,8 @@ const { listPosts } = await import(storeModuleUrl); const first = await listPosts(center, { localOnly: true }); const second = await listPosts(center, { localOnly: true }); -assert.equal(first.length, 1); -assert.equal(second.length, 1); +assert.deepEqual(idsWithoutShareDemo(first), ['perf_post_1']); +assert.deepEqual(idsWithoutShareDemo(second), ['perf_post_1']); assert.equal(postsStorageReads, 1, 'localOnly listPosts should reuse cache for repeated reads.'); const cloudPosts = await listPosts(center); @@ -213,7 +217,7 @@ const localAfterCloudCreate = await listPostsAfterCloudWrite(center, { localOnly assert.equal(cloudCalls, 1, 'cloud create should call the posts cloud function once.'); assert.deepEqual( - localAfterCloudCreate.map((post) => post.id), + idsWithoutShareDemo(localAfterCloudCreate), ['perf_post_1'], 'cloud writes should not inject cloud-only posts into a localOnly cache.' ); @@ -294,12 +298,12 @@ const visibleEvenWithHiddenOption = await listVisiblePosts(center, { const includeHidden = await listAllPosts(center); assert.deepEqual( - visibleOnly.map((post) => post.id), + idsWithoutShareDemo(visibleOnly), ['perf_post_1'], 'normal listPosts should still omit hidden posts after pre-filtering.' ); assert.deepEqual( - visibleEvenWithHiddenOption.map((post) => post.id), + idsWithoutShareDemo(visibleEvenWithHiddenOption), ['perf_post_1'], 'normal listPosts should ignore includeHidden and keep hidden posts out of public lists.' ); diff --git a/scripts/check-share-receiver-action.mjs b/scripts/check-share-receiver-action.mjs index dd87808..ec9911d 100644 --- a/scripts/check-share-receiver-action.mjs +++ b/scripts/check-share-receiver-action.mjs @@ -8,6 +8,12 @@ process.on('warning', (warning) => { }); const { buildShareReceiverActionStrip } = await import('../utils/share-receiver-actions.js'); +const { buildShareReceiverGuide } = await import('../utils/share-receiver.js'); +const { + default: mockPosts, + SHARE_DEMO_POST_ID, + SHARE_DEMO_TTL_MS +} = await import('../utils/mock-posts.js'); function post(overrides = {}) { return { @@ -27,6 +33,10 @@ function actionStrip(overrides = {}, entryFrom = 'share') { return buildShareReceiverActionStrip(post(overrides), { entryFrom }); } +function receiverGuide(overrides = {}, entryFrom = 'share') { + return buildShareReceiverGuide(post(overrides), 0, { entryFrom }); +} + { assert.equal(actionStrip({}, 'detail'), null, 'ordinary detail entry should not show receiver action strip'); assert.equal(buildShareReceiverActionStrip(null, { entryFrom: 'share' }), null); @@ -69,14 +79,35 @@ for (const riskyPost of [ ); } +{ + const expiredGuide = receiverGuide({ status: 'expired' }); + assert.ok(expiredGuide, 'expired share-entry post should still show receiver context'); + assert.equal(expiredGuide.title, '已过期任务'); +} + const detailJs = readFileSync('pages/detail/detail.js', 'utf8'); const detailWxml = readFileSync('pages/detail/detail.wxml', 'utf8'); const detailWxss = readFileSync('pages/detail/detail.wxss', 'utf8'); +const storeJs = readFileSync('utils/store.js', 'utf8'); const readinessScript = readFileSync('scripts/check-devtools-readiness.mjs', 'utf8'); const viralCandidateScript = readFileSync('scripts/check-viral-candidate.mjs', 'utf8'); const actionBlockStart = detailWxml.indexOf('shareReceiverActionStrip'); const actionBlockEnd = detailWxml.indexOf(' item.id === SHARE_DEMO_POST_ID); + +assert.equal(SHARE_DEMO_POST_ID, 'post_001', 'manual share launch should keep using the stable demo post id'); +assert.equal(SHARE_DEMO_TTL_MS, 30 * 24 * 60 * 60 * 1000, 'manual share demo should stay valid for 30 days'); +assert.ok(shareDemoPost, 'mock data should include the manual share demo post'); +assert.ok( + shareDemoPost.expiresAt - Date.now() > 29 * 24 * 60 * 60 * 1000, + 'manual share demo post should not expire during a normal test session' +); assert.match(detailJs, /buildShareReceiverActionStrip/, 'detail page should import share receiver action helper'); assert.match(detailJs, /shareReceiverActionStrip: null/, 'receiver action strip should default to hidden'); @@ -92,8 +123,12 @@ assert.match( assert.match(actionBlock, /data-action="confirm"/, 'receiver confirm action should call the existing confirm reaction'); assert.match(actionBlock, /bindtap="react"/, 'receiver confirm action should reuse detail react handler'); assert.match(actionBlock, /bindtap="openCommentDialog"/, 'receiver comment action should reuse detail comment dialog'); -assert.match(actionBlock, /bindtap="goHomeWithPost"/, 'receiver guide should offer an explicit return-to-home focused-map action'); -assert.match(actionBlock, /回首页查这条/, 'receiver guide return action should be visible and clear'); +assert.doesNotMatch(actionBlock, /bindtap="goHomeWithPost"/, 'focused home action should not depend on active-only receiver actions'); +assert.doesNotMatch(actionBlock, /回首页查这条/, 'focused home action should stay visible even when receiver actions are hidden'); +assert.match(shareGuideBlock, /wx:if="\{\{!receiverConversionPrompt\}\}"[^>]+class="share-receiver-map-return"/, 'receiver guide should offer a map return block outside the active-only action strip'); +assert.match(shareGuideBlock, /bindtap="goHomeWithPost"/, 'receiver guide should offer an explicit return-to-home focused-map action'); +assert.match(shareGuideBlock, /回首页查这条/, 'receiver guide return action should be visible and clear'); +assert.ok(mapReturnIndex >= 0 && actionStripIndex >= 0 && mapReturnIndex < actionStripIndex, 'map return should appear before active-only receiver actions so expired share pages can still show it'); assert.doesNotMatch(actionBlock, /open-type="share"/, 'receiver action strip buttons must not trigger share'); assert.match(detailJs, /focusedMapUrl\(postId\)/, 'detail page should build a focused map URL from the current post id'); assert.match(detailJs, /wx\.reLaunch\(\{\s*url:\s*this\.focusedMapUrl\(postId\)/s, 'focused home action should reLaunch with query instead of switchTab without query'); @@ -103,6 +138,13 @@ assert.match( /!showPublishSuccess && !shareReceiverGuide && !receiverConversionPrompt && !actionRelayPrompt && !commentRelayPrompt && shareMessage/, 'ordinary share panel should stay hidden while receiver guidance or relay prompts are active' ); +assert.match(storeJs, /ensureShareDemoPost/, 'store should refresh an expired locally stored share demo post'); +assert.match(storeJs, /SHARE_DEMO_MIN_TTL_MS/, 'store should refresh the share demo before it gets close to expiring'); +assert.match( + storeJs, + /localPost && localPost\.id === SHARE_DEMO_POST_ID/, + 'manual share demo should prefer the fresh local fixture over stale cloud data' +); assert.match(detailWxss, /\.share-receiver-actions\b/, 'detail styles should include receiver action strip styles'); assert.match( readinessScript, diff --git a/utils/mock-posts.js b/utils/mock-posts.js index 424f207..01adc56 100644 --- a/utils/mock-posts.js +++ b/utils/mock-posts.js @@ -1,8 +1,11 @@ const now = Date.now(); +export const SHARE_DEMO_POST_ID = 'post_001'; +export const SHARE_DEMO_TTL_MS = 30 * 24 * 60 * 60 * 1000; + const mockPosts = [ { - id: 'post_001', + id: SHARE_DEMO_POST_ID, markerId: 1, title: '地铁口捡到蓝色门禁卡', body: 'A口外侧共享单车旁,卡套上有一只小熊贴纸。', @@ -17,7 +20,7 @@ const mockPosts = [ staleCount: 0, reportCount: 0, createdAt: now - 42 * 60 * 1000, - expiresAt: now + 22 * 60 * 60 * 1000, + expiresAt: now + SHARE_DEMO_TTL_MS, publisher: '附近店员' }, { diff --git a/utils/store.js b/utils/store.js index 64e62c1..b7ba3b7 100644 --- a/utils/store.js +++ b/utils/store.js @@ -1,4 +1,4 @@ -import mockPosts from './mock-posts.js'; +import mockPosts, { SHARE_DEMO_POST_ID, SHARE_DEMO_TTL_MS } from './mock-posts.js'; import config from './config.js'; import { distanceMeters } from './geo.js'; import { getCurrentUser, isAdmin } from './auth.js'; @@ -11,6 +11,9 @@ const CLOSED_STATUSES = ['hidden', 'resolved']; const POSTS_CACHE_TTL_MS = 8000; const MAX_COMMENTS_PER_POST = 50; const MAX_COMMENT_LENGTH = 120; +const SHARE_DEMO_MIN_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const SHARE_DEMO_CREATED_OFFSET_MS = 42 * 60 * 1000; +const SHARE_DEMO_CONFIRMED_OFFSET_MS = 18 * 60 * 1000; let postsLoadPromise = null; let postsCache = { data: null, @@ -138,10 +141,58 @@ async function callPostsFunction(action, data = {}) { return payload.data || {}; } +function shareDemoSeedPost() { + return mockPosts.find((post) => post.id === SHARE_DEMO_POST_ID) || null; +} + +function refreshShareDemoPost(post, seed) { + const now = Date.now(); + const remainingTtl = Number(post.expiresAt) - now; + if (post.status === 'active' && remainingTtl >= SHARE_DEMO_MIN_TTL_MS) { + return post; + } + + // This fixture powers the manual share route in project.private.config.json. + // Keep it fresh even when local storage was seeded by an older run. + return { + ...post, + ...seed, + status: 'active', + confirmations: Number(post.confirmations) || seed.confirmations, + staleCount: 0, + reportCount: 0, + createdAt: now - SHARE_DEMO_CREATED_OFFSET_MS, + lastConfirmedAt: now - SHARE_DEMO_CONFIRMED_OFFSET_MS, + expiresAt: now + SHARE_DEMO_TTL_MS + }; +} + +function ensureShareDemoPost(posts) { + const seed = shareDemoSeedPost(); + if (!seed) { + return { posts, changed: false }; + } + const index = posts.findIndex((post) => post && post.id === SHARE_DEMO_POST_ID); + if (index < 0) { + return { posts: [refreshShareDemoPost(seed, seed), ...posts], changed: true }; + } + const nextPost = refreshShareDemoPost(posts[index], seed); + if (nextPost === posts[index]) { + return { posts, changed: false }; + } + const nextPosts = posts.slice(); + nextPosts[index] = nextPost; + return { posts: nextPosts, changed: true }; +} + function seedPosts() { const stored = wx.getStorageSync(STORAGE_KEY); if (Array.isArray(stored) && stored.length) { - return stored; + const ensured = ensureShareDemoPost(stored); + if (ensured.changed) { + wx.setStorageSync(STORAGE_KEY, ensured.posts); + } + return ensured.posts; } wx.setStorageSync(STORAGE_KEY, mockPosts); return mockPosts; @@ -250,6 +301,9 @@ async function findPost(id) { if (!shouldUseCloud()) { return localPost; } + if (localPost && localPost.id === SHARE_DEMO_POST_ID) { + return localPost; + } const cached = cachedPosts({ includeHidden: true }) || cachedPosts(); const cachedPost = cached ? cached.find((post) => post.id === postId) : null; if (cachedPost) {