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}}
+
+
+ 想先回首页?
+ 回到首页会定位这条任务,并展开附近列表。
+
+
+
{{shareReceiverActionStrip.title}}
@@ -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) {