From 1edceda4db72808479171245c7f30ee6024a2fcd Mon Sep 17 00:00:00 2001 From: Alex-larget <33240357+Alex-larget@users.noreply.github.com> Date: Sat, 14 Mar 2026 17:13:06 +0800 Subject: [PATCH] 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. --- .../agent/后端工程师/evolution/2026-03-14.md | 33 +++ .cursor/agent/后端工程师/evolution/索引.md | 1 + .cursor/agent/团队/evolution/2026-03-14.md | 17 ++ .cursor/agent/团队/evolution/索引.md | 1 + .../evolution/2025-03-14-文本长按复制.md | 40 +++ .../小程序开发工程师/evolution/2026-03-14.md | 18 ++ .../agent/小程序开发工程师/evolution/索引.md | 2 + .cursor/agent/开发助理/经验清单.md | 4 +- .cursor/agent/开发助理/项目索引/后端.md | 3 +- .cursor/agent/开发助理/项目索引/团队.md | 3 +- .cursor/agent/开发助理/项目索引/小程序.md | 4 +- .cursor/agent/开发助理/项目索引/管理端.md | 3 +- .../管理端开发工程师/evolution/2026-03-14.md | 17 ++ .../agent/管理端开发工程师/evolution/索引.md | 1 + .cursor/skills/miniprogram-dev/SKILL.md | 11 +- miniprogram/app.js | 8 +- miniprogram/app.json | 3 +- .../pages/avatar-nickname/avatar-nickname.js | 155 ++++++++++ .../avatar-nickname/avatar-nickname.json | 4 + .../avatar-nickname/avatar-nickname.wxml | 67 +++++ .../avatar-nickname/avatar-nickname.wxss | 271 ++++++++++++++++++ miniprogram/pages/index/index.js | 37 +-- miniprogram/pages/my/my.js | 29 +- miniprogram/pages/my/my.wxml | 10 +- .../pages/profile-edit/profile-edit.js | 13 + .../pages/profile-edit/profile-edit.wxss | 7 +- miniprogram/utils/readingTracker.js | 8 +- miniprogram/utils/util.js | 13 + soul-admin/src/pages/users/UsersPage.tsx | 4 +- soul-api/internal/handler/db.go | 13 +- soul-api/internal/handler/user.go | 44 ++- .../avatars/1773477415258581700_nbanes.jpeg | Bin 0 -> 3441 bytes soul-api/wechat/info.log | 4 + 33 files changed, 773 insertions(+), 75 deletions(-) create mode 100644 .cursor/agent/后端工程师/evolution/2026-03-14.md create mode 100644 .cursor/agent/团队/evolution/2026-03-14.md create mode 100644 .cursor/agent/小程序开发工程师/evolution/2025-03-14-文本长按复制.md create mode 100644 .cursor/agent/小程序开发工程师/evolution/2026-03-14.md create mode 100644 .cursor/agent/管理端开发工程师/evolution/2026-03-14.md create mode 100644 miniprogram/pages/avatar-nickname/avatar-nickname.js create mode 100644 miniprogram/pages/avatar-nickname/avatar-nickname.json create mode 100644 miniprogram/pages/avatar-nickname/avatar-nickname.wxml create mode 100644 miniprogram/pages/avatar-nickname/avatar-nickname.wxss create mode 100644 soul-api/uploads/avatars/1773477415258581700_nbanes.jpeg diff --git a/.cursor/agent/后端工程师/evolution/2026-03-14.md b/.cursor/agent/后端工程师/evolution/2026-03-14.md new file mode 100644 index 00000000..b8db6779 --- /dev/null +++ b/.cursor/agent/后端工程师/evolution/2026-03-14.md @@ -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`)均复用该算法,修正后两端同步生效。 diff --git a/.cursor/agent/后端工程师/evolution/索引.md b/.cursor/agent/后端工程师/evolution/索引.md index 30ba6514..48b0b2cb 100644 --- a/.cursor/agent/后端工程师/evolution/索引.md +++ b/.cursor/agent/后端工程师/evolution/索引.md @@ -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) | diff --git a/.cursor/agent/团队/evolution/2026-03-14.md b/.cursor/agent/团队/evolution/2026-03-14.md new file mode 100644 index 00000000..346a20cc --- /dev/null +++ b/.cursor/agent/团队/evolution/2026-03-14.md @@ -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()` 刷新列表,否则用户看不到变化。 diff --git a/.cursor/agent/团队/evolution/索引.md b/.cursor/agent/团队/evolution/索引.md index 88f99084..4095e819 100644 --- a/.cursor/agent/团队/evolution/索引.md +++ b/.cursor/agent/团队/evolution/索引.md @@ -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) | diff --git a/.cursor/agent/小程序开发工程师/evolution/2025-03-14-文本长按复制.md b/.cursor/agent/小程序开发工程师/evolution/2025-03-14-文本长按复制.md new file mode 100644 index 00000000..c58cb917 --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2025-03-14-文本长按复制.md @@ -0,0 +1,40 @@ +# 阅读页文本长按选中复制 + +> 问题→解决闭环,已升级 miniprogram-dev SKILL + +--- + +## 问题 + +小程序阅读页正文长按时无法选中、复制文本。 + +--- + +## 解决方案 + +使用 `text` 组件的 **`user-select`** 属性(基础库 2.12.1+,官方推荐;`selectable` 已废弃): + +```html +{{seg.text}} +``` + +--- + +## 实施范围(read.wxml) + +- 章节标题:`` +- 正文片段:text / mention / linkTag 均加 `user-select` +- 预览段落:`{{item}}`(原为 view 内直接 `{{item}}`,需用 text 包裹) + +--- + +## 备选方案 + +- **selectable**:已废弃但多数环境仍可用,若 `user-select` 导致布局异常(inline-block 换行)可回退 +- **wx.setClipboardData + bindlongpress**:iOS 原生选中失效时,可做长按整段复制兜底 + +--- + +## Skill 升级 + +已写入 miniprogram-dev SKILL §10 文本可选与复制。 diff --git a/.cursor/agent/小程序开发工程师/evolution/2026-03-14.md b/.cursor/agent/小程序开发工程师/evolution/2026-03-14.md new file mode 100644 index 00000000..4a4ab326 --- /dev/null +++ b/.cursor/agent/小程序开发工程师/evolution/2026-03-14.md @@ -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` 接口判断。 diff --git a/.cursor/agent/小程序开发工程师/evolution/索引.md b/.cursor/agent/小程序开发工程师/evolution/索引.md index f63c6986..31be5043 100644 --- a/.cursor/agent/小程序开发工程师/evolution/索引.md +++ b/.cursor/agent/小程序开发工程师/evolution/索引.md @@ -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) | diff --git a/.cursor/agent/开发助理/经验清单.md b/.cursor/agent/开发助理/经验清单.md index 82b1453a..f84682a0 100644 --- a/.cursor/agent/开发助理/经验清单.md +++ b/.cursor/agent/开发助理/经验清单.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(排名算法修正、设置隐藏、资料引导梳理) diff --git a/.cursor/agent/开发助理/项目索引/后端.md b/.cursor/agent/开发助理/项目索引/后端.md index cf970151..c19177a8 100644 --- a/.cursor/agent/开发助理/项目索引/后端.md +++ b/.cursor/agent/开发助理/项目索引/后端.md @@ -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 diff --git a/.cursor/agent/开发助理/项目索引/团队.md b/.cursor/agent/开发助理/项目索引/团队.md index aaf23c42..8def0a39 100644 --- a/.cursor/agent/开发助理/项目索引/团队.md +++ b/.cursor/agent/开发助理/项目索引/团队.md @@ -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 diff --git a/.cursor/agent/开发助理/项目索引/小程序.md b/.cursor/agent/开发助理/项目索引/小程序.md index d7834d26..70e41916 100644 --- a/.cursor/agent/开发助理/项目索引/小程序.md +++ b/.cursor/agent/开发助理/项目索引/小程序.md @@ -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 diff --git a/.cursor/agent/开发助理/项目索引/管理端.md b/.cursor/agent/开发助理/项目索引/管理端.md index 9ccd4447..f3d6120a 100644 --- a/.cursor/agent/开发助理/项目索引/管理端.md +++ b/.cursor/agent/开发助理/项目索引/管理端.md @@ -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 diff --git a/.cursor/agent/管理端开发工程师/evolution/2026-03-14.md b/.cursor/agent/管理端开发工程师/evolution/2026-03-14.md new file mode 100644 index 00000000..49672f01 --- /dev/null +++ b/.cursor/agent/管理端开发工程师/evolution/2026-03-14.md @@ -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` diff --git a/.cursor/agent/管理端开发工程师/evolution/索引.md b/.cursor/agent/管理端开发工程师/evolution/索引.md index c7c0393d..50202159 100644 --- a/.cursor/agent/管理端开发工程师/evolution/索引.md +++ b/.cursor/agent/管理端开发工程师/evolution/索引.md @@ -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) | diff --git a/.cursor/skills/miniprogram-dev/SKILL.md b/.cursor/skills/miniprogram-dev/SKILL.md index bd180f45..2caeda60 100644 --- a/.cursor/skills/miniprogram-dev/SKILL.md +++ b/.cursor/skills/miniprogram-dev/SKILL.md @@ -84,7 +84,15 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑 --- -## 9. 何时使用本 Skill +## 9. 文本可选与复制(阅读类内容) + +- **长按选中复制**:需支持长按选中的文本用 `...`(基础库 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 接口混用。 diff --git a/miniprogram/app.js b/miniprogram/app.js index 336ad0eb..11b174af 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -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) } diff --git a/miniprogram/app.json b/miniprogram/app.json index 2912c1ff..49f1b392 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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", diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.js b/miniprogram/pages/avatar-nickname/avatar-nickname.js new file mode 100644 index 00000000..a344a603 --- /dev/null +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.js @@ -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' }) + }, +}) diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.json b/miniprogram/pages/avatar-nickname/avatar-nickname.json new file mode 100644 index 00000000..4d1ce1a1 --- /dev/null +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "完善资料", + "usingComponents": {} +} diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.wxml b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml new file mode 100644 index 00000000..a0bf5c5c --- /dev/null +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml @@ -0,0 +1,67 @@ + + + + + 完善资料 + + + + + + + + 👋 + 完善头像和昵称 + 让他人更好地认识你,展示更专业的形象 + + + + + + + + {{nickname ? nickname[0] : '?'}} + + 📷 + + 点击更换头像 + + + + + 昵称 + + + + 微信用户可点击输入框自动填充昵称,或手动输入 + + + + {{saving ? '保存中...' : '完成'}} + + + + 完善更多资料 + + + + + + + + + 使用微信头像 + 点击下方按钮,一键同步当前微信头像 + + 取消 + + + diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.wxss b/miniprogram/pages/avatar-nickname/avatar-nickname.wxss new file mode 100644 index 00000000..83b02442 --- /dev/null +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.wxss @@ -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; +} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 994bf21e..411efa11 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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()}` } diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index 171e48d8..e6954732 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -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) { diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index 80371793..cfb60ee8 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -46,7 +46,7 @@ - {{readCount}} + {{readCountText}} 已读章节 @@ -94,17 +94,17 @@ - {{readCount}} + {{readCountText}} 已读章节 - {{totalReadTime}} + {{totalReadTimeText}} 阅读分钟 - {{matchHistory}} + {{matchHistoryText}} 匹配伙伴 @@ -154,7 +154,7 @@ - + 设置 diff --git a/miniprogram/pages/profile-edit/profile-edit.js b/miniprogram/pages/profile-edit/profile-edit.js index bd93e0cd..7460ae93 100644 --- a/miniprogram/pages/profile-edit/profile-edit.js +++ b/miniprogram/pages/profile-edit/profile-edit.js @@ -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 }) }, diff --git a/miniprogram/pages/profile-edit/profile-edit.wxss b/miniprogram/pages/profile-edit/profile-edit.wxss index ea0ad739..77801e8b 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxss +++ b/miniprogram/pages/profile-edit/profile-edit.wxss @@ -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; diff --git a/miniprogram/utils/readingTracker.js b/miniprogram/utils/readingTracker.js index 611d154a..badb0b0a 100644 --- a/miniprogram/utils/readingTracker.js +++ b/miniprogram/utils/readingTracker.js @@ -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 } diff --git a/miniprogram/utils/util.js b/miniprogram/utils/util.js index 855e96fd..064fd4c5 100644 --- a/miniprogram/utils/util.js +++ b/miniprogram/utils/util.js @@ -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, diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx index 7c26322f..80660425 100644 --- a/soul-admin/src/pages/users/UsersPage.tsx +++ b/soul-admin/src/pages/users/UsersPage.tsx @@ -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() { {editingUser ? : }{editingUser ? '编辑用户' : '添加用户'}
-
setFormData({ ...formData, phone: e.target.value })} disabled={!!editingUser} />
+
setFormData({ ...formData, phone: e.target.value })} />
setFormData({ ...formData, nickname: e.target.value })} />
setFormData({ ...formData, password: e.target.value })} />
setFormData({ ...formData, isAdmin: c })} />
diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index 665d7968..201b6d4c 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -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] // 分销收益 diff --git a/soul-api/internal/handler/user.go b/soul-api/internal/handler/user.go index c194aed2..bf6b1d0b 100644 --- a/soul-api/internal/handler/user.go +++ b/soul-api/internal/handler/user.go @@ -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) diff --git a/soul-api/uploads/avatars/1773477415258581700_nbanes.jpeg b/soul-api/uploads/avatars/1773477415258581700_nbanes.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f15bf0afe50771b5ac7e237b931c61356f7dca61 GIT binary patch literal 3441 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 literal 0 HcmV?d00001 diff --git a/soul-api/wechat/info.log b/soul-api/wechat/info.log index 33a6d475..8159e37d 100644 --- a/soul-api/wechat/info.log +++ b/soul-api/wechat/info.log @@ -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\"}"}