diff --git a/cloudfunctions/posts/index.js b/cloudfunctions/posts/index.js index 5096cda..1525e59 100644 --- a/cloudfunctions/posts/index.js +++ b/cloudfunctions/posts/index.js @@ -27,7 +27,10 @@ const CLOSED_STATUSES = ['hidden', 'resolved']; const CATEGORIES = ['check_in', 'lost_found', 'street_update', 'help_needed']; const LOST_FOUND_INTENTS = ['lost', 'found']; const FEEDBACK_TYPES = ['suggestion', 'bug', 'content', 'other']; -const EXPIRY_HOURS = [6, 24, 72]; +const LONG_TERM_EXPIRY_HOURS = 24 * 365 * 10; +const EXPIRY_HOURS = [168, 720, LONG_TERM_EXPIRY_HOURS]; +const HOUR_MS = 60 * 60 * 1000; +const CUSTOM_EXPIRY_MIN_OFFSET_MS = 30 * 60 * 1000; const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; const VIRAL_EVENT_TYPES = [ 'share_detail_landing', @@ -149,6 +152,27 @@ function cleanExpiryHours(value) { return hours; } +function cleanExpiresAt(input, now) { + const expiresAt = Number(input.expiresAt); + if (Number.isFinite(expiresAt) && expiresAt > 0) { + if (expiresAt < now + CUSTOM_EXPIRY_MIN_OFFSET_MS) { + const error = new Error('Invalid expiry'); + error.code = 'VALIDATION_FAILED'; + throw error; + } + return Math.floor(expiresAt); + } + return now + cleanExpiryHours(input.expiryHours) * HOUR_MS; +} + +function cleanExpiryType(input) { + const expiresAt = Number(input.expiresAt); + if (Number.isFinite(expiresAt) && expiresAt > 0) { + return ''; + } + return Number(input.expiryHours) === LONG_TERM_EXPIRY_HOURS ? 'long_term' : ''; +} + function cleanCoordinate(value, min, max, fieldName) { const numberValue = Number(value); if (!Number.isFinite(numberValue) || numberValue < min || numberValue > max) { @@ -289,7 +313,6 @@ async function createPost(event, openid, isAdmin) { const input = event.input || {}; const now = Date.now(); const category = cleanCategory(input.category); - const expiryHours = cleanExpiryHours(input.expiryHours); const post = { id: `post_${now}_${hashId(`${openid}:${now}:${Math.random()}`).slice(0, 8)}`, markerId: await nextMarkerId(), @@ -307,7 +330,8 @@ async function createPost(event, openid, isAdmin) { staleCount: 0, reportCount: 0, createdAt: now, - expiresAt: now + expiryHours * 60 * 60 * 1000, + expiresAt: cleanExpiresAt(input, now), + expiryType: cleanExpiryType(input), publisherId: openid, publisher: cleanString(input.publisher, 32, 'publisher') || '匿名用户', publisherAvatarUrl: cleanString(input.publisherAvatarUrl, 500, 'publisherAvatarUrl'), diff --git a/harness/claude-progress.md b/harness/claude-progress.md index a0a977c..1ed4ee6 100644 --- a/harness/claude-progress.md +++ b/harness/claude-progress.md @@ -1249,3 +1249,52 @@ - 更新过的文件或工件:`pages/detail/detail.wxml`,`scripts/check-share-receiver-action.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` - 已知风险或未解决问题:本轮通过静态结构和 helper 行为验证覆盖过期态;尚未用真实过期云端数据或真机分享链接做视觉验收 - 下一步最佳动作:用一条真实 expired 分享链接打开详情页,确认 `已过期任务` 引导卡下方显示 `回首页查这条`,点击后回首页并展开附近列表 + +### Session 090PublishExpiryOptions + +- 日期:2026-06-23 +- 工作区:`/Users/bytedance/.codex/worktrees/e329/x`;AGENTS.md 里的历史根路径是 `/Users/bytedance/git/x`,本轮按当前 Codex worktree 执行;当前为 detached HEAD `9d2327a` +- 本轮目标:按用户要求把发布任务有效期从 `6小时/1天/3天` 改成 `1天/1周/1个月`,并增加自定义时间 +- 已完成:`utils/config.js` 的有效期预设改为 `1天`、`1周`、`1个月`、`自定义`;发布页默认有效期改为 `1天`;选择自定义后显示日期和时间 picker,展示具体截止时间;自定义时间需在未来 30 分钟到 30 天内,非法时会显示提示并阻止发布准备度通过;发布准备清单新增“有效期”项;本地 `utils/store.js` 与 `cloudfunctions/posts/index.js` 同步接受 `[24,168,720]` 预设小时和精确 `expiresAt` 自定义截止时间 +- 运行过的验证:`pwd`;读取 `harness/claude-progress.md`、`harness/feature_list.json`;`git log --oneline -5`;`bash harness/init.sh`;`node --check pages/publish/publish.js`;`node --check pages/publish/publish-state.js`;`node --check utils/store.js`;`node --check cloudfunctions/posts/index.js`;`node --no-warnings scripts/check-publish-flow.mjs`;`node --no-warnings scripts/check-devtools-readiness.mjs`;`node scripts/check-json.mjs`;`node harness/check-harness.mjs`;`node --no-warnings scripts/check-performance-guards.mjs`;`git diff --check`;WeChat DevTools 本地 `wcc` 全量编译 WXML;WeChat DevTools 本地 `wcsc -lc` 全量编译 WXSS;`npm run check`;最终再次 `bash harness/init.sh` +- 已记录证据:`node --check` 四条均无语法错误;`scripts/check-publish-flow.mjs` 输出 `Publish flow checks passed.` 并断言新四个有效期选项、自定义有效期 readiness 和 `5/5` 准备度;readiness 输出 `DevTools readiness checks passed. Static gates passed; DevTools and real-device visual acceptance are still required.`;JSON 检查输出 `Checked 11 JSON files.`;harness 输出 `Harness OK: 6 features checked.`;性能 guard 输出 `Performance guard checks passed.`;`git diff --check` 无输出;`wcc` 和 `wcsc -lc` 退出码均为 0;`npm run check` 与 `bash harness/init.sh` 均通过 +- 更新过的文件或工件:`utils/config.js`,`pages/publish/publish.js`,`pages/publish/publish.wxml`,`pages/publish/publish.wxss`,`pages/publish/publish-state.js`,`utils/store.js`,`cloudfunctions/posts/index.js`,`scripts/check-publish-flow.mjs`,`scripts/check-performance-guards.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` +- 已知风险或未解决问题:本轮没有执行真实 WeChat DevTools/真机交互验收;readiness 仍报告 DevTools service port 9420 `connect_refused` 且配置显示 Service Port disabled,这不是 UI passed evidence。仍需实际操作自定义日期/时间 picker、提交本地/云端发布、检查详情页有效期展示;云端路径需要部署更新后的 `posts` 云函数后才会接受新有效期白名单和自定义 `expiresAt` +- 下一步最佳动作:在 WeChat DevTools 或真机打开发布 tab,分别点 `1天/1周/1个月/自定义`,选择一个合法自定义截止时间并发布,确认详情页显示对应剩余时间;部署 `posts` 云函数后再验证云端发布 + +### Session 091PublishCustomExpiryNoMax + +- 日期:2026-06-23 +- 工作区:`/Users/bytedance/.codex/worktrees/e329/x`;当前为 detached HEAD `9d2327a` +- 本轮目标:按用户反馈移除自定义有效期最大 30 天限制 +- 已完成:发布页自定义日期 picker 移除 `end` 限制;前端 `customExpiryError` 只校验自定义时间至少晚于当前 30 分钟;`utils/store.js` 和 `cloudfunctions/posts/index.js` 同步移除最大 30 天校验,仅保留下限校验;`scripts/check-publish-flow.mjs` 新增 45 天自定义有效期仍可发布的断言 +- 运行过的验证:`bash harness/init.sh`;`node --check pages/publish/publish.js`;`node --check utils/store.js`;`node --check cloudfunctions/posts/index.js`;`node --no-warnings scripts/check-publish-flow.mjs`;`node --no-warnings scripts/check-devtools-readiness.mjs`;`node scripts/check-json.mjs`;`node harness/check-harness.mjs`;`git diff --check`;WeChat DevTools 本地 `wcc` 全量编译 WXML;WeChat DevTools 本地 `wcsc -lc` 全量编译 WXSS;`npm run check`;最终再次 `bash harness/init.sh` +- 已记录证据:三条 `node --check` 均无语法错误;发布流程检查输出 `Publish flow checks passed.`,并覆盖 45 天自定义有效期没有 30 天上限;readiness 输出 `DevTools readiness checks passed. Static gates passed; DevTools and real-device visual acceptance are still required.`;JSON 检查输出 `Checked 11 JSON files.`;harness 输出 `Harness OK: 6 features checked.`;`git diff --check` 无输出;`wcc`/`wcsc -lc` 均退出 0;`npm run check` 和最终 `bash harness/init.sh` 均通过 +- 更新过的文件或工件:`pages/publish/publish.js`,`pages/publish/publish.wxml`,`utils/store.js`,`cloudfunctions/posts/index.js`,`scripts/check-publish-flow.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` +- 已知风险或未解决问题:尚未在 WeChat DevTools/真机中实际打开 date picker 选择超过 30 天的日期并发布;云端路径仍需要部署更新后的 `posts` 云函数后才会接受新规则 +- 下一步最佳动作:在 DevTools 发布页选择超过 30 天的自定义日期确认 UI 不再限制;部署 `posts` 云函数后再验证云端发布同样接受超过 30 天的自定义有效期 + +### Session 092PublishExpiryLongTermPreset + +- 日期:2026-06-23 +- 工作区:`/Users/bytedance/.codex/worktrees/e329/x`;当前为 detached HEAD `9d2327a` +- 本轮目标:按用户最新反馈把发布有效期预设改成 `1周 / 1月 / 长期 / 自定义` +- 已完成:`utils/config.js` 的有效期预设改为 `1周`、`1月`、`长期`、`自定义`,发布页默认选中 `1周`;长期预设使用 `expiryType: long_term` 标记并在详情、个人发布、活动和管理展示中显示为 `长期有效`,避免展示成很大的天数;本地 `utils/store.js` 与 `cloudfunctions/posts/index.js` 同步接受 `1周/1月/长期` 预设和不设最大日期的自定义 `expiresAt` +- 运行过的验证:`bash harness/init.sh`;`node --check pages/publish/publish.js`;`node --check utils/config.js`;`node --check utils/store.js`;`node --check utils/format.js`;`node --check cloudfunctions/posts/index.js`;`node --no-warnings scripts/check-publish-flow.mjs`;`node --no-warnings scripts/check-candidate-flow.mjs`;`node --no-warnings scripts/check-performance-guards.mjs`;`node --no-warnings scripts/check-devtools-readiness.mjs`;`node scripts/check-json.mjs`;`node harness/check-harness.mjs`;`git diff --check`;WeChat DevTools 本地 `wcc` 全量编译 WXML;WeChat DevTools 本地 `wcsc -lc` 全量编译 WXSS;最终 `npm run check`;最终再次 `bash harness/init.sh` +- 已记录证据:`scripts/check-publish-flow.mjs` 断言有效期选项为 `1周/1月/长期/自定义`、长期展示为 `长期有效`、自定义 45 天仍可发布;候选流和性能 guard 均通过;readiness 输出 `DevTools readiness checks passed. Static gates passed; DevTools and real-device visual acceptance are still required.`;JSON 检查输出 `Checked 11 JSON files.`;harness 输出 `Harness OK: 6 features checked.`;`git diff --check` 无输出;`wcc`/`wcsc -lc` 均退出 0;`npm run check` 和最终 `bash harness/init.sh` 均通过 +- 更新过的文件或工件:`utils/config.js`,`pages/publish/publish.js`,`pages/publish/publish.wxml`,`pages/publish/publish.wxss`,`pages/publish/publish-state.js`,`utils/store.js`,`utils/format.js`,`utils/post-presenter.js`,`pages/detail/detail.js`,`pages/admin/admin-review.js`,`cloudfunctions/posts/index.js`,`scripts/check-publish-flow.mjs`,`scripts/check-candidate-flow.mjs`,`scripts/check-performance-guards.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` +- 已知风险或未解决问题:尚未在真实 WeChat DevTools/真机中手动点击四个有效期选项和发布成功流;readiness 仍报告 DevTools service port 9420 阻塞,这不是 UI passed evidence。云端路径需要部署更新后的 `posts` 云函数后才会接受新的长期预设和自定义规则 +- 下一步最佳动作:在 WeChat DevTools 或真机打开发布页,确认四个有效期按钮、长期详情文案和超过 30 天的自定义日期均符合预期;部署 `posts` 云函数后再验证云端发布 + +### Session 093PublishDefaultLongTermImpact + +- 日期:2026-06-23 +- 工作区:`/Users/bytedance/.codex/worktrees/e329/x`;当前为 detached HEAD `9d2327a` +- 本轮目标:按用户要求把发布有效期默认选择改为 `长期`,并评估修改有效期对既有功能的影响 +- 已完成:发布页默认表单和默认选中态都改为从 `config.expiryOptions` 中查找 `type === 'long_term'` 的选项,因此默认选中 `长期`,重置表单后也保持 `长期`;`scripts/check-publish-flow.mjs` 增加静态守卫,要求默认值来自长期选项、默认 `expiryHours` 使用长期小时数并保留 `expiryType: long_term` +- 功能影响结论:有效期变化会影响任务何时自动变成 `expired`,进而影响列表排序/状态展示、评论、确认、分享接收侧风险提示等所有依赖 `expiresAt > Date.now()` 的功能。默认改为长期后,新任务默认会长期保持 active、可评论、可确认、可在普通列表展示;用户主动选择 `1周`、`1月` 或 `自定义` 时仍按对应截止时间过期;关闭、过时、举报和隐藏逻辑不依赖默认值,仍按原逻辑生效 +- 运行过的验证:`bash harness/init.sh`;`node --check pages/publish/publish.js`;`node --check scripts/check-publish-flow.mjs`;`node --no-warnings scripts/check-publish-flow.mjs`;`npm run check`;最终再次 `bash harness/init.sh`;`git diff --check` +- 已记录证据:发布流专项输出 `Publish flow checks passed.`;`npm run check` 通过并输出 JSON/harness/readiness 全部通过;最终 harness 输出 `Harness init complete.`;`git diff --check` 无输出 +- 更新过的文件或工件:`pages/publish/publish.js`,`scripts/check-publish-flow.mjs`,`harness/feature_list.json`,`harness/claude-progress.md` +- 已知风险或未解决问题:尚未在真实 WeChat DevTools/真机中手动确认发布页初始高亮为 `长期`;DevTools service port 9420 仍阻塞,readiness 不能替代 UI passed evidence。云端路径仍需部署更新后的 `posts` 云函数 +- 下一步最佳动作:在 DevTools UI 手动打开当前 worktree,确认发布页初始有效期高亮 `长期`,发布后详情页显示 `长期有效` diff --git a/harness/feature_list.json b/harness/feature_list.json index 49d692d..40072ea 100644 --- a/harness/feature_list.json +++ b/harness/feature_list.json @@ -209,9 +209,16 @@ "2026-06-12: WeChat DevTools local compilers passed for B publish branch after third iteration: wcc compiled all WXML and wcsc -lc compiled all WXSS with exit code 0 and no output.", "2026-06-12: Attempted WeChat DevTools CLI open and preview for B publish branch after enabling the service port prompt, but both failed with wait IDE port timeout against port 9420; no preview QR/info was produced.", "2026-06-16: Viral publish iteration added utils/publish-spread.js and upgraded the detail page's from=publish success card into a structured spread plan with audience, signal goal, follow-up, image/comment hints, and closed-task no-spread handling.", - "2026-06-16: Added scripts/check-publish-spread.mjs and wired it into scripts/check-devtools-readiness.mjs so npm readiness checks cover category-specific spread copy, lost/found intent differences, resolved/expired no-spread handling, image/comment hints, and share path parameter preservation." + "2026-06-16: Added scripts/check-publish-spread.mjs and wired it into scripts/check-devtools-readiness.mjs so npm readiness checks cover category-specific spread copy, lost/found intent differences, resolved/expired no-spread handling, image/comment hints, and share path parameter preservation.", + "2026-06-23: Publish expiry presets changed from 6h/1d/3d to 1d/1w/1mo plus a custom deadline option.", + "2026-06-23: Custom publish expiry uses date and time pickers, records exact expiresAt when valid, and blocks custom times earlier than 30 minutes from now.", + "2026-06-23: Local store and posts cloud function now validate preset expiry hours [24,168,720] and accept exact custom expiresAt values without a 30-day maximum.", + "2026-06-23: Removed the custom expiry maximum after user feedback; the date picker no longer has an end date and publish-flow checks cover a 45-day custom expiry.", + "2026-06-23: Updated publish expiry presets to 1w/1mo/long-term/custom; long-term posts carry expiryType=long_term and render as 长期有效 instead of a large day count.", + "2026-06-23: Publish now defaults to the long-term expiry option while keeping the visible option order as 1周/1月/长期/自定义.", + "2026-06-23: Verification passed node --check for publish page/state, store, format helper, and posts cloud function; node --no-warnings scripts/check-publish-flow.mjs; node --no-warnings scripts/check-devtools-readiness.mjs; npm run check; bash harness/init.sh; git diff --check; and local WXML/WXSS compiler exits 0." ], - "notes": "图片发布已改为云端必需;仍需部署更新后的 posts 云函数,并在 WeChat DevTools/真机中验证上传、跨用户浏览和过大图片拦截。 2026-06-12 B 组发布迭代新增发布准备度、位置确认和有效期按钮;尚未在 WeChat DevTools/真机验证定位授权、键盘遮挡、图片上传和真实发布闭环。 第二轮 B 组已收敛定位失败/重试路径和 readiness 文案;定位授权弹窗、键盘、图片上传和真实发布仍需 DevTools/真机验证。 第三轮 B 组已将发布主动作显式化为 primaryAction;仍需 DevTools/真机验证真实定位授权、键盘安全区、图片上传和跳转。 2026-06-16 发布后扩散计划只在 from=publish 详情上下文显示,普通详情页不展示;分享路径会移除 from=publish 避免接收者看到发布者专属卡。DevTools CLI open/preview 当前被 9420 服务端口超时阻塞,需在 DevTools UI 中确认服务端口并手动重开项目。" + "notes": "图片发布已改为云端必需;仍需部署更新后的 posts 云函数,并在 WeChat DevTools/真机中验证上传、跨用户浏览和过大图片拦截。 2026-06-12 B 组发布迭代新增发布准备度、位置确认和有效期按钮;尚未在 WeChat DevTools/真机验证定位授权、键盘遮挡、图片上传和真实发布闭环。 第二轮 B 组已收敛定位失败/重试路径和 readiness 文案;定位授权弹窗、键盘、图片上传和真实发布仍需 DevTools/真机验证。 第三轮 B 组已将发布主动作显式化为 primaryAction;仍需 DevTools/真机验证真实定位授权、键盘安全区、图片上传和跳转。 2026-06-16 发布后扩散计划只在 from=publish 详情上下文显示,普通详情页不展示;分享路径会移除 from=publish 避免接收者看到发布者专属卡。2026-06-23 有效期预设已改为 1周/1月/长期/自定义,默认选择长期;自定义时间只校验至少晚于当前 30 分钟,不限制最大日期。长期预设保存为 long_term 并显示 长期有效;功能影响是默认发布任务不会很快自动过期,因此评论/确认/列表展示会长期保持可用,关闭、过时、举报和自定义截止时间仍按原逻辑生效。仍需在 DevTools/真机中实际操作日期/时间 picker、提交本地/云端发布并检查详情页过期文案。DevTools CLI open/preview 当前被 9420 服务端口超时阻塞,需在 DevTools UI 中确认服务端口并手动重开项目。" }, { "id": "detail-trust-001", diff --git a/pages/admin/admin-review.js b/pages/admin/admin-review.js index 8b8747f..c2d6030 100644 --- a/pages/admin/admin-review.js +++ b/pages/admin/admin-review.js @@ -157,7 +157,7 @@ export function decorateAdminPost(post, options = {}) { statusTone: statusTone(post.status), publisher, createdText: formatCreatedAt(post.createdAt), - expiryText: post.status === 'resolved' ? '已关闭' : formatTimeLeft(post.expiresAt), + expiryText: post.status === 'resolved' ? '已关闭' : formatTimeLeft(post.expiresAt, post.expiryType), confirmationText: formatConfirmationText(post.confirmations, post.lastConfirmedAt), riskReasonText: riskReasonText(post), suggestedActionText: suggestedActionText(post), diff --git a/pages/detail/detail.js b/pages/detail/detail.js index ecca885..6eacdbb 100644 --- a/pages/detail/detail.js +++ b/pages/detail/detail.js @@ -62,7 +62,7 @@ function decorateDetailPost(raw) { intentText: intentLabel(raw.intent), statusText: statusLabel(raw.status), createdText: formatCreatedAt(raw.createdAt), - expiryText: raw.status === 'resolved' ? '已关闭' : formatTimeLeft(raw.expiresAt), + expiryText: raw.status === 'resolved' ? '已关闭' : formatTimeLeft(raw.expiresAt, raw.expiryType), distanceText: `${raw.distance}m`, publisherName, publisherAvatarUrl: raw.publisherAvatarUrl || '', diff --git a/pages/publish/publish-state.js b/pages/publish/publish-state.js index 039ce9a..21b23f3 100644 --- a/pages/publish/publish-state.js +++ b/pages/publish/publish-state.js @@ -25,13 +25,21 @@ function locationActionText(locationStatus) { return '确认位置'; } +function hasExpiry(form) { + if (Number(form.expiresAt) > Date.now()) { + return true; + } + return Number(form.expiryHours) > 0; +} + function missingActionText(missingItem, locationStatus) { const actionMap = { 登录: '去登录', 标题: '补标题', 详情: '补详情', 分类: '选分类', - 失物方向: '选失物方向' + 失物方向: '选失物方向', + 有效期: '选有效期' }; if (missingItem === '当前位置') { if (locationStatus === 'locating') { @@ -93,6 +101,7 @@ export function buildPublishState(options = {}) { const categoryDone = present(form.category) && (form.category !== 'lost_found' || present(form.intent)); const locationDone = hasLocation && locationStatus === 'ready'; + const expiryDone = hasExpiry(form); const items = [ { @@ -113,6 +122,12 @@ export function buildPublishState(options = {}) { value: categoryDone ? '已选择' : '待选择', done: categoryDone }, + { + key: 'expiry', + label: '有效期', + value: expiryDone ? '已设置' : '待选择', + done: expiryDone + }, { key: 'location', label: '位置', @@ -134,6 +149,9 @@ export function buildPublishState(options = {}) { if (!categoryDone) { missing.push(form.category === 'lost_found' ? '失物方向' : '分类'); } + if (!expiryDone) { + missing.push('有效期'); + } if (!locationDone) { missing.push('当前位置'); } diff --git a/pages/publish/publish.js b/pages/publish/publish.js index 0a76eed..90d5cea 100644 --- a/pages/publish/publish.js +++ b/pages/publish/publish.js @@ -10,10 +10,19 @@ const MAX_IMAGE_SIZE_TEXT = '1.5MB'; const IMAGE_COMPRESS_QUALITY = 70; const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; const CLOUD_FILE_ID_PREFIX = 'cloud://'; +const CUSTOM_EXPIRY_VALUE = 'custom'; +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; +const CUSTOM_EXPIRY_MIN_OFFSET_MS = 30 * 60 * 1000; const LOCATION_IDLE_SUMMARY = '发布前确认一次当前位置,附近的人会按这个位置看到任务。'; const LOCATION_LOCATING_SUMMARY = '正在确认当前位置,请停留在当前页面。'; const LOCATION_READY_SUMMARY = '已确认当前位置,需要更换时可重新确认。'; const LOCATION_FAILED_SUMMARY = '还没拿到当前位置,请检查微信定位授权,或点重试定位。'; +const DEFAULT_EXPIRY_INDEX = Math.max( + config.expiryOptions.findIndex((option) => option.type === 'long_term'), + 0 +); +const DEFAULT_EXPIRY_OPTION = config.expiryOptions[DEFAULT_EXPIRY_INDEX]; function defaultForm() { return { @@ -22,10 +31,73 @@ function defaultForm() { category: config.categories[0].value, intent: '', placeName: '', - expiryHours: config.expiryOptions[1].value + expiryHours: DEFAULT_EXPIRY_OPTION.value, + expiresAt: 0, + expiryType: DEFAULT_EXPIRY_OPTION.type || '' }; } +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function formatDateValue(timestamp) { + const date = new Date(timestamp); + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +} + +function formatTimeValue(timestamp) { + const date = new Date(timestamp); + return `${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +} + +function formatCustomExpiryText(timestamp) { + const date = new Date(timestamp); + return `截止 ${date.getMonth() + 1}月${date.getDate()}日 ${formatTimeValue(timestamp)}`; +} + +function defaultCustomExpiryTimestamp(now = Date.now()) { + return now + DAY_MS; +} + +function customExpiryError(timestamp, now = Date.now()) { + if (!Number.isFinite(timestamp) || timestamp < now + CUSTOM_EXPIRY_MIN_OFFSET_MS) { + return '自定义时间需至少晚于当前30分钟'; + } + return ''; +} + +function customExpiryBounds(now = Date.now()) { + return { + customExpiryStartDate: formatDateValue(now) + }; +} + +function buildCustomExpiryState(timestamp = defaultCustomExpiryTimestamp()) { + const safeTimestamp = Number.isFinite(Number(timestamp)) + ? Number(timestamp) + : defaultCustomExpiryTimestamp(); + return { + customExpiryTimestamp: safeTimestamp, + customExpiryDate: formatDateValue(safeTimestamp), + customExpiryTime: formatTimeValue(safeTimestamp), + customExpiryText: formatCustomExpiryText(safeTimestamp), + customExpiryError: customExpiryError(safeTimestamp), + ...customExpiryBounds() + }; +} + +function parseCustomExpiryTimestamp(dateValue, timeValue) { + const dateMatch = String(dateValue || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); + const timeMatch = String(timeValue || '').match(/^(\d{2}):(\d{2})$/); + if (!dateMatch || !timeMatch) { + return 0; + } + const [, year, month, day] = dateMatch.map(Number); + const [, hour, minute] = timeMatch.map(Number); + return new Date(year, month - 1, day, hour, minute, 0, 0).getTime(); +} + function shouldUseCloudStorage() { return Boolean(config.cloud && config.cloud.enabled && config.cloud.envId && wx.cloud); } @@ -133,7 +205,9 @@ Page({ lostFoundIntents: config.lostFoundIntents, activeGuide: config.publishGuides[config.categories[0].value], categoryIndex: 0, - expiryIndex: 1, + expiryIndex: DEFAULT_EXPIRY_INDEX, + isCustomExpiry: false, + ...buildCustomExpiryState(), intentIndex: -1, isGuest: true, submitting: false, @@ -215,7 +289,9 @@ Page({ this.setPublishData({ activeGuide: config.publishGuides[config.categories[0].value], categoryIndex: 0, - expiryIndex: 1, + expiryIndex: DEFAULT_EXPIRY_INDEX, + isCustomExpiry: false, + ...buildCustomExpiryState(), intentIndex: -1, imageItems: [], canAddImage: true, @@ -424,14 +500,51 @@ Page({ }, applyExpiryIndex(index) { + const option = this.data.expiryOptions[index]; + const isCustomExpiry = option.value === CUSTOM_EXPIRY_VALUE; + const customState = buildCustomExpiryState(this.data.customExpiryTimestamp); const form = { ...this.data.form, - expiryHours: this.data.expiryOptions[index].value + expiryHours: isCustomExpiry ? 0 : option.value, + expiryType: option.type === 'long_term' ? 'long_term' : '', + // Custom expiry stores an exact deadline; presets keep the old hour-based path. + expiresAt: isCustomExpiry && !customState.customExpiryError + ? customState.customExpiryTimestamp + : 0 }; this.setPublishData({ expiryIndex: index, + isCustomExpiry, + ...customState, + form + }); + }, + + applyCustomExpiry(timestamp) { + const customState = buildCustomExpiryState(timestamp); + const form = { + ...this.data.form, + expiryHours: 0, + expiryType: '', + expiresAt: customState.customExpiryError ? 0 : customState.customExpiryTimestamp + }; + this.setPublishData({ + ...customState, form }); + if (customState.customExpiryError) { + wx.showToast({ title: customState.customExpiryError, icon: 'none' }); + } + }, + + onCustomExpiryDateChange(event) { + const timestamp = parseCustomExpiryTimestamp(event.detail.value, this.data.customExpiryTime); + this.applyCustomExpiry(timestamp); + }, + + onCustomExpiryTimeChange(event) { + const timestamp = parseCustomExpiryTimestamp(this.data.customExpiryDate, event.detail.value); + this.applyCustomExpiry(timestamp); }, async confirmLocation() { diff --git a/pages/publish/publish.wxml b/pages/publish/publish.wxml index 0b9b77b..1b7b4b1 100644 --- a/pages/publish/publish.wxml +++ b/pages/publish/publish.wxml @@ -127,6 +127,21 @@ {{item.label}} + + + 截止时间 + {{customExpiryText}} + + + + {{customExpiryDate}} + + + {{customExpiryTime}} + + + {{customExpiryError}} + diff --git a/pages/publish/publish.wxss b/pages/publish/publish.wxss index d5c1192..099d76b 100644 --- a/pages/publish/publish.wxss +++ b/pages/publish/publish.wxss @@ -422,7 +422,7 @@ .expiry-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12rpx; } @@ -447,6 +447,59 @@ box-shadow: inset 0 0 0 2rpx rgba(31, 102, 88, 0.08); } +.custom-expiry { + margin-top: 16rpx; + padding: 18rpx; + border: 1rpx solid rgba(31, 102, 88, 0.14); + border-radius: 16rpx; + background: #fffefa; +} + +.custom-expiry-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; +} + +.custom-expiry-title { + color: #20352f; + font-size: 25rpx; + font-weight: 900; +} + +.custom-expiry-text { + color: #697970; + font-size: 23rpx; + line-height: 1.35; + text-align: right; +} + +.custom-expiry-controls { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12rpx; + margin-top: 14rpx; +} + +.custom-expiry-picker { + height: 72rpx; + border-radius: 14rpx; + background: #f3f2eb; + color: #1f6658; + font-size: 25rpx; + font-weight: 900; + line-height: 72rpx; + text-align: center; +} + +.custom-expiry-error { + margin-top: 10rpx; + color: #b45b2a; + font-size: 23rpx; + line-height: 1.35; +} + .publish-action .bottom-action-inner { grid-template-columns: minmax(0, 1fr) 186rpx; } diff --git a/scripts/check-candidate-flow.mjs b/scripts/check-candidate-flow.mjs index 65b0b22..5f09eac 100644 --- a/scripts/check-candidate-flow.mjs +++ b/scripts/check-candidate-flow.mjs @@ -9,7 +9,7 @@ const filledForm = { category: 'check_in', intent: '', placeName: '', - expiryHours: 24 + expiryHours: 168 }; function publishState(overrides = {}) { diff --git a/scripts/check-performance-guards.mjs b/scripts/check-performance-guards.mjs index 462c7c8..f37b64a 100644 --- a/scripts/check-performance-guards.mjs +++ b/scripts/check-performance-guards.mjs @@ -210,7 +210,7 @@ await createPost({ category: 'help_needed', latitude: center.latitude, longitude: center.longitude, - expiryHours: 6, + expiryHours: 168, imageUrls: [] }); const localAfterCloudCreate = await listPostsAfterCloudWrite(center, { localOnly: true }); diff --git a/scripts/check-publish-flow.mjs b/scripts/check-publish-flow.mjs index 09c01ee..f0d8b48 100644 --- a/scripts/check-publish-flow.mjs +++ b/scripts/check-publish-flow.mjs @@ -1,14 +1,44 @@ import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import config from '../utils/config.js'; +import { formatTimeLeft } from '../utils/format.js'; import { buildPublishState } from '../pages/publish/publish-state.js'; +assert.deepEqual(config.expiryOptions, [ + { value: 168, label: '1周' }, + { value: 720, label: '1月' }, + { value: config.longTermExpiryHours, label: '长期', type: 'long_term' }, + { value: 'custom', label: '自定义' } +]); +assert.equal(config.expiryOptions.findIndex((option) => option.type === 'long_term'), 2); + +const publishJs = readFileSync(new URL('../pages/publish/publish.js', import.meta.url), 'utf8'); +assert.ok( + publishJs.includes("config.expiryOptions.findIndex((option) => option.type === 'long_term')"), + 'publish page should derive its default expiry from the long-term option' +); +assert.ok( + publishJs.includes('expiryHours: DEFAULT_EXPIRY_OPTION.value'), + 'publish default form should use the long-term expiry value' +); +assert.ok( + publishJs.includes("expiryType: DEFAULT_EXPIRY_OPTION.type || ''"), + 'publish default form should keep the long-term display marker' +); +assert.ok( + publishJs.includes('expiryIndex: DEFAULT_EXPIRY_INDEX'), + 'publish page should select the long-term option by default' +); + const baseForm = { title: '', body: '', category: 'check_in', intent: '', placeName: '', - expiryHours: 24 + expiryHours: config.longTermExpiryHours, + expiryType: 'long_term' }; function state(overrides = {}) { @@ -94,6 +124,22 @@ assert.deepEqual(lostFoundState.missing, ['失物方向']); assert.equal(lostFoundState.buttonText, '选失物方向'); assert.equal(lostFoundState.primaryAction, 'fill'); +const missingExpiryState = state({ + hasLocation: true, + locationStatus: 'ready', + form: { + title: '社区花园今天很适合拍照', + body: '下午光线很好,人也不多。', + category: 'check_in', + expiryHours: 0, + expiresAt: 0 + } +}); +assert.equal(missingExpiryState.ready, false); +assert.deepEqual(missingExpiryState.missing, ['有效期']); +assert.equal(missingExpiryState.buttonText, '选有效期'); +assert.equal(missingExpiryState.items.find((item) => item.key === 'expiry').done, false); + const readyLostFoundState = state({ hasLocation: true, locationStatus: 'ready', @@ -123,8 +169,39 @@ assert.equal(readyState.ready, true); assert.equal(readyState.actionDisabled, false); assert.equal(readyState.buttonText, '发布'); assert.equal(readyState.title, '可以发布到附近'); -assert.equal(readyState.completionText, '4/4'); +assert.equal(readyState.completionText, '5/5'); assert.equal(readyState.primaryAction, 'publish'); assert.match(readyState.note, /2张图片/); +const customExpiryState = state({ + hasLocation: true, + locationStatus: 'ready', + form: { + title: '社区花园今天很适合拍照', + body: '下午光线很好,人也不多。', + category: 'check_in', + expiryHours: 0, + expiresAt: Date.now() + 2 * 60 * 60 * 1000 + } +}); +assert.equal(customExpiryState.ready, true); +assert.equal(customExpiryState.items.find((item) => item.key === 'expiry').value, '已设置'); + +const longCustomExpiryState = state({ + hasLocation: true, + locationStatus: 'ready', + form: { + title: '社区花园长期约拍点', + body: '周末傍晚光线一直不错,可以慢慢约时间。', + category: 'check_in', + expiryHours: 0, + expiresAt: Date.now() + 45 * 24 * 60 * 60 * 1000 + } +}); +assert.equal(longCustomExpiryState.ready, true, 'custom expiry should not have a 30-day maximum.'); +assert.equal( + formatTimeLeft(Date.now() + config.longTermExpiryHours * 60 * 60 * 1000, 'long_term'), + '长期有效' +); + console.log('Publish flow checks passed.'); diff --git a/utils/config.js b/utils/config.js index 2bd47c6..fbe5002 100644 --- a/utils/config.js +++ b/utils/config.js @@ -1,3 +1,5 @@ +export const LONG_TERM_EXPIRY_HOURS = 24 * 365 * 10; + const config = { defaultCenter: { latitude: 39.96685, @@ -61,10 +63,12 @@ const config = { placePlaceholder: '例如:社区广场东侧' } }, + longTermExpiryHours: LONG_TERM_EXPIRY_HOURS, expiryOptions: [ - { value: 6, label: '6小时' }, - { value: 24, label: '1天' }, - { value: 72, label: '3天' } + { value: 168, label: '1周' }, + { value: 720, label: '1月' }, + { value: LONG_TERM_EXPIRY_HOURS, label: '长期', type: 'long_term' }, + { value: 'custom', label: '自定义' } ] }; diff --git a/utils/format.js b/utils/format.js index 902e1c9..87a3093 100644 --- a/utils/format.js +++ b/utils/format.js @@ -148,11 +148,14 @@ export function formatTrustInsight(post = {}, commentCount = 0) { }; } -export function formatTimeLeft(expiresAt) { +export function formatTimeLeft(expiresAt, expiryType = '') { const ms = expiresAt - Date.now(); if (ms <= 0) { return '已过期'; } + if (expiryType === 'long_term') { + return '长期有效'; + } const hours = Math.ceil(ms / (60 * 60 * 1000)); if (hours < 24) { return `${hours}小时后过期`; diff --git a/utils/post-presenter.js b/utils/post-presenter.js index 6982965..cda35a3 100644 --- a/utils/post-presenter.js +++ b/utils/post-presenter.js @@ -66,7 +66,7 @@ export function decoratePost(post) { statusText: statusLabel(post.status), confirmationText: formatConfirmationText(post.confirmations, post.lastConfirmedAt), createdText: formatCreatedAt(post.createdAt), - expiryText: post.status === 'resolved' ? '已关闭' : formatTimeLeft(post.expiresAt) + expiryText: post.status === 'resolved' ? '已关闭' : formatTimeLeft(post.expiresAt, post.expiryType) }; } diff --git a/utils/store.js b/utils/store.js index b7ba3b7..5b2f60d 100644 --- a/utils/store.js +++ b/utils/store.js @@ -11,6 +11,8 @@ const CLOSED_STATUSES = ['hidden', 'resolved']; const POSTS_CACHE_TTL_MS = 8000; const MAX_COMMENTS_PER_POST = 50; const MAX_COMMENT_LENGTH = 120; +const HOUR_MS = 60 * 60 * 1000; +const CUSTOM_EXPIRY_MIN_OFFSET_MS = 30 * 60 * 1000; 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; @@ -355,6 +357,33 @@ function validationError(message) { return error; } +function resolveExpiresAt(input, now) { + const expiresAt = Number(input.expiresAt); + if (Number.isFinite(expiresAt) && expiresAt > 0) { + if (expiresAt < now + CUSTOM_EXPIRY_MIN_OFFSET_MS) { + throw validationError('自定义有效期需至少晚于当前30分钟'); + } + return Math.floor(expiresAt); + } + + const expiryHours = Number(input.expiryHours); + const allowedHours = config.expiryOptions + .map((option) => Number(option.value)) + .filter(Number.isFinite); + if (allowedHours.indexOf(expiryHours) < 0) { + throw validationError('请选择有效期'); + } + return now + expiryHours * HOUR_MS; +} + +function resolveExpiryType(input) { + const expiresAt = Number(input.expiresAt); + if (Number.isFinite(expiresAt) && expiresAt > 0) { + return ''; + } + return Number(input.expiryHours) === config.longTermExpiryHours ? 'long_term' : ''; +} + function postClosedError() { const error = new Error('当前任务已关闭,暂不能评论'); error.code = 'POST_CLOSED'; @@ -494,7 +523,8 @@ export async function createPost(input) { staleCount: 0, reportCount: 0, createdAt: now, - expiresAt: now + Number(input.expiryHours) * 60 * 60 * 1000, + expiresAt: resolveExpiresAt(input, now), + expiryType: resolveExpiryType(input), publisherId: user.id, publisher: String(input.publisher || user.nickname || '匿名用户').trim(), publisherAvatarUrl: user.avatarUrl || '',