Update evolution indices and enhance user experience in mini program
- Added new entries for content ranking algorithm adjustments and cross-platform reuse in the evolution indices for backend, team, and mini program development. - Improved user interface elements in the mini program, including the addition of a hidden settings entry and refined guidance for profile modifications. - Enhanced reading statistics display with formatted numbers for better clarity and user engagement. - Updated reading tracker logic to prevent duplicate duration accumulation and ensure accurate reporting of reading progress.
This commit is contained in:
33
.cursor/agent/后端工程师/evolution/2026-03-14.md
Normal file
33
.cursor/agent/后端工程师/evolution/2026-03-14.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 2026-03-14 - 内容排名算法修正(排名分公式)
|
||||
|
||||
## 问题 / 场景
|
||||
|
||||
- 管理端「内容排行」与小程序「精选推荐」共用 `computeArticleRankingSections`,原算法错误:
|
||||
- 使用「原始数值 × 权重」:`hot = readCnt×readWeight + payCnt×payWeight + recencyScore×recencyWeight`
|
||||
- `recencyScore` 为 0–1 的天数衰减,非排名分
|
||||
- 管理端修改权重后,列表不刷新(只调了 loadList,未调 loadRanking)。
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 算法修正(db_book.go computeSectionsWithHotScore)
|
||||
|
||||
- **公式**:热度积分 = 阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分(三权重之和须为 1)
|
||||
- **排名分规则**:
|
||||
- 阅读量前 20 名:第 1 名=20 分 … 第 20 名=1 分,其余 0 分
|
||||
- 最近更新前 30 篇:第 1 名=30 分 … 第 30 名=1 分,其余 0 分
|
||||
- 付款数前 20 名:第 1 名=20 分 … 第 20 名=1 分,其余 0 分
|
||||
- **权重配置**:从 `system_config.article_ranking_weights` 读取 readWeight、recencyWeight、payWeight
|
||||
- **手动覆盖**:若 `chapters.hot_score > 0`,则优先使用该值
|
||||
|
||||
### 与前端约定
|
||||
|
||||
- 管理端保存权重后需同时调用 `loadList()` 和 `loadRanking()`,并关闭弹窗,列表才能立即刷新。
|
||||
|
||||
## 代码位置
|
||||
|
||||
- `soul-api/internal/handler/db_book.go`:`computeSectionsWithHotScore`、`computeArticleRankingSections`
|
||||
- 管理端 `ContentPage.tsx`:`handleSaveRankingWeights` 中 loadRanking + setShowRankingAlgorithmModal(false)
|
||||
|
||||
## 影响
|
||||
|
||||
- 管理端内容排行榜、小程序精选推荐(`/api/miniprogram/book/recommended`)均复用该算法,修正后两端同步生效。
|
||||
@@ -6,3 +6,4 @@
|
||||
| 2026-03-05 | 文章详情@某人:content 内嵌 @ 标记、miniprogram 添加好友接口 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:接口边界不变;overview 聚合接口可选但需降级 | [2026-03-10.md](./2026-03-10.md) |
|
||||
| 2026-03-12 | persons token 字段与 DB 迁移;CKBLead 用 token 兑换 ckb_api_key | [2026-03-12.md](./2026-03-12.md) |
|
||||
| 2026-03-14 | 内容排名算法修正:排名分公式(阅读/新度/付款前 N 名),支持 hot_score 手动覆盖 | [2026-03-14.md](./2026-03-14.md) |
|
||||
|
||||
17
.cursor/agent/团队/evolution/2026-03-14.md
Normal file
17
.cursor/agent/团队/evolution/2026-03-14.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 2026-03-14 - 内容排名算法跨端复用约定
|
||||
|
||||
## 业务规则
|
||||
|
||||
- **热度积分公式**:阅读权重×阅读排名分 + 新度权重×新度排名分 + 付款权重×付款排名分(三权重之和须为 1)
|
||||
- **排名分规则**:阅读量前 20 名(20~1 分)、最近更新前 30 篇(30~1 分)、付款数前 20 名(20~1 分)
|
||||
|
||||
## 跨端复用
|
||||
|
||||
- **管理端**:`/api/db/book?action=ranking` → `computeArticleRankingSections`
|
||||
- **小程序**:`/api/miniprogram/book/recommended` → `computeArticleRankingSections`(取前 3 条)
|
||||
- 两者共用同一套算法、权重配置(`article_ranking_weights`)、置顶配置(`pinned_section_ids`)
|
||||
|
||||
## 约定
|
||||
|
||||
- 排名算法修改只需改 soul-api 一处,管理端与小程序自动同步。
|
||||
- 管理端保存权重后必须调用 `loadRanking()` 刷新列表,否则用户看不到变化。
|
||||
@@ -7,3 +7,4 @@
|
||||
| 2026-03-05 | 分支冲突后各端完整性自查流程 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:菜单/布局新规范基线与入口收敛规则 | [2026-03-10.md](./2026-03-10.md) |
|
||||
| 2026-03-12 | 密钥/token 设计:关联小程序 key、@ 人物 token,不暴露真实密钥、服务端兑换 | [2026-03-12.md](./2026-03-12.md) |
|
||||
| 2026-03-14 | 内容排名算法跨端复用:管理端内容排行与小程序精选推荐共用 computeArticleRankingSections | [2026-03-14.md](./2026-03-14.md) |
|
||||
|
||||
40
.cursor/agent/小程序开发工程师/evolution/2025-03-14-文本长按复制.md
Normal file
40
.cursor/agent/小程序开发工程师/evolution/2025-03-14-文本长按复制.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# 阅读页文本长按选中复制
|
||||
|
||||
> 问题→解决闭环,已升级 miniprogram-dev SKILL
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
小程序阅读页正文长按时无法选中、复制文本。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
|
||||
使用 `text` 组件的 **`user-select`** 属性(基础库 2.12.1+,官方推荐;`selectable` 已废弃):
|
||||
|
||||
```html
|
||||
<text user-select>{{seg.text}}</text>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实施范围(read.wxml)
|
||||
|
||||
- 章节标题:`<text class="chapter-title" user-select>`
|
||||
- 正文片段:text / mention / linkTag 均加 `user-select`
|
||||
- 预览段落:`<text user-select>{{item}}</text>`(原为 view 内直接 `{{item}}`,需用 text 包裹)
|
||||
|
||||
---
|
||||
|
||||
## 备选方案
|
||||
|
||||
- **selectable**:已废弃但多数环境仍可用,若 `user-select` 导致布局异常(inline-block 换行)可回退
|
||||
- **wx.setClipboardData + bindlongpress**:iOS 原生选中失效时,可做长按整段复制兜底
|
||||
|
||||
---
|
||||
|
||||
## Skill 升级
|
||||
|
||||
已写入 miniprogram-dev SKILL §10 文本可选与复制。
|
||||
18
.cursor/agent/小程序开发工程师/evolution/2026-03-14.md
Normal file
18
.cursor/agent/小程序开发工程师/evolution/2026-03-14.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 2026-03-14 - 我的页设置隐藏与资料引导场景梳理
|
||||
|
||||
## 1. 设置入口隐藏
|
||||
|
||||
- **需求**:我的页「设置」菜单项隐藏
|
||||
- **实现**:`my.wxml` 中设置菜单项加 `wx:if="{{false}}"`
|
||||
- **说明**:设置页仍存在,用户可通过推广页「去设置」等入口进入(如绑定微信号用于提现)
|
||||
|
||||
## 2. 资料修改引导场景(何时引导用户修改资料)
|
||||
|
||||
| 场景 | 位置 | 触发条件 | 行为 |
|
||||
|------|------|----------|------|
|
||||
| 登录后 | app.js `_ensureProfileCompletedAfterLogin` | 昵称空/默认/头像空 | Toast「请先完善头像和昵称」→ 跳转 profile-edit |
|
||||
| 阅读页 @某人 | read.js `_doMentionAddFriend` | 已登录但无手机/微信号 | 弹窗「完善资料」→ 确认跳转 profile-edit |
|
||||
| 找伙伴页 | match.js `ensureContactInfo` | 已登录但无手机/微信号 | 页面内弹窗填写联系方式(不跳转) |
|
||||
| 首页链接卡若 | index.js | 无手机/微信号 | 弹窗输入手机号(不跳转 profile-edit) |
|
||||
|
||||
- **判断依据**:`_isProfileIncomplete(user)` 检查 nickname、avatar;手机/微信号需单独拉 `user/profile` 接口判断。
|
||||
@@ -3,9 +3,11 @@
|
||||
| 日期 | 摘要 | 文件 |
|
||||
|------|------|------|
|
||||
| 2025-03-14 | 联网吸收:基础库 3.14、Skyline、隐私按需授权、新 API | [2025-03-14-联网吸收小程序最新开发规则与API.md](./2025-03-14-联网吸收小程序最新开发规则与API.md) |
|
||||
| 2025-03-14 | 阅读页文本长按选中复制:text 组件 user-select | [2025-03-14-文本长按复制.md](./2025-03-14-文本长按复制.md) |
|
||||
| 2026-02-28 | input 边距口诀、match 资源对接弹窗修正 | [2026-02-28.md](./2026-02-28.md) |
|
||||
| 2026-03-03 | 我的页面卡片区边距优化,16rpx 推荐值 | [2026-03-03.md](./2026-03-03.md) |
|
||||
| 2026-03-05 | 分支合并后核心流程自测;app.json 拆行;orders 接口确认 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人高亮与一键加好友(解析@、调添加好友接口) | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:关注内容产物格式与阅读页解析兼容回归 | [2026-03-10.md](./2026-03-10.md) |
|
||||
| 2026-03-12 | 链接标签 mpKey、@ 人物 token 兑换:contentParser、onLinkTagTap、onMentionTap | [2026-03-12.md](./2026-03-12.md) |
|
||||
| 2026-03-14 | 我的页设置入口隐藏;资料修改引导场景梳理(登录后、@某人、找伙伴、链接卡若) | [2026-03-14.md](./2026-03-14.md) |
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
|
||||
| 2026-03-12 | 后端、团队 | 业务规则 | api-dev SKILL、team three-tier-arch | 9.9 买断统一由后端折叠为 hasFullBook/has_full_book,小程序和管理端只认该信号;可通过用户资料开关 OR 订单实现赠送全书 |
|
||||
| 2026-03-13 | 小程序、后端、团队 | 业务规则 | api-dev SKILL、miniprogram-dev SKILL、three-tier-arch SKILL | 文章详情预览统一由后端按 50% 截取,小程序按 accessState 使用预览/全文,外层 content 与 data.content 始终一致以避免泄露全文 |
|
||||
| 2025-03-14 | 小程序 | 最佳实践 | miniprogram-dev SKILL §10 | 阅读页文本长按选中复制:text 组件 user-select(selectable 已废弃),正文/标题/预览均加 user-select |
|
||||
| 2026-03-14 | 后端、管理端、小程序、团队 | 业务规则/bug 修复 | - | 内容排名算法修正(排名分公式);保存权重后 loadRanking 刷新;我的页设置隐藏;资料引导场景梳理 |
|
||||
|
||||
---
|
||||
|
||||
@@ -50,4 +52,4 @@
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-13(文章预览规则统一、小程序与后端对齐)
|
||||
**最后更新**:2026-03-14(排名算法修正、设置隐藏、资料引导梳理)
|
||||
|
||||
@@ -27,9 +27,10 @@ soul-api(Go + Gin + GORM + MySQL)提供三组路由:`/api/miniprogram/*`
|
||||
| 2026-03-12 | persons 表新增 token 字段(add-persons-token.sql);CKBLead 用 token 兑换 ckb_api_key | 已完成 |
|
||||
| 2026-03-12 | 9.9 买断后端开关方案:users 增手动 fullbook 开关,purchase-status/check-purchased 折叠为统一 hasFullBook/has_full_book,小程序免改即支持赠送全书 | 已完成 |
|
||||
| 2026-03-13 | 文章详情预览统一与安全:previewContent 改为截取正文前 50%,findChapterAndRespond 保证外层 content 与 data.content 一致,未授权只返回预览 | 已完成 |
|
||||
| 2026-03-14 | 内容排名算法修正:computeSectionsWithHotScore 改为排名分公式(阅读/新度/付款前 N 名得分),支持手动 hot_score 覆盖 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-13
|
||||
**最后更新**:2026-03-14
|
||||
|
||||
@@ -24,9 +24,10 @@ Soul 创业派对全项目架构与约定:路由隔离(miniprogram/admin/db
|
||||
| 2026-03-12 | 密钥/token 设计:关联小程序 key、@ 人物 token;不暴露真实密钥、服务端兑换 | 已完成 |
|
||||
| 2026-03-12 | 9.9 买断团队约定:后端统一输出 hasFullBook/has_full_book,小程序和管理端只认该信号;支持通过用户资料开关 OR 订单赠送全书且不影响 VIP 逻辑 | 已完成 |
|
||||
| 2026-03-13 | 文章详情预览规则统一:预览长度由后端统一按 50% 计算,小程序按 accessState 切换预览/全文,接口约定 content 与 data.content 始终一致 | 已完成 |
|
||||
| 2026-03-14 | 内容排名算法跨端复用:管理端内容排行与小程序精选推荐共用 computeArticleRankingSections,排名分公式、权重配置统一 | 已完成 |
|
||||
|
||||
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-13
|
||||
**最后更新**:2026-03-14
|
||||
|
||||
@@ -29,9 +29,11 @@
|
||||
| 2026-03-11 | 以界面定需求:小程序界面清单纳入《以界面定需求》;展示以用户资料为准,与现有实现一致 | 已完成 |
|
||||
| 2026-03-12 | 链接标签 mpKey 兑换 appId;@ 人物 token 兑换 ckb_api_key;contentParser、onLinkTagTap、onMentionTap | 已完成 |
|
||||
| 2026-03-13 | 阅读页文章预览与付费解锁对齐:预览长度改由后端统一计算,前端按 accessState 显示预览/全文,避免 data.content 泄露全文 | 已完成 |
|
||||
| 2025-03-14 | 阅读页文本长按选中复制:text 组件 user-select,已升级 SKILL §10 | 已完成 |
|
||||
| 2026-03-14 | 我的页设置入口隐藏(wx:if);资料修改引导场景梳理(登录后、@某人、找伙伴、链接卡若) | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-13
|
||||
**最后更新**:2026-03-14
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
| 2026-03-10 | Toast 通知系统落地:创建 utils/toast.ts(纯原生),全系统 18 文件约 90 处 alert 全部替换为 toast.success/error/info | 已完成 |
|
||||
| 2026-03-11 | 以界面定需求:管理端界面清单纳入《以界面定需求》,作为验收基准 | 已完成 |
|
||||
| 2026-03-12 | ContentPage TypeScript 严格类型修复;关联小程序 key、@ 人物 token 设计(链接标签存 key、PersonItem.id=token) | 已完成 |
|
||||
| 2026-03-14 | 排名算法权重保存后刷新:handleSaveRankingWeights 成功后 loadRanking + 关闭弹窗,列表立即更新 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-12
|
||||
**最后更新**:2026-03-14
|
||||
|
||||
17
.cursor/agent/管理端开发工程师/evolution/2026-03-14.md
Normal file
17
.cursor/agent/管理端开发工程师/evolution/2026-03-14.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 2026-03-14 - 内容排行权重保存后刷新
|
||||
|
||||
## 问题 / 场景
|
||||
|
||||
- 管理端「排名算法」弹窗修改权重并保存后,内容排行榜列表和排序不更新。
|
||||
- 原因:`handleSaveRankingWeights` 成功时只调用了 `loadList()`(章节树),未调用 `loadRanking()`(内容排行榜)。
|
||||
|
||||
## 解决方案
|
||||
|
||||
- 保存成功后:
|
||||
1. `setShowRankingAlgorithmModal(false)` 关闭弹窗
|
||||
2. `loadList()` 刷新章节树(含 hotScore)
|
||||
3. `loadRanking()` 刷新内容排行榜
|
||||
|
||||
## 代码位置
|
||||
|
||||
- `soul-admin/src/pages/content/ContentPage.tsx`:`handleSaveRankingWeights`
|
||||
@@ -3,6 +3,7 @@
|
||||
| 日期 | 摘要 | 文件 |
|
||||
|------|------|------|
|
||||
| 2026-03-12 | ContentPage TypeScript 严格类型修复;关联小程序 key、@ 人物 token 设计 | [2026-03-12.md](./2026-03-12.md) |
|
||||
| 2026-03-14 | 排名算法权重保存后 loadRanking 刷新、关闭弹窗 | [2026-03-14.md](./2026-03-14.md) |
|
||||
| 2026-03-05 | 分支合并后全功能自测,404/异常接口记录 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人:编辑页插入 @用户、保存约定 content 格式 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局:主导航收敛、Settings Tab 承载 author/admin | [2026-03-10.md](./2026-03-10.md) |
|
||||
|
||||
@@ -84,7 +84,15 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
|
||||
|
||||
---
|
||||
|
||||
## 9. 何时使用本 Skill
|
||||
## 9. 文本可选与复制(阅读类内容)
|
||||
|
||||
- **长按选中复制**:需支持长按选中的文本用 `<text user-select>...</text>`(基础库 2.12.1+,`selectable` 已废弃)。
|
||||
- **适用**:章节标题、正文段落、@ 提及、# 链接标签、预览内容等。
|
||||
- **注意**:`user-select` 会使 text 显示为 inline-block,若布局异常可回退 `selectable`;iOS 原生选中失效时可用 `bindlongpress` + `wx.setClipboardData` 做整段复制兜底。
|
||||
|
||||
---
|
||||
|
||||
## 10. 何时使用本 Skill
|
||||
|
||||
- 在 **miniprogram/** 下新增或修改页面、组件、utils 时。
|
||||
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
|
||||
@@ -92,5 +100,6 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
|
||||
- 做登录、手机号、推荐码等涉及用户信息的授权时(遵循 §8 隐私按需授权)。
|
||||
- 做表单、input/textarea 样式时(遵循 §6,用 view 包裹,padding 写在 view 上)。
|
||||
- 做个人中心、设置页布局时(遵循 §7,卡片区边距 16rpx)。
|
||||
- 做阅读、文章等需长按复制的文本时(遵循 §9,text 加 user-select)。
|
||||
|
||||
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。
|
||||
|
||||
@@ -217,16 +217,16 @@ App({
|
||||
return isDefaultNickname || noAvatar
|
||||
},
|
||||
|
||||
// 登录后若资料未完善,引导跳转到资料编辑页
|
||||
// 登录后若资料未完善,引导跳转到头像昵称引导页
|
||||
_ensureProfileCompletedAfterLogin(user) {
|
||||
try {
|
||||
if (!user || !this._isProfileIncomplete(user)) return
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
// 避免在资料页内重复跳转
|
||||
if (current && current.route === 'pages/profile-edit/profile-edit') return
|
||||
// 避免在头像昵称页或资料编辑页内重复跳转
|
||||
if (current && (current.route === 'pages/avatar-nickname/avatar-nickname' || current.route === 'pages/profile-edit/profile-edit')) return
|
||||
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
|
||||
} catch (e) {
|
||||
console.warn('[App] 跳转资料编辑页失败:', e)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"pages/mentors/mentors",
|
||||
"pages/mentor-detail/mentor-detail",
|
||||
"pages/profile-show/profile-show",
|
||||
"pages/profile-edit/profile-edit"
|
||||
"pages/profile-edit/profile-edit",
|
||||
"pages/avatar-nickname/avatar-nickname"
|
||||
],
|
||||
"window": {
|
||||
"backgroundTextStyle": "light",
|
||||
|
||||
155
miniprogram/pages/avatar-nickname/avatar-nickname.js
Normal file
155
miniprogram/pages/avatar-nickname/avatar-nickname.js
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Soul创业派对 - 头像昵称引导页
|
||||
* 登录后资料未完善时引导用户修改默认头像和昵称,仅包含头像+昵称两项
|
||||
*/
|
||||
const app = getApp()
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
avatar: '',
|
||||
nickname: '',
|
||||
saving: false,
|
||||
showAvatarModal: false,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
|
||||
this.loadFromUser()
|
||||
},
|
||||
|
||||
loadFromUser() {
|
||||
const user = app.globalData.userInfo
|
||||
if (!app.globalData.isLoggedIn || !user?.id) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
setTimeout(() => getApp().goBackOrToHome(), 1500)
|
||||
return
|
||||
}
|
||||
const nickname = (user.nickname || user.nickName || '').trim()
|
||||
const avatar = user.avatar || user.avatarUrl || ''
|
||||
this.setData({ nickname, avatar })
|
||||
},
|
||||
|
||||
goBack() {
|
||||
getApp().goBackOrToHome()
|
||||
},
|
||||
|
||||
onNicknameInput(e) {
|
||||
this.setData({ nickname: e.detail.value })
|
||||
},
|
||||
onNicknameChange(e) {
|
||||
this.setData({ nickname: e.detail.value })
|
||||
},
|
||||
|
||||
onAvatarTap() {
|
||||
wx.showActionSheet({
|
||||
itemList: ['使用微信头像', '从相册选择'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) {
|
||||
this.setData({ showAvatarModal: true })
|
||||
} else if (res.tapIndex === 1) {
|
||||
this.chooseAvatarFromAlbum()
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
closeAvatarModal() {
|
||||
this.setData({ showAvatarModal: false })
|
||||
},
|
||||
|
||||
chooseAvatarFromAlbum() {
|
||||
wx.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const tempPath = res.tempFiles[0].tempFilePath
|
||||
await this.uploadAndSaveAvatar(tempPath)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async onChooseAvatar(e) {
|
||||
const tempAvatarUrl = e.detail?.avatarUrl
|
||||
this.setData({ showAvatarModal: false })
|
||||
if (!tempAvatarUrl) return
|
||||
await this.uploadAndSaveAvatar(tempAvatarUrl)
|
||||
},
|
||||
|
||||
async uploadAndSaveAvatar(tempPath) {
|
||||
wx.showLoading({ title: '上传中...', mask: true })
|
||||
try {
|
||||
const uploadRes = await new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: app.globalData.baseUrl + '/api/miniprogram/upload',
|
||||
filePath: tempPath,
|
||||
name: 'file',
|
||||
formData: { folder: 'avatars' },
|
||||
success: (r) => {
|
||||
try {
|
||||
const data = JSON.parse(r.data)
|
||||
if (data.success) resolve(data)
|
||||
else reject(new Error(data.error || '上传失败'))
|
||||
} catch {
|
||||
reject(new Error('解析失败'))
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
|
||||
this.setData({ avatar: avatarUrl })
|
||||
await app.request({
|
||||
url: '/api/miniprogram/user/profile',
|
||||
method: 'POST',
|
||||
data: { userId: app.globalData.userInfo?.id, avatar: avatarUrl },
|
||||
})
|
||||
if (app.globalData.userInfo) {
|
||||
app.globalData.userInfo.avatar = avatarUrl
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
}
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '头像已更新', icon: 'success' })
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const nickname = (this.data.nickname || '').trim()
|
||||
const avatar = (this.data.avatar || '').trim()
|
||||
if (!nickname) {
|
||||
wx.showToast({ title: '请输入昵称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ saving: true })
|
||||
try {
|
||||
await app.request({
|
||||
url: '/api/miniprogram/user/profile',
|
||||
method: 'POST',
|
||||
data: { userId, avatar: avatar || undefined, nickname },
|
||||
})
|
||||
if (app.globalData.userInfo) {
|
||||
if (nickname) app.globalData.userInfo.nickname = nickname
|
||||
if (avatar) app.globalData.userInfo.avatar = avatar
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
}
|
||||
wx.showToast({ title: '保存成功', icon: 'success' })
|
||||
setTimeout(() => getApp().goBackOrToHome(), 800)
|
||||
} catch (e) {
|
||||
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
|
||||
}
|
||||
this.setData({ saving: false })
|
||||
},
|
||||
|
||||
goToFullProfile() {
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
},
|
||||
})
|
||||
4
miniprogram/pages/avatar-nickname/avatar-nickname.json
Normal file
4
miniprogram/pages/avatar-nickname/avatar-nickname.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "完善资料",
|
||||
"usingComponents": {}
|
||||
}
|
||||
67
miniprogram/pages/avatar-nickname/avatar-nickname.wxml
Normal file
67
miniprogram/pages/avatar-nickname/avatar-nickname.wxml
Normal file
@@ -0,0 +1,67 @@
|
||||
<!--Soul创业派对 - 头像昵称引导页,仅头像+昵称-->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><text class="back-icon">‹</text></view>
|
||||
<text class="nav-title">完善资料</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<view class="content">
|
||||
<!-- 引导文案 -->
|
||||
<view class="guide-card">
|
||||
<text class="guide-icon">👋</text>
|
||||
<text class="guide-title">完善头像和昵称</text>
|
||||
<text class="guide-desc">让他人更好地认识你,展示更专业的形象</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-wrap" bindtap="onAvatarTap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
|
||||
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
|
||||
</view>
|
||||
<view class="avatar-camera">📷</view>
|
||||
</view>
|
||||
<text class="avatar-change">点击更换头像</text>
|
||||
</view>
|
||||
|
||||
<!-- 昵称 -->
|
||||
<view class="form-section">
|
||||
<text class="form-label">昵称</text>
|
||||
<view class="form-input-wrap">
|
||||
<input
|
||||
class="form-input-inner"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
value="{{nickname}}"
|
||||
bindinput="onNicknameInput"
|
||||
bindchange="onNicknameChange"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
<text class="input-tip">微信用户可点击输入框自动填充昵称,或手动输入</text>
|
||||
</view>
|
||||
|
||||
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
|
||||
{{saving ? '保存中...' : '完成'}}
|
||||
</view>
|
||||
|
||||
<view class="link-row" bindtap="goToFullProfile">
|
||||
<text class="link-text">完善更多资料</text>
|
||||
<text class="link-arrow">→</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 头像弹窗:使用微信头像 -->
|
||||
<view class="modal-overlay" wx:if="{{showAvatarModal}}" bindtap="closeAvatarModal">
|
||||
<view class="modal-content avatar-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeAvatarModal">✕</view>
|
||||
<text class="avatar-modal-title">使用微信头像</text>
|
||||
<text class="avatar-modal-desc">点击下方按钮,一键同步当前微信头像</text>
|
||||
<button class="btn-choose-avatar" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">使用微信头像</button>
|
||||
<view class="avatar-modal-cancel" bindtap="closeAvatarModal">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
271
miniprogram/pages/avatar-nickname/avatar-nickname.wxss
Normal file
271
miniprogram/pages/avatar-nickname/avatar-nickname.wxss
Normal file
@@ -0,0 +1,271 @@
|
||||
/* Soul创业派对 - 头像昵称引导页 */
|
||||
.page {
|
||||
background: #050B14;
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 24rpx;
|
||||
background: rgba(5, 11, 20, 0.9);
|
||||
backdrop-filter: blur(8rpx);
|
||||
border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.nav-back {
|
||||
width: 60rpx;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
.back-icon {
|
||||
font-size: 44rpx;
|
||||
color: #5EEAD4;
|
||||
}
|
||||
.nav-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-placeholder {
|
||||
width: 60rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 32rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 48rpx 32rpx;
|
||||
background: rgba(94, 234, 212, 0.08);
|
||||
border: 1rpx solid rgba(94, 234, 212, 0.25);
|
||||
border-radius: 24rpx;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
.guide-icon {
|
||||
font-size: 64rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
.guide-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #5EEAD4;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.guide-desc {
|
||||
font-size: 26rpx;
|
||||
color: rgba(148, 163, 184, 0.95);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
width: 192rpx;
|
||||
height: 192rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #5EEAD4;
|
||||
box-shadow: 0 0 30rpx rgba(94, 234, 212, 0.3);
|
||||
}
|
||||
.avatar-inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 72rpx;
|
||||
font-weight: bold;
|
||||
color: #5EEAD4;
|
||||
background: rgba(94, 234, 212, 0.2);
|
||||
}
|
||||
.avatar-camera {
|
||||
position: absolute;
|
||||
bottom: -8rpx;
|
||||
right: -8rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
background: #5EEAD4;
|
||||
color: #000;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
border: 4rpx solid #050B14;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.avatar-change {
|
||||
font-size: 28rpx;
|
||||
color: #5EEAD4;
|
||||
font-weight: 500;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #94A3B8;
|
||||
margin-bottom: 12rpx;
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
.form-input-wrap {
|
||||
padding: 24rpx 32rpx;
|
||||
background: #17212F;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-input-inner {
|
||||
width: 100%;
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
.input-tip {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
margin-left: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
line-height: 96rpx;
|
||||
text-align: center;
|
||||
background: #5EEAD4;
|
||||
color: #050B14;
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
border-radius: 24rpx;
|
||||
margin-top: 24rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.save-btn[disabled] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
.link-text {
|
||||
font-size: 28rpx;
|
||||
color: #94A3B8;
|
||||
}
|
||||
.link-arrow {
|
||||
font-size: 28rpx;
|
||||
color: #5EEAD4;
|
||||
}
|
||||
|
||||
/* 头像弹窗 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(16rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
max-width: 640rpx;
|
||||
background: #0b1220;
|
||||
border-radius: 32rpx;
|
||||
padding: 48rpx;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 24rpx;
|
||||
right: 24rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.avatar-modal-title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
.avatar-modal-desc {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #94A3B8;
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.btn-choose-avatar {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #5EEAD4;
|
||||
color: #050B14;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 44rpx;
|
||||
border: none;
|
||||
}
|
||||
.btn-choose-avatar::after {
|
||||
border: none;
|
||||
}
|
||||
.avatar-modal-cancel {
|
||||
margin-top: 24rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
@@ -482,49 +482,24 @@ Page({
|
||||
wx.switchTab({ url: '/pages/match/match' })
|
||||
},
|
||||
|
||||
// 最新新增:用 latest-chapters 接口(后端按 updated_at 取前 N 条),不拉全量,支持万级文章
|
||||
async loadLatestChapters() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
const chapters = (res && res.data) || (res && res.chapters) || []
|
||||
const res = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
|
||||
const list = (res && res.data) ? res.data : []
|
||||
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
|
||||
const exclude = c => !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
|
||||
// stitch_soul:优先取 isNew 标记的章节;若无则取最近更新的前 10 章(排除序言/尾声/附录)
|
||||
let candidates = chapters.filter(c => (c.isNew || c.is_new) === true && exclude(c))
|
||||
if (candidates.length === 0) {
|
||||
candidates = chapters.filter(exclude)
|
||||
}
|
||||
// 解析「第X场」用于倒序,最新(场次大)放在最上方
|
||||
const sessionNum = (c) => {
|
||||
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
|
||||
const m = title.match(/第\s*(\d+)\s*场/) || title.match(/第(\d+)场/)
|
||||
if (m) return parseInt(m[1], 10)
|
||||
const id = c.id != null ? String(c.id) : ''
|
||||
if (/^\d+$/.test(id)) return parseInt(id, 10)
|
||||
return 0
|
||||
}
|
||||
const latest = candidates
|
||||
.sort((a, b) => {
|
||||
const na = sessionNum(a)
|
||||
const nb = sessionNum(b)
|
||||
if (na !== nb) return nb - na // 场次倒序:最新在上
|
||||
return new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0)
|
||||
})
|
||||
const latest = list
|
||||
.filter(exclude)
|
||||
.slice(0, 10)
|
||||
.map(c => {
|
||||
const d = new Date(c.updatedAt || c.updated_at || Date.now())
|
||||
const title = c.section_title || c.sectionTitle || c.title || c.chapterTitle || ''
|
||||
const rawContent = (c.content || '').replace(/<[^>]+>/g, '').trim()
|
||||
// 描述仅用正文摘要,避免 #id 或标题重复;截取 36 字
|
||||
let desc = ''
|
||||
if (rawContent && rawContent.length > 0) {
|
||||
const clean = rawContent.replace(/^#[\d.]+\s*/, '').trim()
|
||||
desc = clean.length > 36 ? clean.slice(0, 36) + '...' : clean
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
mid: c.mid ?? c.MID ?? 0,
|
||||
title,
|
||||
desc,
|
||||
desc: '', // latest-chapters 不返回 content,避免大表全量加载
|
||||
price: c.price ?? 1,
|
||||
dateStr: `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
const app = getApp()
|
||||
const { formatStatNum } = require('../../utils/util.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -28,6 +29,9 @@ Page({
|
||||
// 阅读统计
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0,
|
||||
readCountText: '0',
|
||||
totalReadTimeText: '0',
|
||||
matchHistoryText: '0',
|
||||
|
||||
// 最近阅读
|
||||
recentChapters: [],
|
||||
@@ -128,25 +132,32 @@ Page({
|
||||
earningsLoading: true,
|
||||
recentChapters: [],
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0
|
||||
matchHistory: 0,
|
||||
readCountText: '0',
|
||||
totalReadTimeText: '0',
|
||||
matchHistoryText: '0'
|
||||
})
|
||||
this.loadDashboardStats()
|
||||
this.loadMyEarnings()
|
||||
this.loadPendingConfirm()
|
||||
this.loadVipStatus()
|
||||
} else {
|
||||
const guestReadCount = app.getReadCount()
|
||||
this.setData({
|
||||
isLoggedIn: false,
|
||||
userInfo: null,
|
||||
userIdShort: '',
|
||||
readCount: app.getReadCount(),
|
||||
readCount: guestReadCount,
|
||||
readCountText: formatStatNum(guestReadCount),
|
||||
referralCount: 0,
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: false,
|
||||
recentChapters: [],
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0
|
||||
matchHistory: 0,
|
||||
totalReadTimeText: '0',
|
||||
matchHistoryText: '0'
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -175,10 +186,16 @@ Page({
|
||||
}))
|
||||
: []
|
||||
|
||||
const readCount = Number(res.data.readCount || 0)
|
||||
const totalReadTime = Number(res.data.totalReadMinutes || 0)
|
||||
const matchHistory = Number(res.data.matchHistory || 0)
|
||||
this.setData({
|
||||
readCount: Number(res.data.readCount || 0),
|
||||
totalReadTime: Number(res.data.totalReadMinutes || 0),
|
||||
matchHistory: Number(res.data.matchHistory || 0),
|
||||
readCount,
|
||||
totalReadTime,
|
||||
matchHistory,
|
||||
readCountText: formatStatNum(readCount),
|
||||
totalReadTimeText: formatStatNum(totalReadTime),
|
||||
matchHistoryText: formatStatNum(matchHistory),
|
||||
recentChapters
|
||||
})
|
||||
} catch (e) {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</view>
|
||||
<view class="profile-stats-row">
|
||||
<view class="profile-stat" bindtap="goToChapters">
|
||||
<text class="profile-stat-val">{{readCount}}</text>
|
||||
<text class="profile-stat-val">{{readCountText}}</text>
|
||||
<text class="profile-stat-label">已读章节</text>
|
||||
</view>
|
||||
<view class="profile-stat" bindtap="goToReferral">
|
||||
@@ -94,17 +94,17 @@
|
||||
<view class="stats-grid">
|
||||
<view class="stat-box" bindtap="goToChapters">
|
||||
<image class="stat-icon-img" src="/assets/icons/book-open-teal.svg" mode="aspectFit"/>
|
||||
<text class="stat-num">{{readCount}}</text>
|
||||
<text class="stat-num">{{readCountText}}</text>
|
||||
<text class="stat-label">已读章节</text>
|
||||
</view>
|
||||
<view class="stat-box" bindtap="goToChapters">
|
||||
<image class="stat-icon-img" src="/assets/icons/clock-teal.svg" mode="aspectFit"/>
|
||||
<text class="stat-num">{{totalReadTime}}</text>
|
||||
<text class="stat-num">{{totalReadTimeText}}</text>
|
||||
<text class="stat-label">阅读分钟</text>
|
||||
</view>
|
||||
<view class="stat-box" bindtap="goToMatch">
|
||||
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
|
||||
<text class="stat-num">{{matchHistory}}</text>
|
||||
<text class="stat-num">{{matchHistoryText}}</text>
|
||||
<text class="stat-label">匹配伙伴</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -154,7 +154,7 @@
|
||||
</view>
|
||||
<text class="menu-arrow">›</text>
|
||||
</view>
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
|
||||
<view class="menu-item" wx:if="{{false}}" bindtap="handleMenuTap" data-id="settings">
|
||||
<view class="menu-left">
|
||||
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
|
||||
<text class="menu-text">设置</text>
|
||||
|
||||
@@ -95,6 +95,19 @@ Page({
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
onShareAppMessage() {
|
||||
const ref = app.getMyReferralCode()
|
||||
return {
|
||||
title: 'Soul创业派对 - 编辑资料',
|
||||
path: ref ? `/pages/profile-edit/profile-edit?ref=${ref}` : '/pages/profile-edit/profile-edit'
|
||||
}
|
||||
},
|
||||
|
||||
onShareTimeline() {
|
||||
const ref = app.getMyReferralCode()
|
||||
return { title: 'Soul创业派对 - 编辑资料', query: ref ? `ref=${ref}` : '' }
|
||||
},
|
||||
|
||||
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
|
||||
onNicknameChange(e) { this.setData({ nickname: e.detail.value }) },
|
||||
onRegionInput(e) { this.setData({ region: e.detail.value }) },
|
||||
|
||||
@@ -163,8 +163,11 @@
|
||||
.btn-choose-avatar {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #5EEAD4;
|
||||
color: #050B14;
|
||||
font-size: 30rpx;
|
||||
|
||||
@@ -170,10 +170,10 @@ class ReadingTracker {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
|
||||
// 计算本次上报的时长
|
||||
// 计算本次上报的时长(仅发送增量 delta,后端会累加,避免重复累加导致阅读分钟数异常)
|
||||
const now = Date.now()
|
||||
const duration = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
|
||||
this.activeTracker.totalDuration += duration
|
||||
const delta = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
|
||||
this.activeTracker.totalDuration += delta
|
||||
this.activeTracker.lastScrollTime = now
|
||||
|
||||
try {
|
||||
@@ -183,7 +183,7 @@ class ReadingTracker {
|
||||
userId,
|
||||
sectionId: this.activeTracker.sectionId,
|
||||
progress: this.activeTracker.maxProgress,
|
||||
duration: this.activeTracker.totalDuration,
|
||||
duration: Math.max(0, delta),
|
||||
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
|
||||
completedAt: this.activeTracker.completedAt
|
||||
}
|
||||
|
||||
@@ -32,6 +32,18 @@ const formatMoney = (amount, decimals = 2) => {
|
||||
return Number(amount).toFixed(decimals)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化统计数字:≥1万显示 x.xw,≥1千显示 x.xk,否则原样
|
||||
* @param {number} n - 原始数字
|
||||
* @returns {string}
|
||||
*/
|
||||
const formatStatNum = n => {
|
||||
const num = Number(n) || 0
|
||||
if (num >= 10000) return (num / 10000).toFixed(1).replace(/\.0$/, '') + 'w'
|
||||
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
return String(num)
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (fn, delay = 300) => {
|
||||
let timer = null
|
||||
@@ -166,6 +178,7 @@ module.exports = {
|
||||
formatTime,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatStatNum,
|
||||
formatNumber,
|
||||
debounce,
|
||||
throttle,
|
||||
|
||||
@@ -258,7 +258,7 @@ export function UsersPage() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingUser) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, phone: formData.phone || undefined, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
|
||||
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', { phone: formData.phone, nickname: formData.nickname, password: formData.password, isAdmin: formData.isAdmin })
|
||||
@@ -1057,7 +1057,7 @@ export function UsersPage() {
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2"><Label className="text-gray-300">手机号</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入手机号" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} disabled={!!editingUser} /></div>
|
||||
<div className="space-y-2"><Label className="text-gray-300">手机号</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入手机号" value={formData.phone} onChange={(e) => setFormData({ ...formData, phone: e.target.value })} /></div>
|
||||
<div className="space-y-2"><Label className="text-gray-300">昵称</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="请输入昵称" value={formData.nickname} onChange={(e) => setFormData({ ...formData, nickname: e.target.value })} /></div>
|
||||
<div className="space-y-2"><Label className="text-gray-300">{editingUser ? '新密码 (留空则不修改)' : '密码'}</Label><Input type="password" className="bg-[#0a1628] border-gray-700 text-white" placeholder={editingUser ? '留空则不修改' : '请输入密码'} value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} /></div>
|
||||
<div className="flex items-center justify-between"><Label className="text-gray-300">管理员权限</Label><Switch checked={formData.isAdmin} onCheckedChange={(c) => setFormData({ ...formData, isAdmin: c })} /></div>
|
||||
|
||||
@@ -540,14 +540,12 @@ func DBUsersList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": nil})
|
||||
return
|
||||
}
|
||||
// 填充 hasFullBook(含 is_vip 或 orders)
|
||||
// 填充 hasFullBook(含 orders、is_vip、手动设置的 has_full_book)
|
||||
var cnt int64
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND (status = ? OR status = ?) AND (product_type = ? OR product_type = ?)",
|
||||
id, "paid", "completed", "fullbook", "vip").Count(&cnt)
|
||||
user.HasFullBook = ptrBool(cnt > 0)
|
||||
if user.IsVip != nil && *user.IsVip {
|
||||
user.HasFullBook = ptrBool(true)
|
||||
}
|
||||
hasFull := cnt > 0 || (user.IsVip != nil && *user.IsVip) || (user.HasFullBook != nil && *user.HasFullBook)
|
||||
user.HasFullBook = ptrBool(hasFull)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "user": user})
|
||||
return
|
||||
}
|
||||
@@ -670,11 +668,14 @@ func DBUsersList(c *gin.Context) {
|
||||
// 填充每个用户的实时计算字段
|
||||
for i := range users {
|
||||
uid := users[i].ID
|
||||
// 购买状态(含手动设置的 VIP:is_vip=1 且 vip_expire_date>NOW)
|
||||
// 购买状态(含订单、is_vip、手动设置的 has_full_book)
|
||||
hasFull := hasFullBookMap[uid]
|
||||
if users[i].IsVip != nil && *users[i].IsVip && users[i].VipExpireDate != nil && users[i].VipExpireDate.After(time.Now()) {
|
||||
hasFull = true
|
||||
}
|
||||
if users[i].HasFullBook != nil && *users[i].HasFullBook {
|
||||
hasFull = true
|
||||
}
|
||||
users[i].HasFullBook = ptrBool(hasFull)
|
||||
users[i].PurchasedSectionCount = sectionCountMap[uid]
|
||||
// 分销收益
|
||||
|
||||
@@ -451,20 +451,44 @@ func UserReadingProgressGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// parseDuration 从 JSON 解析 duration,兼容数字与字符串(防止客户端传字符串导致累加异常)
|
||||
func parseDuration(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return int(x)
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(x)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// UserReadingProgressPost POST /api/user/reading-progress
|
||||
func UserReadingProgressPost(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration int `json:"duration"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
Progress int `json:"progress"`
|
||||
Duration interface{} `json:"duration"` // 兼容 int/float64/string,防止字符串导致累加异常
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
return
|
||||
}
|
||||
duration := parseDuration(body.Duration)
|
||||
if duration < 0 {
|
||||
duration = 0
|
||||
}
|
||||
db := database.DB()
|
||||
now := time.Now()
|
||||
var existing model.ReadingProgress
|
||||
@@ -474,7 +498,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if body.Progress > newProgress {
|
||||
newProgress = body.Progress
|
||||
}
|
||||
newDuration := existing.Duration + body.Duration
|
||||
newDuration := existing.Duration + duration
|
||||
newStatus := body.Status
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
@@ -501,7 +525,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
completedAt = &t
|
||||
}
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: body.Duration,
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: duration,
|
||||
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
|
||||
})
|
||||
}
|
||||
@@ -681,6 +705,10 @@ func UserDashboardStats(c *gin.Context) {
|
||||
if totalReadSeconds > 0 && totalReadMinutes == 0 {
|
||||
totalReadMinutes = 1
|
||||
}
|
||||
// 异常数据保护:历史 bug 导致累加错误可能产生超大值, cap 到 99999 分钟(约 69 天)
|
||||
if totalReadMinutes > 99999 {
|
||||
totalReadMinutes = 99999
|
||||
}
|
||||
|
||||
// 3. 批量查 chapters 获取真实标题与 mid
|
||||
chapterMap := make(map[string]model.Chapter)
|
||||
|
||||
BIN
soul-api/uploads/avatars/1773477415258581700_nbanes.jpeg
Normal file
BIN
soul-api/uploads/avatars/1773477415258581700_nbanes.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -37036,3 +37036,7 @@
|
||||
{"level":"debug","timestamp":"2026-03-11T14:59:00+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 11 Mar 2026 06:58:59 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08B3A5C4CD0610CE0418F7CC8C5820D6C20428DABB02-0\r\nServer: nginx\r\nWechatpay-Nonce: f30d77e59498accda290d38f8606fa42\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: uV0fwT0jxeme73f39fNQwNgnuoYy5WC7dOv4q8iQ15N0eYAMq9RLuMA/fpMVu3+a0sJscocYueGa3ySKfFyKiYBzKxAsduudohBIePDZZ8lMJUdsBUgbDpgMg+wP3bKJG3RoHX0NcEMH5cEpIf5A+JpuVe2Kkwh4qpOsVV2Tm6w1vEeeYSX0vcusyhkzmXWTNlG8tJ89TP3wQohOAZqWJiLRDwYdLGBcrQQgrham1QHCdlIF9h3RcKLKDiZw5IYTo+PaSlWWkdycT5fI0PHAWC39O4WfsnT3LUEu8zbUnBukZxJujnSxYJNdFiTLkkrjRyKMt8cVw9oni63UOQhb0A==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773212339\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260309104414306664\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-11T14:59:00+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/MP20260309174649961512?mchid=1318592501 request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"K4AubFG6LBIplNcqAmdNCaYijgIeIpEl\",timestamp=\"1773212340\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"lxBWrCsMGjfx5CmdJfBNAo+5uPQqEONe3Tan0kQ28RdwhJlCAv0PoxuQ9yMpsh0jzTUypZLvt5FcUXGI2kLQ9liIuX4KiTQ2vFWztc0iiSF2FfDe4L9qxvcLhQIOwW+4c+RPIiZwZ5FRu0jLM/n4z+MrxaFh7Lj/rng/1y8TeHH1iBsK3JKQL4ZLDqxLWpaKnxPU6aVuqlZCH/EtUSppITmQaAC6T4oSSCBC7Iy1Vhh9zwPSkYwTxb5D8c0WGneZ0RG/bR2yRVGRGCAYdAryZbnjntVoyyUrsj6foX8JSuPqDf5/Rrj1Gv7qoe+KPhJpSGVcqvSEr887BcnbNswFpw==\"Accept:*/*} request body:"}
|
||||
{"level":"debug","timestamp":"2026-03-11T14:59:00+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 249\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 11 Mar 2026 06:58:59 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 08B3A5C4CD0610CC0618DD9C85AB0120D4FA1628BAD605-0\r\nServer: nginx\r\nWechatpay-Nonce: 1b90fd05c10ad4982ea8a0883207c8a8\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: VAuHofjAteRYLAXA9ORZG3MbnmMN3P4kHf4zOEAyrOvIBVIQOHism5yLUY8Hqz0uu52D3+2wfUo8oUFWGOzs3MaRXwfkdJIX/ls304pCtVExop53F+edQV06tS7wfWY1Pe4rkFzP/qinCYiUfwh/yWcAMwyLh9g0wUvToLUQ2IP8ujFNIx+zuReX7Kk68OsfNZSFvP4Yfr8KfRYeElXhYgOenkWrOa02apbByykArWHrJ+1Mhcb2lnmoLzaIoKGwMZsZlW6Tkn6bzZSZpTY2aurni4AWcxUO7+UzhXIahYJaWBAhcYZTXsYc1kAtvdn6ajfcy4VzQ09rvaJ5KUUwHg==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773212339\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"amount\":{\"payer_currency\":\"CNY\",\"total\":100},\"appid\":\"wxb8bbb2b10dec74aa\",\"mchid\":\"1318592501\",\"out_trade_no\":\"MP20260309174649961512\",\"promotion_detail\":[],\"scene_info\":{\"device_id\":\"\"},\"trade_state\":\"NOTPAY\",\"trade_state_desc\":\"订单未支付\"}"}
|
||||
{"level":"debug","timestamp":"2026-03-14T16:34:46+08:00","caller":"kernel/accessToken.go:381","content":"GET https://api.weixin.qq.com/cgi-bin/token?appid=wxb8bbb2b10dec74aa&grant_type=client_credential&neededText=&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-03-14T16:34:46+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Sat, 14 Mar 2026 08:34:46 GMT\r\n\r\n{\"access_token\":\"102_WxUUlZfmIN7oR-liKLSivbLSkPRACcD_WtbH1H4MB337NnHl8Mj9zG1vEA_XkAwJlcsfip7NgDR6jpIX5R0oCYUV8k88FcXdlxuW9pBlJsVXzIB0v72xQ8g4678DNUaADAGQX\",\"expires_in\":7200}"}
|
||||
{"level":"debug","timestamp":"2026-03-14T16:34:46+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_WxUUlZfmIN7oR-liKLSivbLSkPRACcD_WtbH1H4MB337NnHl8Mj9zG1vEA_XkAwJlcsfip7NgDR6jpIX5R0oCYUV8k88FcXdlxuW9pBlJsVXzIB0v72xQ8g4678DNUaADAGQX&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0c11a8Ga1BBMmL0brvHa1gvcSI11a8Gg&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
|
||||
{"level":"debug","timestamp":"2026-03-14T16:34:46+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Sat, 14 Mar 2026 08:34:47 GMT\r\n\r\n{\"session_key\":\"xG1Dw0KNNQejitT7CEM7Lw==\",\"openid\":\"ogpTW5a9exdEmEwqZsYywvgSpSQg\"}"}
|
||||
|
||||
Reference in New Issue
Block a user