feat: refresh paid post detail experience
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
# Community Paid Post Detail Refresh Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将社区付费帖子详情页高度还原到设计稿,并修复免费预览字数与两阶段积分解锁链路。
|
||||
|
||||
**Architecture:** 后端继续通过 `/api/community/postDetail` 和 `/api/community/purchasePost` 提供统一契约,仅修正锁定正文的预览长度。uni-app 详情页在单个页面组件内重组视觉层级,复用现有社区积分、关注、点赞、评论、分享和购买 API,不新增路由或第三方依赖。
|
||||
|
||||
**Tech Stack:** PHP 8 / ThinkPHP、uni-app、Vue 3 `<script setup>`、TypeScript、SCSS、uView 图标、Git、项目部署脚本。
|
||||
|
||||
---
|
||||
|
||||
## 工作区保护
|
||||
|
||||
- 当前工作区已有用户改动,尤其是 `uniapp/src/api/community.ts`、社区列表相关文件、`docs/业务进度管理.md`、`docs/设计稿映射.md` 和 `.playwright-mcp/`。
|
||||
- 只编辑本计划明确列出的源码;提交时逐个指定路径,禁止 `git add .`。
|
||||
- `docs/业务进度管理.md` 与 `docs/设计稿映射.md` 只追加本任务记录,不把原有未提交改动纳入源码提交。
|
||||
- 用户没有要求构建或自动化测试,因此不执行 `npm run build:h5`、PHP 测试或 QA 测试;只进行代码复核、页面热更新对比和项目要求的 server 部署。
|
||||
|
||||
### Task 1: 修复后端免费预览契约
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/app/api/controller/CommunityController.php:85-97`
|
||||
|
||||
- [ ] **Step 1: 将硬编码 20 字符替换为帖子配置值**
|
||||
|
||||
在锁定分支中使用以下逻辑:
|
||||
|
||||
```php
|
||||
$previewLength = (int) ($data['free_content_len'] ?? 100);
|
||||
if ($previewLength <= 0) {
|
||||
$previewLength = 100;
|
||||
}
|
||||
$previewLength = max(20, min(500, $previewLength));
|
||||
|
||||
if (mb_strlen($data['content']) > $previewLength) {
|
||||
$data['content'] = mb_substr($data['content'], 0, $previewLength) . '...';
|
||||
}
|
||||
```
|
||||
|
||||
保留“锁定状态最多返回第一张图片”的现有逻辑,不改变已购买、VIP 免费和作者本人的全文权限。
|
||||
|
||||
同时将特朗普译文生成与 `translated_content` 返回移动到付费权限判断之后:仅当 `is_locked=false` 时生成或返回译文,防止锁定用户通过译文绕过付费权限。
|
||||
|
||||
- [ ] **Step 2: 静态复核后端契约**
|
||||
|
||||
确认以下条件同时成立:
|
||||
|
||||
```text
|
||||
postDetail 仍是免登录接口
|
||||
锁定用户只能拿到免费预览
|
||||
free_content_len 缺失或非法时回落到 100
|
||||
free_content_len 最终限制在 20~500
|
||||
purchasePost 的 confirm=0/1 契约不变
|
||||
锁定帖子不会生成或返回 translated_content
|
||||
```
|
||||
|
||||
建议但本次不自动执行:
|
||||
|
||||
```powershell
|
||||
php -l server/app/api/controller/CommunityController.php
|
||||
```
|
||||
|
||||
预期:`No syntax errors detected`。
|
||||
|
||||
- [ ] **Step 3: 单独提交后端修改**
|
||||
|
||||
```powershell
|
||||
git add -- server/app/api/controller/CommunityController.php
|
||||
git commit -m "fix: honor paid post preview length"
|
||||
```
|
||||
|
||||
### Task 2: 重构详情页数据状态与解锁流程
|
||||
|
||||
**Files:**
|
||||
- Modify: `uniapp/src/packages_community/pages/post_detail.vue:179-635`
|
||||
|
||||
- [ ] **Step 1: 复用积分统计接口并新增页面状态**
|
||||
|
||||
在社区 API 导入中加入已有的 `getCommunityUserStats`,并新增以下状态:
|
||||
|
||||
```ts
|
||||
const detailError = ref(false)
|
||||
const commentError = ref(false)
|
||||
const previewExpanded = ref(false)
|
||||
const userPoints = ref<number | null>(null)
|
||||
const pointsLoading = ref(false)
|
||||
const likePending = ref(false)
|
||||
const commentLikePending = ref<Set<number>>(new Set())
|
||||
```
|
||||
|
||||
积分展示使用:
|
||||
|
||||
```ts
|
||||
const userPointsText = computed(() => {
|
||||
if (!getToken()) return '登录后查看'
|
||||
if (pointsLoading.value) return '加载中'
|
||||
return userPoints.value === null ? '--' : String(userPoints.value)
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 将免费预览拆分为标题和摘要**
|
||||
|
||||
添加纯函数和计算属性,优先按换行、首句、冒号和长度降级拆分:
|
||||
|
||||
```ts
|
||||
const splitPostPreview = (value: unknown) => {
|
||||
const content = String(value || '').replace(/\r\n/g, '\n').trim()
|
||||
if (!content) return { title: '', summary: '' }
|
||||
|
||||
const lines = content.split('\n').map((line) => line.trim()).filter(Boolean)
|
||||
if (lines.length > 1) {
|
||||
return { title: lines[0], summary: lines.slice(1).join('\n') }
|
||||
}
|
||||
|
||||
const sentence = content.match(/^(.+?[。!?.!?])\s*(.*)$/)
|
||||
if (sentence && sentence[1].length <= 56) {
|
||||
return { title: sentence[1], summary: sentence[2] || '' }
|
||||
}
|
||||
|
||||
const colonIndex = content.search(/[::]/)
|
||||
if (colonIndex >= 4 && colonIndex <= 36) {
|
||||
return {
|
||||
title: content.slice(0, colonIndex + 1),
|
||||
summary: content.slice(colonIndex + 1).trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (content.length > 42) {
|
||||
return { title: content.slice(0, 42), summary: content.slice(42).trim() }
|
||||
}
|
||||
|
||||
return { title: content, summary: '' }
|
||||
}
|
||||
|
||||
const postPreview = computed(() => splitPostPreview(post.value?.content))
|
||||
const previewCanExpand = computed(() => postPreview.value.summary.length > 90)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 让详情和评论错误可恢复**
|
||||
|
||||
`fetchDetail()` 开始时清空 `detailError`,失败时设置为 `true`;特朗普翻译改为非阻塞触发,避免翻译请求阻塞首屏:
|
||||
|
||||
```ts
|
||||
post.value = res
|
||||
translatedText.value = res?.translated_content || ''
|
||||
loading.value = false
|
||||
|
||||
if (isTrump && res?.content && !translatedText.value && !autoTranslateStarted.value) {
|
||||
autoTranslateStarted.value = true
|
||||
void requestTranslate()
|
||||
}
|
||||
```
|
||||
|
||||
`fetchComments()` 失败时设置 `commentError`,成功时清空,并保留正文正常展示。
|
||||
|
||||
- [ ] **Step 4: 使用两阶段购买流程**
|
||||
|
||||
将 `handlePurchase()` 改为:
|
||||
|
||||
```ts
|
||||
const applyUnlockedContent = async (result: any) => {
|
||||
if (!post.value || !result?.content) return
|
||||
post.value.content = result.content
|
||||
post.value.is_locked = false
|
||||
post.value.is_purchased = true
|
||||
if (!result.from_cache) {
|
||||
post.value.paid_count = Number(post.value.paid_count || 0) + 1
|
||||
}
|
||||
previewExpanded.value = true
|
||||
await loadUserPoints()
|
||||
}
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!checkLogin() || purchasing.value || !post.value) return
|
||||
purchasing.value = true
|
||||
try {
|
||||
const probe = await purchasePost({ post_id: postId.value, confirm: 0 })
|
||||
if (probe?.content) {
|
||||
await applyUnlockedContent(probe)
|
||||
return
|
||||
}
|
||||
if (!probe?.needs_payment) return
|
||||
|
||||
uni.showModal({
|
||||
title: '确认解锁',
|
||||
content: `确定使用 ${probe.cost || post.value.price_points || 0} 积分解锁该内容吗?`,
|
||||
confirmText: '确认解锁',
|
||||
confirmColor: '#1468f5',
|
||||
success: async (modalResult) => {
|
||||
if (!modalResult.confirm) return
|
||||
purchasing.value = true
|
||||
try {
|
||||
const paidResult = await purchasePost({ post_id: postId.value, confirm: 1 })
|
||||
await applyUnlockedContent(paidResult)
|
||||
uni.showToast({ title: '解锁成功', icon: 'success' })
|
||||
} finally {
|
||||
purchasing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
purchasing.value = false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
实现时确保弹窗确认后的异步失败仍由请求层提示,并避免外层 `finally` 抢先影响确认按钮状态。
|
||||
|
||||
- [ ] **Step 5: 防止点赞计数异常**
|
||||
|
||||
帖子点赞与评论点赞均增加进行中保护,并统一使用非负数:
|
||||
|
||||
```ts
|
||||
const nextLikeCount = (count: unknown, liked: boolean) =>
|
||||
Math.max(0, Number(count || 0) + (liked ? 1 : -1))
|
||||
```
|
||||
|
||||
评论成功后 `await fetchComments()`,再令 `post.value.comment_count = commentTotal.value`,避免本地累计与接口总数不一致。
|
||||
|
||||
### Task 3: 高度还原帖子详情视觉
|
||||
|
||||
**Files:**
|
||||
- Modify: `uniapp/src/packages_community/pages/post_detail.vue:1-177`
|
||||
- Modify: `uniapp/src/packages_community/pages/post_detail.vue:637-1240`
|
||||
|
||||
- [ ] **Step 1: 重构顶部导航**
|
||||
|
||||
模板固定显示“帖子详情”,并在右侧复用分享弹窗:
|
||||
|
||||
```vue
|
||||
<view class="detail-topbar__nav">
|
||||
<view class="detail-topbar__action detail-topbar__action--back" @tap="goBack">
|
||||
<u-icon name="arrow-left" size="40" color="#111111" />
|
||||
</view>
|
||||
<text class="detail-topbar__title">帖子详情</text>
|
||||
<view class="detail-topbar__action" @tap="handleShare">
|
||||
<u-icon name="share" size="42" color="#111111" />
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 合并作者与内容卡**
|
||||
|
||||
使用 `detail-post-card` 承载作者、关注按钮、付费标签、标题和摘要。摘要通过 class 控制三行收起:
|
||||
|
||||
```vue
|
||||
<text
|
||||
v-if="postPreview.summary"
|
||||
class="detail-post-card__summary"
|
||||
:class="{ 'detail-post-card__summary--collapsed': previewCanExpand && !previewExpanded }"
|
||||
>
|
||||
{{ postPreview.summary }}
|
||||
</text>
|
||||
<text v-if="previewCanExpand" class="detail-post-card__expand" @tap="previewExpanded = !previewExpanded">
|
||||
{{ previewExpanded ? '收起' : '展开' }}
|
||||
</text>
|
||||
```
|
||||
|
||||
关注按钮使用描边样式;已关注状态使用浅蓝底。现有私聊入口只在已关注时以紧凑次按钮保留。
|
||||
|
||||
- [ ] **Step 3: 重建解锁卡**
|
||||
|
||||
解锁卡包含锁图形、积分信息、积分明细和说明:
|
||||
|
||||
```vue
|
||||
<view v-if="post.is_locked" class="detail-unlock-card">
|
||||
<view class="detail-unlock-card__lock"><u-icon name="lock" size="58" color="#1468f5" /></view>
|
||||
<view class="detail-unlock-card__content">
|
||||
<text class="detail-unlock-card__title">解锁全文</text>
|
||||
<view class="detail-unlock-card__points">
|
||||
<text>需 </text><text class="is-primary">{{ post.price_points }} 积分</text>
|
||||
<text class="detail-unlock-card__dot">·</text>
|
||||
<text>当前余额 </text><text class="is-primary">{{ userPointsText }}</text>
|
||||
</view>
|
||||
<text class="detail-unlock-card__link" @tap="openPoints">积分明细 ›</text>
|
||||
<view class="detail-unlock-card__btn" :class="{ 'is-disabled': purchasing }" @tap="handlePurchase">
|
||||
<text>{{ purchasing ? '解锁中' : '确认解锁' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-unlock-card__notice">
|
||||
<u-icon name="info-circle" size="26" color="#7c8798" />
|
||||
<text>解锁后永久可读;内容一经解锁不支持退回积分</text>
|
||||
</view>
|
||||
</view>
|
||||
```
|
||||
|
||||
视觉值采用浅蓝渐变、`24rpx` 圆角、蓝色渐变主按钮和足够的卡内留白,430px H5 下不横向溢出。
|
||||
|
||||
- [ ] **Step 4: 重构互动与评论区**
|
||||
|
||||
互动标题和三项数据同行,评论项恢复头像并将点赞、回复、删除放到元信息行。底部输入栏继续固定在 flex 布局末端,`scroll-view` 使用 `flex: 1; min-height: 0`。
|
||||
|
||||
- [ ] **Step 5: 增加加载失败与评论重试状态**
|
||||
|
||||
详情失败区提供“重新加载”和“返回”按钮;评论失败区提供“重新加载评论”。按钮分别调用 `fetchDetail(postId)` 与 `fetchComments()`。
|
||||
|
||||
- [ ] **Step 6: 提交 uni-app 页面修改**
|
||||
|
||||
```powershell
|
||||
git add -- uniapp/src/packages_community/pages/post_detail.vue
|
||||
git commit -m "feat: refresh paid post detail experience"
|
||||
```
|
||||
|
||||
### Task 4: 更新项目记录并进行页面对比复核
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/业务进度管理.md`
|
||||
- Modify: `docs/设计稿映射.md`
|
||||
- Existing: `docs/superpowers/plans/2026-08-02-community-paid-post-detail-refresh.md`
|
||||
|
||||
- [ ] **Step 1: 追加业务进度记录**
|
||||
|
||||
记录以下内容:付费详情视觉还原、两阶段解锁、积分余额、免费预览长度、错误状态和涉及文件。不覆盖文档中现有未提交改动。
|
||||
|
||||
- [ ] **Step 2: 更新设计稿映射**
|
||||
|
||||
新增或更新“社区帖子详情”映射到 `uniapp/src/packages_community/pages/post_detail.vue`,状态标记为“代码已修改,待构建验收”或与实际复核结果一致。
|
||||
|
||||
- [ ] **Step 3: 使用现有 H5 热更新页面进行截图对比**
|
||||
|
||||
重新加载:
|
||||
|
||||
```text
|
||||
http://localhost:5178/packages_community/pages/post_detail?id=8
|
||||
```
|
||||
|
||||
检查固定标题、分享入口、作者内容卡、解锁卡、互动区、评论头像、底部输入栏和 430px 无横向溢出。此步骤不运行 H5 构建。
|
||||
|
||||
- [ ] **Step 4: 审查改动范围**
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
git status --short
|
||||
git log -3 --oneline
|
||||
```
|
||||
|
||||
只审查本任务文件;`.playwright-mcp/` 和用户已有社区改动不得纳入提交。
|
||||
|
||||
### Task 5: 同步 server 修改到测试服
|
||||
|
||||
**Files:**
|
||||
- Deploy source commit containing `server/app/api/controller/CommunityController.php`
|
||||
|
||||
- [ ] **Step 1: 执行项目标准部署脚本**
|
||||
|
||||
```powershell
|
||||
Set-Location D:\www\gs-sport-era
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\deploy-server.ps1 -Sudo -AutoIncremental -AutoIncrementalCommits 3 -RemoteDir /www/wwwroot/test-server.sbnews.net
|
||||
```
|
||||
|
||||
预期:脚本将已提交的 server 运行源码同步到 `/www/wwwroot/test-server.sbnews.net`,不部署到旧目录。
|
||||
|
||||
- [ ] **Step 2: 交付说明**
|
||||
|
||||
列出已提交的后端与 uni-app 提交、部署结果、未执行的构建/自动化测试,以及保留在工作区的用户原有改动。
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user