更新个人资料页实现评估会议记录,明确展示与编辑页字段一致性要求,补充技能字段的展示与编辑需求。优化小程序页面,增加联系方式完善弹窗,确保用户在使用找伙伴功能前填写手机号或微信号。调整相关文档以反映最新进展,提升用户体验与功能一致性。

This commit is contained in:
Alex-larget
2026-02-28 15:16:23 +08:00
parent 244fe98591
commit 41e5b1258b
57 changed files with 3451 additions and 1740 deletions

View File

@@ -5,3 +5,8 @@
- **stitch_soul 串联「内容→会员→导师」变现路径**:临时需求池 10 个稿子覆盖目录、导师、会员、首页、资料编辑,需在正式需求文档中明确 73 章、导师、案例库、会员的业务定义与验收标准。
- **待澄清项**73 章与现有内容库是否同一套;导师与内容作者是否同一人;「案例库」是独立内容池还是章节分类;会员权益与价格策略。
- **优先级建议**:首页/目录/会员 > 导师列表/详情 > 资料编辑。
## 个人资料页实现评估会议
- **展示/编辑页一致性**展示页enhanced_professional_profile与编辑页comprehensive_profile_editor_v1_1需字段一一对应、配色统一。
- **skills「我擅长」**:展示页已有,编辑页必须补充;验收时确认两页数据一致。

View File

@@ -5,3 +5,13 @@
- **现有基础**soul-api 已有 chapter、book、vip 模型;导师能力需新建或扩展现有 match 体系(现有 mentor 为 match 类型,非独立导师实体)。
- **待设计**:导师列表/详情/搜索筛选、预约单、会员权益与预约支付打通;接口挂 `/api/miniprogram/*`
- **协同**:与产品核对 chapter/book/vip 现状后,给出导师/预约/会员权益的模型与接口方案。
## chapter/book/vip 模型补充(问题 5 作答)
- **chapters 表**每行一节id 如 1.1/prefacepart/chapter/section 三层73 章=行数统计。
- **book**:无独立表,= chapters 聚合;接口 `/api/book/all-chapters``/api/book/chapter/:id`
- **vip**vip_roles 配置角色users 存 is_vip/vip_expire_date 等;权益优先 users无则 orders 兜底¥1980。
## 个人资料页实现评估会议
- **profile API**`GET/POST /api/miniprogram/user/profile` 已覆盖 skills 等全部扩展字段;无需新增接口。

View File

@@ -9,3 +9,7 @@
## 会议规则升级
- **问题与作答区**:开完会后必须将待确认/待澄清问题列出,在会议纪要中增加「问题与作答区」节,问题表含:序号、问题、责任角色、作答(留空供后续填写);便于追溯闭环。
## 个人资料页实现评估会议
- **展示/编辑页协同**profile-show 与 profile-edit 共用同一 APIskills 等扩展字段需双向同步;配色统一为 enhanced#5EEAD4)强化品牌一致。

View File

@@ -5,3 +5,14 @@
- **页面范围**:首页、目录、导师列表/详情、会员落地页、个人资料/编辑;全部接口走 `/api/miniprogram/*`
- **支付**:会员购买、导师预约支付需按微信支付规范实现。
- **时机**:待需求与接口确定后按优先级分阶段排期(建议:内容→会员→导师→资料编辑)。
## 个人资料页实现评估会议
- **profile-show**:已按 enhanced_professional_profile 完成accent #5EEAD4profile-edit 需与其视觉统一。
- **profile-edit 待做**:① 增加「我擅长」输入框skills 后端已支持);② 配色统一为 enhanced#5EEAD4 / #050B14 / #0F1720)。
- **流程**:我的 → profile-show → ⋯ 编辑 → profile-edit → 保存 → 返回;已打通,无需改。
## input/textarea padding 规范
- **原则**:给 input 或 textarea 设置 padding 时,必须用 view 包裹padding 写在 view 上;不在 input/textarea 自身上设 padding避免原生组件光标截断、布局异常。
- **已升级**miniprogram-dev SKILL §6。

View File

@@ -5,3 +5,7 @@
- **待支撑能力**:章节管理(增删改、排序、免费/付费/NEW、导师管理审核、标签、价格、展示、会员配置权益、价格、有效期、预约管理列表、状态
- **接口依赖**`/api/admin/*``/api/db/*`;字段需与 miniprogram 端统一。
- **时机**:待后端方案确定后规划管理端页面与接口对接。
## 个人资料页实现评估会议
- **无新增任务**:个人资料展示/编辑为 C 端能力,管理端沿用现有能力即可。

View File

@@ -5,3 +5,7 @@
- **关键联调场景**:阅读进度、免费/付费解锁、会员权益、导师预约与支付、资料完善与提现限制。
- **三端**miniprogram ↔ soul-api、soul-admin ↔ soul-api变更后需回归支付、登录、提现等现有流程。
- **待办**:需求确定后补充三端联调用例与回归清单。
## 个人资料页实现评估会议
- **验证点**profile-show 与 profile-edit 字段一一对应;保存后两页及「我的」数据一致;手机/微信号脱敏与复制头像上传、昵称、MBTI 选择。

View File

@@ -0,0 +1,80 @@
# stitch_soul P0 测试清单
> 测试人员按此清单验证 P0 功能。
---
## 一、开发完成情况
| 阶段 | 状态 | 说明 |
|------|------|------|
| **P0** | ✅ 完成 | 首页/目录 + NEW + 精选推荐算法 |
| P1 | 未开始 | 会员落地页 |
| P2 | 未开始 | 导师 + 预约 |
| P3 | 未开始 | 资料编辑扩展 |
---
## 二、P0 接口测试
**前提**soul-api 已启动,数据库已执行 `add-chapters-is-new.sql`
### 2.1 后端接口(可用 PowerShell 脚本或 curl 验证)
```powershell
# 在 soul-api 目录下执行
cd e:\Gongsi\Mycontent\soul-api
.\scripts\test-p0-endpoints.ps1
```
或手动验证:
| 接口 | 期望 |
|------|------|
| `GET /api/miniprogram/book/all-chapters` | success: truedata 为数组,每项含 `isNew` 字段 |
| `GET /api/miniprogram/book/recommended` | success: truedata 为 13 条,每项含 `tag`(热门/推荐/精选) |
| `GET /api/miniprogram/book/latest-chapters` | success: truedata 为数组(按 updated_at 降序) |
| `GET /api/miniprogram/book/hot` | success: truedata 为数组(按阅读量或兜底排序) |
### 2.2 管理端测试
| 步骤 | 操作 | 期望 |
|------|------|------|
| 1 | 登录 soul-admin | 成功 |
| 2 | 进入「内容管理」 | 章节列表正常 |
| 3 | 点击某一节「编辑」 | 弹出编辑框 |
| 4 | 勾选「标记 NEW」并保存 | 保存成功,无报错 |
| 5 | 刷新列表,再次编辑同一节 | 「标记 NEW」保持勾选 |
### 2.3 小程序测试
| 步骤 | 操作 | 期望 |
|------|------|------|
| 1 | 打开小程序首页 | 加载正常 |
| 2 | 查看「最新更新」Banner | 显示一条章节,点击可进入阅读 |
| 3 | 查看「精选推荐」 | 显示 3 条,带 热门/推荐/精选 标签 |
| 4 | 查看「最新新增」 | 有 isNew 的章节在此展示 |
| 5 | 进入「目录」页 | 从服务端加载,按篇章聚合 |
| 6 | 在目录中查看标记 NEW 的章节 | 显示 NEW 标签 |
| 7 | 查看免费/¥1 显示 | 免费节显示「免费」付费节显示「¥1」 |
---
## 三、联调验证
| 验证点 | 说明 |
|--------|------|
| 管理端标记 NEW → 小程序展示 | 在管理端勾选某节 NEW小程序目录/首页「最新新增」应出现 |
| 精选推荐排除序言/尾声/附录 | 若 part_title 含「序言」「尾声」「附录」,不应出现在 recommended/hot |
| 阅读量兜底 | 无 reading_progress 数据时hot/recommended 应返回 updated_at 排序的兜底结果 |
---
## 四、已知限制
- **阅读量**:当前依赖 `reading_progress` 表,新环境无数据时会走兜底(按 updated_at
- **固定 3 章兜底**:若连章节列表都拿不到,会返回空;未实现「预设固定 3 章」配置。
---
*测试完成后可更新本文件,标注通过/失败及问题。*

View File

@@ -0,0 +1,91 @@
# 会议纪要 - 2026-02-28 | 个人资料页实现评估
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-02-28
- **议题**个人资料展示页profile-show与编辑页profile-edit实现评估
- **触发方式**:开个会议评估怎么实现
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
- **展示页**enhanced_professional_profile面向他人查看含头像、昵称、MBTI/地区、基本信息、个人故事、互助需求、项目介绍。
- **编辑页**comprehensive_profile_editor_v1_1完整表单需与展示页字段一一对应。
- **待澄清**:展示页有「我擅长」展示,编辑页需补充;两页配色建议统一。
**验收标准**:我的 → 展示页 → 编辑页 → 保存 → 返回,流程闭环;字段完整对应。
### 【后端开发】
- 现有 `GET/POST /api/miniprogram/user/profile` 已覆盖 nickname, avatar, mbti, region, industry, position, businessScale, **skills**, phone, wechatId, 个人故事三字段、互助需求两字段、projectIntro。
- 无需新接口skills 已支持读写,编辑页只需前端对接。
### 【管理端开发工程师】
- 个人资料为 C 端能力,管理端无新增任务。
### 【小程序开发工程师】
- **profile-show**:已按 enhanced_professional_profile 完成,配色 #5EEAD4 / #050B14 / #0F1720
- **profile-edit**:已按 comprehensive_profile_editor_v1_1 实现功能,配色 #4FD1C5 / #000
- **待做**:① 编辑页增加「我擅长」输入框;② 配色统一为 enhanced 风格。
### 【测试人员】
- 验证展示页与编辑页字段一致、保存后数据正确回显。
- 手机号/微信号脱敏与复制头像上传、昵称、MBTI 选择;空/超长输入边界。
---
## 讨论过程
- 产品确认skills 必须在编辑页体现。
- 产品确认:两页配色统一为 enhanced 风格(#5EEAD4)以强化品牌一致。
- 小程序确认skills 后端已有,配色替换工作量小。
---
## 会议决议
1. **skills 字段**:在 profile-edit「基本信息」区块增加「我擅长」输入框与 profile-show 对应。
2. **视觉统一**profile-edit 配色统一为 enhancedaccent #5EEAD4, background #050B14, card #0F1720)。
3. **实现顺序**:先补 skills再做配色统一。
4. **流程**:我的 → profile-show → ⋯ → profile-edit → 保存 → 返回,已打通,无需修改。
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 小程序开发工程师 | profile-edit 增加「我擅长」输入框及 JS 读写 | 高 | 本次迭代 |
| 小程序开发工程师 | profile-edit 配色统一为 enhanced 风格 | 中 | 本次迭代 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 编辑页导航栏是否需增加右侧 more_horiz + 头像(设计稿有此元素)? | 产品经理 | (待补充) |
| 2 | 展示页「成为超级个体」按钮点击后的具体跳转路径? | 产品经理 | (待补充) |
---
## 各角色经验与业务理解更新
- 个人资料展示页与编辑页需字段、配色、流程一致,便于用户理解。
- profile-edit 与 profile-show 共用同一套 APIskills 等扩展字段需双向同步。
- 本次会议决议已写入本纪要;各角色经验已同步至 `agent/{角色}/evolution/2026-02-28.md`
---
*会议纪要由助理橙子生成 | 2026-02-28*

View File

@@ -7,8 +7,8 @@
## 基本信息
- **时间**2026-02-28
- **议题**:临时需求池 `soul20260228/stitch_soul` 中的 10 个 UI 稿需求讨论,形成开发共识
- **触发方式**:开个会议所有人讨论这个需求
- **议题**分析临时需求池 soul20260228/stitch_soul 全部需求
- **触发方式**:开个会议所有人都来,分析这个需求
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
@@ -17,53 +17,58 @@
### 【产品经理】
- 这些稿子串联「内容阅读 + 导师咨询」两条线:目录→会员→导师咨询。
- 用户价值:目录+阅读进度提升内容留存;导师列表/详情支持 1v1 咨询变现;会员落地页支撑付费转化。
- 待澄清73 章与现有内容库是否同一套?导师与内容作者是否同一人?「案例库」是独立内容池还是章节分类?
- 优先级建议:首页/目录/会员 > 导师列表/详情 > 资料编辑。
从 10 个稿子可归纳出 **stitch_soul 串联「内容→会员→导师」变现路径**
- **首页**optimized_home_content_feed_v1品牌、搜索、最新更新、阅读进度、超级个体、精选推荐、最新新增NEW
- **目录**catalog_with_new_additions_v173 章、篇章结构、NEW 标签、免费/¥1 付费
- **会员落地**premium_membership_landing_v1¥1980/年,内容权益(章节、案例库、智能纪要、会议纪要)+ 社交权益匹配、排行、资源、VIP 标识)
- **导师**:列表(搜索/分类)+ 详情(介绍/服务/价格/预约),单次咨询 ¥6002500
- **个人资料**:展示(基本信息/个人故事/互助需求/项目介绍)、编辑(完整表单)、手机号/微信号弹窗
- **我的**VIP 标识、分享收益、阅读统计、最近阅读、订单
**待澄清**73 章与现有内容库是否同一套;导师与内容作者是否同一人;「案例库」是独立内容池还是章节分类;会员权益与价格策略。
**建议优先级**:首页/目录/会员 > 导师 > 资料。
### 【后端开发】
- 需要接口:目录/章节(列表、分组、阅读进度、免费/付费/NEW、导师列表、详情、搜索/筛选、预约)、会员权益校验、资料(手机号、微信号、头像)。
- 数据模型:章节、导师、会员权益、预约单、阅读进度等,需核对 soul-api 现有模型。
- 支付/会员:会员付费、导师预约支付需与现有支付流程打通
- 待确认:现有 soul-api 中已有 chapter、book、vip导师需新建或扩展现有 match 体系。
**现有基础**soul-api 已有 chapter、book、vip 模型;导师能力需新建或扩展现有 match 体系(现有 mentor 为 match 类型,非独立导师实体)。
**待设计**:导师列表/详情/搜索筛选、预约单、会员权益与预约支付打通;接口挂 `/api/miniprogram/*`。与产品核对 chapter/book/vip 现状后,给出导师/预约/会员权益的模型与接口方案
### 【管理端开发工程师】
- 需要能力:章节管理(增删改、排序、免费/付费/NEW 配置)、导师管理(审核、标签、价格、展示)、会员配置(权益、价格、有效期)、预约管理(列表、状态)
- 接口依赖 `/api/admin/*``/api/db/*`
- 字段需与小程序端保持一致。
管理端需配套:章节/导师 CRUD、NEW 标记、会员权益配置、预约管理、收益/提现审核。接口走 `/api/admin/*``/api/db/*`,字段与小程序/后端一致。待后端方案确定后规划具体页面
### 【小程序开发工程师】
- 页面:首页目录导师列表/详情、会员落地页、个人资料/编辑
- 全部接口走 `/api/miniprogram/*`
- 支付:会员购买、导师预约支付需按微信支付规范实现。
10 个稿子覆盖首页/目录/会员/导师列表/导师详情/资料展示/资料编辑/我的五类页面,交互清晰,需 `/api/miniprogram/*`。待需求与接口确定后分阶段实现
### 【测试人员】
- 三端联调miniprogram ↔ soul-api、soul-admin ↔ soul-api
- 关键场景:阅读进度、免费/付费解锁、会员权益、导师预约与支付、资料完善与提现限制。
- 回归:变更后需回归支付、登录、提现等现有流程。
关键场景为阅读/付费、会员、导师预约、资料完善三端联调小程序↔API、管理端↔API验证点。待需求确定后补充联调用例和回归清单
---
## 讨论过程
- 产品强调 73 章与现有内容需先澄清,建议产品补充正式需求文档
- 后端需梳理 soul-api 现有 chapter/book/vip 模型,导师能力需新建或扩展 match
- 前端(管理端、小程序)需统一字段约定,避免后期频繁联调
- 测试建议在需求确定后补充三端联调用例清单
- 产品询问后端73 章、book、vip 现状是否明确
- 后端回复:需与产品共同核对 chapter/book/vip 后再定导师/预约/会员权益模型
- 管理端确认需管理章节、导师、会员、预约、收益
- 小程序确认页面稿清晰,等接口与需求后分阶段开发
- 测试:待业务规则确定后补充用例。
---
## 会议决议
1. **需求池定性**`soul20260228/stitch_soul` 作为原型/参考,需产品补充正式需求文档,明确业务规则与验收标准
2. **数据与接口**:后端与产品核对现有 chapter/book/vip 模型,并给出导师、预约、会员权益的模型与接口建议
3. **优先级**以产品最终确认为准,建议顺序为「内容→会员→导师→资料编辑」
4. **待确认**73 章与现有内容关系、导师与作者关系、「案例库」定义、会员权益与价格策略
1. **stitch_soul 定位** stitch 产品线在 Soul 创业派对上的扩展,串联「内容阅读 + 会员 + 导师咨询」变现路径
2. **产品**:需在正式需求文档中明确 73 章、导师、案例库、会员的业务定义与验收标准
3. **后端**梳理 chapter/book/vip设计导师/预约/会员权益模型与接口方案
4. **开发优先级**:首页/目录/会员 > 导师列表与详情 > 资料编辑 / 我的
5. **待确认项**73 章与内容库关系、导师与作者关系、案例库定义、会员权益与价格。
6. **管理端跟进原则(已采纳)**:以后小程序有功能变更时,管理端须根据 C 端能力主动补充管理功能;后端需支持对应配置能力。**本需求示例**:导师价格 → 后端支持每个导师独立价格配置(单次/半年/年度),管理端导师编辑页提供价格配置。已写入 role-flow-control、admin-dev Skill。
---
@@ -71,65 +76,496 @@
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 产品经理 | 产出 stitch_soul 正式需求文档,澄清 73 章/导师/案例库/会员规则 | 高 | 需求排期前 |
| 后端开发 | 梳理 chapter/book/vip 现状,给出导师/预约/会员权益模型与接口方案 | 高 | 需求评审后 |
| 管理端开发工程师 | 待后端方案确定后,规划章节/导师/会员/预约管理页面 | 中 | 后端接口就绪后 |
| 小程序开发工程师 | 待需求与接口确定后,按优先级实现首页/目录/导师/会员/资料页面 | 中 | 分阶段排期 |
| 测试人员 | 需求确定后,补充三端联调用例与回归清单 | 中 | 开发启动前 |
| 产品经理 | 撰写 stitch_soul 正式需求文档,明确业务边界 | 高 | 3 天内 |
| 后端工程师 | 梳理 chapter/book/vip,输出导师/预约/会员权益模型与接口方案 | 高 | 产品文档确认后 |
| 管理端开发工程师 | 待后端方案确定后,规划章节/导师/会员/预约管理页面 | 中 | 后端方案确定后 |
| 小程序开发工程师 | 待需求与接口确定后,分阶段实现首页/目录/会员/导师/资料 | 中 | 接口确定后 |
| 测试人员 | 需求确定后,补充阅读/付费/会员/导师/资料联调用例 | 中 | 需求确定后 |
---
## 问题与作答区
> 会议中提出的待确认问题在此列出;作答区域供后续补充答案,便于追溯闭环。
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 73 章与现有内容库是否同一套? | 产品经理 | (待补充) |
| 1 | 73 章与现有内容库是否同一套? | 产品经理 | 73 章为内容章数的统计 |
| 2 | 导师与内容作者是否同一人? | 产品经理 | 不是。导师是导师,属于咨询服务对接的人 |
| 3 | 「案例库」是独立内容池还是章节分类? | 产品经理 | 章节分类 |
| 4 | 会员权益与价格策略¥1980/年、权益边界)? | 产品经理 | 会员权益:所有章节全部免费,并自动进入超级个体名单 |
| 5 | chapter/book/vip 现有模型与业务定义? | 后端工程师 | 见下方「后端补充说明」 |
| 2 | 导师与内容作者是否同一人? | 产品经理 | (待补充) |
### 后端补充说明(问题 5
| 3 | 「案例库」是独立内容池还是章节分类? | 产品经理 | (待补充) |
**Chapterchapters 表)**
- 每行 = 一节section`id` 为业务标识如 `1.1``preface`
- `part_id` / `part_title`:篇章;`chapter_id` / `chapter_title`:章;`section_title`:节标题
- `content``is_free``price``sort_order`:正文、免费/付费、价格、排序
- 73 章 = chapters 表行数统计与产品「73 章为内容章数」一致
| 4 | 会员权益与价格策略如何设计? | 产品经理 | (待补充) |
**Book**
- 无独立表;「书」= chapters 的聚合视图
- 接口:`/api/book/all-chapters` 返回全部 chapters`/api/book/chapter/:id` 按 id 查单节
| 5 | 导师能力是新建实体还是扩展现有 match 体系? | 后端开发 | (待补充) |
**VIP**
- `vip_roles`超级个体角色配置name、sort供管理端下拉选择
- `users` 表:`is_vip``vip_expire_date``vip_activated_at``vip_sort``vip_role``vip_name``vip_avatar``vip_project``vip_contact``vip_bio`
- 权益判断:`is_vip=1``vip_expire_date>NOW()`;无则从 orders 兜底product_type=`fullbook`/`vip`pay_time+365 天)
- 默认价格¥1980权益已定义在 vip.go智能纪要、会议纪要库、案例库、链接资源、解锁全章、匹配伙伴、排行、VIP 标识)
**精选推荐与热门章节(业务规则补充)**
- **精选推荐**(首页「为你推荐」前 3 章):按正文章节阅读量从高到低排序,同量按更新时间;取前 3 章,依次标「热门」「推荐」「精选」。兜底:无阅读数据时按最近更新取 3 章;再兜底为预设固定 3 章。
- **热门章节**(搜索页等):同上阅读量排序,取更多条(如 10 条)。兜底:无阅读数据时按购买次数;再兜底为默认列表。
- **排除**:序言、尾声、附录不参与排序与推荐。
- **管理端**:算法驱动,无需运营勾选「推荐」;固定兜底章节可产品预设或后台配置。
以下是回答:
1、73章为内容章数的统计
2、不是。导师是导师属于咨询服务对接的人
3、章节分类
4、会员1980元。
5、后端创建导师
---
## 实现方案讨论(基于澄清后的需求)
> 各角色分析理解 15 题作答及后端补充说明后,发表实现看法。
### 【产品经理】
需求已厘清,可按以下 MVP 范围推进:
- **73 章**:沿用 chapters 表73 = 行数统计;「案例库」按篇章/章节分类展示即可。
- **会员**:全章免费 + 自动进入超级个体名单¥1980 沿用现 vip 逻辑。
- **导师**:独立于内容作者,需新建导师实体与预约流程。
**验收标准建议**:① 首页展示最新更新、阅读进度、超级个体、精选推荐、最新新增;② 目录按篇章聚合、支持 NEW 标识、免费/¥1③ 会员落地页支付后 is_vip=1、vip_expire_date 正确;④ 导师列表可搜索筛选、详情可预约;⑤ 资料编辑保存后手机/微信号必填方可使用提现与找伙伴。
### 【后端工程师】
**可直接复用的**chapters、users含 vip 字段、orders、vip 开通逻辑。小程序已有 `/api/miniprogram/book/*``/api/miniprogram/vip/*``/api/miniprogram/user/*`
**需新增/扩展**
1. **chapters 表**:新增 `is_new`(或类似字段)支持 NEW 标签;若无则用 `created_at` 近 N 天判断。
2. **首页聚合**`book/latest-chapters` 已有;可新增 `book/recommended`(精选)、首页「最新新增」复用 latest 按时间筛。
3. **导师模块**:新建 `mentors` 表(头像、姓名、简介、技能标签、价格、服务内容、判断风格等);新建 `mentor_consultations`预约单user_id、mentor_id、时间、状态、支付接口`GET/POST /api/miniprogram/mentors`(列表/详情/预约)。
4. **个人资料扩展**users 表可扩展 `story_*``help_offer``help_need``project_intro` 等;或新建 `user_profiles` 关联 users。编辑接口扩展现有 `user/profile`
**实施顺序**:① 章节 NEW 标记 + 首页/目录所需接口补齐 → ② 会员落地(现 vip 已够)→ ③ 导师表 + 预约接口 → ④ 资料扩展。
### 【管理端开发工程师】
**可复用**章节管理admin/chapters、db/book、用户/VIP 管理db/users、db/vip-roles
**需新增**
1. **导师管理**`/api/admin/mentors``/api/db/mentors`CRUD + 上下架;依赖后端 mentors 表。
2. **预约管理**`/api/admin/mentor-consultations`,列表、状态筛选、导出。
3. **章节 NEW**:若 chapters 新增 is_new管理端章节编辑页增加「标记 NEW」勾选。
4. **会员**:现 db/users 已支持 Set VIP权益文案可配置化若后续需要
**实施顺序**:待后端 mentors、consultations 表与接口就绪后,再开发导师管理、预约管理页面;章节 NEW 可与后端同步上线。
### 【小程序开发工程师】
**可复用**首页index、目录catalog、VIP 页vip、个人中心profile、支付流程。现有 `book/all-chapters``vip/status``user/profile``pay` 等已覆盖基础能力。
**需新增/改造**
1. **首页**:按稿子接入「最新更新」「精选推荐」「最新新增」;`book/latest-chapters``book/hot` 已有,需确认 recommended 接口;超级个体复用 `vip/members`
2. **目录**:按 part 聚合、展示 NEW、免费/¥1数据源 `book/all-chapters`NEW 依赖后端字段或策略。
3. **会员落地**:新页或改造 vip 页,权益展示 + 购买按钮,支付走现 `pay`
4. **导师**:新页「选择导师」「导师详情」,接入 `mentors` 列表/详情/预约接口。
5. **资料编辑**:扩展表单字段(个人故事、互助需求、项目介绍)、手机/微信号弹窗(稿子 comprehensive_profile_editor_v1_2
**实施顺序**:① 首页/目录 UI 与数据对接 → ② 会员落地 → ③ 导师列表+详情 → ④ 资料编辑扩展。
### 【测试人员】
**核心场景**
1. **阅读/付费**:免费节直接读;付费节未购/VIP 不可读VIP 全章可读;单节购买与 VIP 购买互不冲突。
2. **会员**:开通后 is_vip、vip_expire_date 正确;超级个体名单可见;权益生效。
3. **导师**:列表搜索/筛选、详情展示、预约创建、支付(若预约收费)。
4. **资料**:编辑保存成功;手机/微信号未填时提现、找伙伴应拦截并引导弹窗。
**联调**小程序↔APIbook、vip、user、mentors管理端↔APIchapters、mentors、consultations
**实施顺序**:接口就绪后补充用例;优先阅读/会员,再导师、资料。
### 实现路线图(共识)
| 阶段 | 后端 | 管理端 | 小程序 | 测试 |
|-----|------|-------|--------|------|
| **P0** | chapters 支持 NEWbook/latest、book/recommended 确认或补齐 | 章节编辑支持 NEW | 首页/目录 UI 与数据对接 | 阅读/会员用例 |
| **P1** | 会员沿用现 vip无新增接口 | — | 会员落地页 | 会员开通验收 |
| **P2** | mentors 表 + consultations 表;列表/详情/预约接口 | 导师 CRUD、预约列表 | 导师列表+详情+预约 | 导师预约流程 |
| **P3** | users 扩展或 user_profilesprofile 接口扩展 | — | 资料编辑扩展、手机/微弹窗 | 资料+拦截校验 |
**启动条件**:产品确认 MVP 范围与验收标准后,后端先输出 P0 接口方案,管理端/小程序按路线图跟进。
---
## 各开发对新需求的看法
### 【后端开发】
需求清晰,与现有 chapter/book/vip 模型兼容度高,可复用为主、增量开发。导师和资料扩展是主要新增点,技术风险可控。建议产品尽早确认 P2 导师价格配置方式(固定/可配置)、预约状态流转,便于接口设计。
### 【管理端开发工程师】
见下方「管理端建设性与补充说明」。
### 【小程序开发工程师】
稿子完整、交互明确,实现难度主要在数据对接和组件复用。首页/目录 P0 已落地;会员、导师、资料按阶段推进即可。建议后端接口响应格式稳定后再做样式微调,减少返工。
### 【测试人员】
场景边界清楚,可分批补充用例。需关注:导师预约与支付的联调、资料未填时的拦截逻辑、会员与单节购买的权益优先级。
---
## 管理端建设性与补充说明(基于小程序需求)
> 管理端对应小程序各模块,除基础 CRUD 外,建议补充以下能力以更好支持运营与数据闭环。
| 小程序模块 | 管理端基础能力 | 建设性补充 | 说明 |
|-----------|----------------|------------|------|
| **首页/目录** | 章节 NEW 标记 | ① 精选推荐固定兜底章节配置<br>② 章节阅读量/点击数据看板 | 算法兜底可运营配置;运营需看到哪些章节受欢迎 |
| **会员落地** | 用户 VIP 开通 | ① 会员权益文案配置化<br>② 会员开通/续费统计 | 权益文案可随活动调整;统计支撑运营决策 |
| **导师/预约** | 导师 CRUD、预约列表 | ① 导师排序/推荐位<br>② 咨询项目与价格配置<br>③ 预约数据统计(按导师/按时间) | 小程序列表顺序可运营控制;价格可调;数据支撑导师运营 |
| **我的/分享收益** | (若已有收益逻辑) | ① 收益明细与分润规则配置<br>② 提现审核流程 | 小程序有分享收益、可提现金额,管理端需审核与配置 |
| **个人资料** | — | ① 用户资料完善率统计<br>② (若涉及敏感)资料审核 | 支撑找伙伴匹配质量;可选能力 |
### 管理端补充优先级建议
| 优先级 | 补充项 | 与小程序关联 |
|--------|--------|--------------|
| 高 | 导师排序/推荐位、咨询项目价格配置 | 小程序导师列表展示顺序、v2 弹窗价格 |
| 高 | 精选推荐兜底章节配置 | 小程序首页精选推荐无数据时的展示 |
| 中 | 会员权益文案配置、开通统计 | 小程序会员落地页权益展示 |
| 中 | 预约数据统计 | 导师运营效果评估 |
| 低 | 资料完善率、提现审核 | 找伙伴质量、收益闭环 |
---
## 开发协作方案
> 各开发角色如何协作、谁先谁后、交接点、并行与串行。
### 协作总原则
- **产品先行**MVP 范围与验收标准确定后,开发方可启动。
- **后端先行**:接口契约先出,小程序/管理端再对接。
- **分阶段接力**:按 P0→P1→P2→P3 推进,每阶段有明确交付与验收。
- **接口契约**:后端每阶段输出接口文档(路径、请求/响应、字段),前端按契约开发。
### 阶段内协作时序
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ P0首页/目录 + NEW 标记 │
└─────────────────────────────────────────────────────────────────────────────────────┘
产品确认 MVP 与验收
【后端】输出 P0 接口契约
· chapters 是否新增 is_new若否说明「最新新增」判定规则如 created_at 近 7 天)
· book/latest-chapters、book/recommended 响应格式
· book/all-chapters 是否返回 is_new 或等价信息
├──────────────────────────┬──────────────────────────┐
▼ ▼ ▼
【后端】实现 P0 接口 【管理端】章节编辑支持 NEW 【小程序】首页/目录 UI
(迁移脚本 + handler (依赖 chapters 结构) (按契约 Mock 或直连)
│ │ │
└──────────────────────────┴──────────────────────────┘
【测试】阅读/会员用例补充 ──► 联调验证 ──► P0 验收
```
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ P1会员落地 │
└─────────────────────────────────────────────────────────────────────────────────────┘
无新接口,沿用现 vip 与 pay
【小程序】会员落地页(权益展示 + 购买按钮)
【测试】会员开通验收 ──► P1 验收
```
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ P2导师 + 预约 │
└─────────────────────────────────────────────────────────────────────────────────────┘
【后端】输出 P2 接口契约
· mentors 表结构、字段
· mentor_consultations 表结构、状态流转
· GET/POST /api/miniprogram/mentors列表/详情/预约)
· GET/POST /api/admin/mentors、/api/admin/mentor-consultations
├──────────────────────────┬──────────────────────────┐
▼ ▼ ▼
【后端】实现 mentors + 预约接口 【管理端】导师 CRUD、预约列表 【小程序】导师列表+详情+预约
(迁移 + 小程序接口 + admin 接口) (依赖 admin 接口) (按契约对接)
│ │ │
└──────────────────────────┴──────────────────────────┘
【测试】导师预约流程 ──► 三端联调 ──► P2 验收
```
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ P3资料编辑扩展 │
└─────────────────────────────────────────────────────────────────────────────────────┘
【后端】输出 P3 接口契约
· users 扩展字段 或 user_profiles 表
· user/profile 接口扩展(个人故事、互助需求、项目介绍、手机、微信号)
· 提现/找伙伴入口的「手机/微未填」校验规则
├──────────────────────────┐
▼ ▼
【后端】实现 profile 扩展 【小程序】资料编辑扩展、弹窗
│ │
└──────────────────────────┘
【测试】资料 + 拦截校验 ──► P3 验收
```
### 角色职责与交接
| 角色 | 职责 | 交接给谁 | 交接物 |
|------|------|----------|--------|
| **产品** | 确认 MVP、验收标准P0 前完成 | 全体 | 需求文档(或会议纪要中验收部分) |
| **后端** | 每阶段输出接口契约并实现 | 管理端、小程序、测试 | 接口文档(路径、字段、示例) |
| **管理端** | P0 章节 NEWP2 导师/预约管理 | 测试 | 可用的管理端页面 |
| **小程序** | P0P3 按阶段实现 C 端页面 | 测试 | 可联调的小程序 |
| **测试** | 每阶段补充用例、联调验收 | 产品 | 验收报告 |
### 并行与串行
| 关系 | 说明 |
|------|------|
| **产品 → 后端** | 串行:产品确认后,后端才能定方案 |
| **后端 → 管理端/小程序** | 串行开头:接口契约出后才能开发;契约出后可并行 |
| **管理端 ↔ 小程序** | 并行:同阶段内各自对接各自接口,无互相依赖 |
| **P0 ↔ P1** | P1 可早于 P0 完成(会员无新接口);但建议 P0 先验收再开 P2 |
| **P2 管理端** | 依赖后端 mentors 接口;可与小程序并行,但都等后端 |
### 沟通节点
| 节点 | 参与 | 目的 |
|------|------|------|
| 需求确认会 | 产品 + 全体 | 定 MVP、验收标准 |
| P0 接口评审 | 后端 + 管理端 + 小程序 | 确认 chapters is_new、book 接口格式 |
| P2 接口评审 | 后端 + 管理端 + 小程序 | 确认 mentors、consultations 模型 |
| 每阶段联调 | 后端 + 管理端 + 小程序 + 测试 | 验证功能、过 checklist |
| 阻塞时 | 阻塞方 + 被依赖方 | 快速澄清、调整契约 |
### 协作 checklist每阶段结束前
- [ ] 后端:接口已挂到正确路由组,文档已更新
- [ ] 管理端:仅用 `/api/admin/*``/api/db/*`,字段与接口一致
- [ ] 小程序:仅用 `/api/miniprogram/*`,错误处理完整
- [ ] 测试:用例已补充,联调通过
- [ ] 全体:过 soul-change-checklist
---
## 各角色经验与业务理解更新
本次会议结论已同步至各角色 `agent/{角色}/evolution/2026-02-28.md`
### 产品经理
- stitch_soul 串联「内容→会员→导师」变现路径;73 章、导师、案例库、会员需在需求文档中明确定义
- stitch_soul 串联「内容→会员→导师」变现路径;临时需求池 10 个稿子覆盖完整流程;需在正式需求文档中明确业务定义与验收标准
### 后端开发
- soul-api 已有 chapterbookvip;导师能力需新建或扩展现有 match 体系;预约、会员权益需与支付打通
- 需新建或扩展导师实体;现有 chapter/book/vip 可与产品核对后复用;接口挂 `/api/miniprogram/*`
### 管理端开发工程师
-支撑章节、导师、会员、预约的 CRUD 与配置,字段与 miniprogram 端统一
-管理章节、导师、会员、预约、收益;待后端方案确定后规划管理页面
### 小程序开发工程师
- 首页/目录/导师/会员/资料五类页面,全部走 `/api/miniprogram/*`,支付走微信支付
- 首页/目录/会员/导师/资料五类页面;待需求与接口确定后分阶段实现
### 测试人员
- 阅读进度、免费/付费会员权益、导师预约与支付、资料完善与提现限制为关键联调场景
- 关键场景:阅读/付费/会员/导师预约/资料;待需求确定后补充联调用例
### 团队共享
- stitch_soul 为 stitch 产品线在 Soul 创业派对上的扩展,需与现有 soul-api/soul-admin/miniprogram 架构协同,避免混用 admin/miniprogram 路由
- stitch_soul 与现有三端架构协同;路由约定保持不变:小程序 `/api/miniprogram/*`,管理端 `/api/admin/*``/api/db/*`
---
---
## 开发团队重新分析:怎么实现(可执行方案)
> 在问题 15 作答、精选推荐算法、实现路线图基础上,结合现有代码梳理出的可执行实现方案。
### 现状与差异
| 能力 | 现状 | 与需求差异 |
|------|------|------------|
| 精选推荐 / 热门 | `book/hot` 按 sort_order 取 10 条 | 需按**阅读量**排序;排除序言/尾声/附录;精选取 3 条并打「热门/推荐/精选」 |
| 最新更新 / 最新新增 | `book/latest-chapters` 按 updated_at 取 20 条;**未挂 miniprogram** | 需挂到 miniprogram「最新新增」可复用或加 is_new 筛选 |
| 目录 NEW | chapters 无 is_new | 需新增 is_new 或按 created_at 近 N 天 |
| 会员 | vip + pay 已有 | 无差异 |
| 导师 | 无 | 需新建 mentors、consultations |
| 资料扩展 | user/profile 有基础字段 | 需扩展 story/help_offer/help_need/project_intro |
### 分阶段实现清单(可执行)
#### P0首页/目录 + NEW + 精选推荐算法
| 序号 | 角色 | 动作 | 产出 |
|-----|------|------|------|
| P0-1 | 后端 | chapters 表新增 `is_new`bool迁移脚本 + AutoMigrate | 字段可用 |
| P0-2 | 后端 | `book/hot` 改为按阅读量排序:从 reading_progress 按 section_id 分组 count兜底按 updated_at排除 part 含「序言/尾声/附录」 | 符合算法 |
| P0-3 | 后端 | 新增 `book/recommended`:同 hot 逻辑,取前 3 条,返回时带 tag热门/推荐/精选) | 首页精选用 |
| P0-4 | 后端 | `book/latest-chapters` 挂到 miniprogram 组 | 小程序可调 |
| P0-5 | 后端 | `book/all-chapters` 响应中每章带 `isNew` | 目录 NEW 展示 |
| P0-6 | 管理端 | 章节编辑页增加「标记 NEW」勾选 | 运营可配置 |
| P0-7 | 小程序 | 首页最新更新latest、精选推荐recommended、最新新增all-chapters 筛 isNew、超级个体vip/members、阅读进度已有 | 首页按稿子完成 |
| P0-8 | 小程序 | 目录:按 part 聚合、展示 NEW、免费/¥1 | 目录按稿子完成 |
| P0-9 | 测试 | 阅读/会员用例精选推荐取数、NEW 展示 | 联调通过 |
**阅读量数据来源**`reading_progress` 表按 `section_id` 分组 count若无数据则用兜底updated_at 或固定 3 章)。
#### P1会员落地
| 序号 | 角色 | 动作 | 产出 |
|-----|------|------|------|
| P1-1 | 小程序 | 会员落地页(权益展示 + ¥1980 购买),支付走现 pay | 可开通会员 |
| P1-2 | 测试 | 会员开通验收 | 通过 |
#### P2导师 + 预约
| 序号 | 角色 | 动作 | 产出 |
|-----|------|------|------|
| P2-1 | 后端 | 新建 mentors 表(**支持每个导师独立价格配置**:单次/半年/年度、mentor_consultations 表;迁移脚本 | 表就绪 |
| P2-2 | 后端 | `GET/POST /api/miniprogram/mentors`(列表/详情/预约,价格从导师配置读取);`GET/POST /api/admin/mentors``/api/admin/mentor-consultations` | 接口可用 |
| P2-3 | 管理端 | 导师管理 CRUD、**导师价格配置(每个导师独立)**、预约列表(状态筛选、导出) | 可管理导师 |
| P2-4 | 小程序 | 导师列表、导师详情、**联系导师按钮点击 → 弹出 v2 弹窗(选择咨询项目)**、预约入口 | 可预约 |
| P2-5 | 测试 | 导师预约流程 | 联调通过 |
#### P3资料编辑扩展
| 序号 | 角色 | 动作 | 产出 |
|-----|------|------|------|
| P3-1 | 后端 | users 扩展 story_best_month、story_achievement、story_turning、help_offer、help_need、project_intro或新建 user_profiles | 字段可用 |
| P3-2 | 后端 | `user/profile` 接口读写扩展字段;提现/找伙伴入口校验手机/微 | 接口可用 |
| P3-3 | 小程序 | 资料编辑扩展表单;手机/微未填时弹窗并拦截提现/找伙伴;**我的页头像资料卡片加「编辑」图标 → 跳转个人资料展示页** | 符合稿子 |
| P3-4 | 测试 | 资料保存、拦截校验 | 通过 |
### 接口契约速查(后端输出后可据此开发)
**P0**
- `GET /api/miniprogram/book/recommended``{ data: [{ id, mid, sectionTitle, partTitle, tag: "热门"|"推荐"|"精选", ... }] }`
- `GET /api/miniprogram/book/hot` → 同算法limit 10无 tag
- `GET /api/miniprogram/book/latest-chapters` → 新增挂载
- `GET /api/miniprogram/book/all-chapters` → 每项增加 `isNew`
**P2**
- `GET /api/miniprogram/mentors?q=&skill=` → 列表(含每导师价格,从配置读取)
- `GET /api/miniprogram/mentors/:id` → 详情(含单次/半年/年度价格,从配置读取)
- `POST /api/miniprogram/mentors/:id/book` → 预约
- 后端 mentors 表/模型:支持 `price_single``price_half_year``price_year` 等按导师配置;管理端 PUT `/api/admin/mentors` 或 db 接口支持写入
**P3**
- `user/profile` 请求/响应增加 story_*、help_offer、help_need、project_intro
### 实施顺序(单人在多端开发时)
1. 产品确认 MVP 与验收标准(可复用会议纪要)。
2. 后端完成 P0-1P0-5输出接口契约 → 管理端 P0-6、小程序 P0-7P0-8 并行。
3. P0 联调验收后P1 小程序独立完成。
4. 后端 P2-1P2-2 → 管理端 P2-3、小程序 P2-4 并行。
5. 后端 P3-1P3-2 → 小程序 P3-3。
---
## 附录:页面重构专项会议(设计稿全覆盖 10 张图)
> **触发**:用户要求读取 stitch_soul 全部图片并开会,明确涉及「页面重构」的需求;此前会议未逐张覆盖设计稿。
---
### 各角色发言(页面重构专项)
**【产品经理】**
10 张稿子覆盖 8 类页面首页、目录、会员落地、我的VIP+收益)、个人资料展示、资料编辑(完整+弹窗)、导师列表、导师详情(含咨询选择弹窗)。页面重构优先级:首页/目录P0 已有)→ 会员落地P1→ 导师P2→ 资料展示与编辑P3。需澄清找伙伴高亮逻辑、导师详情 v1/v2 与弹窗关系、我的页与个人资料的跳转关系。
**【后端开发】**
页面重构主要影响前端后端需配合P2 导师列表/详情/预约接口返回的字段需支撑卡片展示头像、简介、标签数组、价格P3 资料扩展字段需覆盖个人故事、互助需求、项目介绍。接口契约与现有实现方案一致。
**【管理端开发工程师】**
管理端无对应设计稿,但导师管理、预约列表、章节 NEW 勾选等页面需与小程序风格一致(深色主题、标签样式)。可参考 stitch_soul 的组件规范做管理端组件库扩展。
**【小程序开发工程师】**
10 张稿子结构清晰,适合组件化:权益卡片、标签、弹窗、底部按钮、数据统计卡片可抽成通用组件。深色主题需统一定义变量。首页、目录 P0 已完成;会员落地、导师、资料按阶段推进时需严格按稿子还原布局与交互。
**【测试人员】**
页面重构验收重点:① 各页面与设计稿一致性(布局、颜色、标签);② 弹窗触发时机(手机/微未填、咨询选择);③ 深色主题在小程序中的表现;④ 组件复用时样式无错乱。
---
### 设计稿清单与重构识别
> 基于对 stitch_soul 下 **全部 10 张 design 图片** 的逐张阅读,补充「页面重构」识别与组件化建议。此前会议未逐张覆盖,本节补齐。
### 设计稿清单与重构识别
| # | 目录 | 页面类型 | 页面重构要点 | 映射阶段 |
|---|------|----------|--------------|----------|
| 1 | `optimized_home_content_feed_v1` | 首页内容流 | **布局**:顶部品牌+搜索→最新更新大卡片→阅读进度→超级个体→精选推荐→最新新增;**组件**搜索框、大卡片、进度条、头像列表、内容卡片、NEW 标签;**交互**:展开/折叠、价格 ¥1 | P0 |
| 2 | `catalog_with_new_additions_v1` | 目录 | **布局**:书籍概览卡片→按 part 可展开/折叠列表;**组件**:免费/NEW/¥1 标签、章节列表项、折叠箭头;**主题**:深色模式;**交互**:找伙伴图标高亮(动态状态) | P0 |
| 3 | `premium_membership_landing_v1` | 会员落地页 | **布局**导航→VIP 宣传区→内容权益 4 卡 + 社交权益 4 卡(双列)→底部固定按钮;**组件**:权益卡片(图标+文字、¥1980 按钮;**色彩**:绿/黄/橙强调色;**需提取**:权益卡片通用组件 | P1 |
| 4 | `professional_profile_with_earnings_vip` | 我的VIP+收益) | **布局**用户区VIP 徽章、会员/匹配/排行标签、到期时间)→分享收益→阅读统计→最近阅读→我的订单/关于作者;**组件**:数据卡片、统计三列、最近阅读项;**状态**VIP、收益、可提现 | P1/P3 |
| 5 | `enhanced_professional_profile` | 个人资料展示 | **布局**:头像+昵称+MBTI/地区→基本信息→个人故事→互助需求→项目介绍→「成为超级个体」;**组件**:卡片分组、复制按钮、奖杯/星星/循环图标;**动态内容**:故事/需求长度不固定 | P3 |
| 6 | `comprehensive_profile_editor_v1_1` | 资料编辑(完整版) | **布局**:温馨提示→头像→基本信息→核心联系方式→个人故事→互助需求→项目介绍→保存;**组件**:表单输入、下拉、地区图钉、多行文本;**样式**:深色主题,浅绿强调 | P3 |
| 7 | `comprehensive_profile_editor_v1_2` | 资料编辑(弹窗) | **组件**:居中弹窗,手机号/微信号输入、保存/取消;**触发**:提现/找伙伴入口时手机或微信号未填;**需设计**:弹窗触发时机、必填校验 | P3 |
| 8 | `mentor_listing_screen` | 导师列表 | **布局**:搜索→筛选标签→推荐导师列表;**组件**:导师卡片(头像、姓名、简介、标签、价格、预约);**数据**需头像、姓名、简介、标签数组、价格、ID | P2 |
| 9 | `mentor_detail_profile_1` | 导师详情 v1 | **布局**:头像+姓名+理念+引言→01 为什么找→02 提供什么→03 收费标准→04 判断风格→联系导师;**组件**:编号区块、标签组、收费表格、划线价;**强调色**:青色 | P2 |
| 10 | `mentor_detail_profile_2` | 导师详情 v2咨询选择弹窗 | **组件**:居中弹窗,单选(单次/半年/年度)、原价划掉、推荐标签、确认选择;**背景**:模糊的「我的」页;**复用**:可与会员/购买类弹窗共用模式 | P2 |
### 跨稿子组件抽取建议
| 组件 | 复用页面 | 说明 |
|------|----------|------|
| 权益卡片 | 会员落地、导师详情 | 图标+标题+描述,圆角深灰背景 |
| 标签Tag | 目录、导师列表、导师详情、个人资料 | 免费/NEW/¥1、技能标签、MBTI/地区 |
| 弹窗(手机/微、咨询选择) | 资料编辑、导师详情 | 居中圆角、输入/单选、保存/确认+取消 |
| 底部固定按钮 | 会员落地、导师详情、资料编辑 | 宽按钮、主色填充 |
| 数据统计卡片 | 我的、阅读进度 | 多列数字+图标+说明 |
| 头像+昵称+标签区 | 个人资料、超级个体、导师 | 圆形头像、下方标签 |
### 深色主题与色彩体系(统一约束)
- **主色**:深黑/深灰背景,白/浅灰文字
- **强调色**:绿色(主 CTA、VIP、黄色会员、推荐、橙色社交权益、部分标签、青色导师详情
- **一致性**:所有 10 张稿均为深色模式,重构时需统一定义 CSS 变量或主题配置
### 待确认(页面重构相关)— 已澄清
| # | 问题 | 作答 |
|---|------|------|
| 1 | 底部导航「找伙伴」高亮逻辑? | **不用改**,保持现状 |
| 2 | 导师详情 v1 与 v2 弹窗关系? | **导师详情 v1** 点击下方「联系导师」按钮 → 弹出 **v2 弹窗**(选择咨询项目) |
| 3 | 「我的」页与「个人资料展示」的跳转? | **我的**页头像资料卡片增加「编辑」图标,点击进入**个人资料展示页**enhanced_professional_profile |
---

View File

@@ -59,4 +59,5 @@ YYYY-MM-DD_会议主题.md
| 日期 | 主题 | 参与角色 | 文件 |
|------|------|---------|------|
| 2026-02-27 | 开发进度同步会议 | 产品、后端、管理端、小程序 | [2026-02-27_开发进度同步会议.md](2026-02-27_开发进度同步会议.md) |
| 2026-02-28 | 临时需求池 stitch_soul 需求评审 | 产品、后端、管理端、小程序、测试 | [2026-02-28_临时需求池stitch_soul需求评审.md](2026-02-28_临时需求池stitch_soul需求评审.md) |
| 2026-02-28 | 临时需求池 stitch_soul 需求评审含页面重构专项·10 张图全覆盖) | 产品、后端、管理端、小程序、测试 | [2026-02-28_临时需求池stitch_soul需求评审.md](2026-02-28_临时需求池stitch_soul需求评审.md) |
| 2026-02-28 | 个人资料页实现评估profile-show / profile-edit | 产品、后端、管理端、小程序、测试 | [2026-02-28_个人资料页实现评估.md](2026-02-28_个人资料页实现评估.md) |

View File

@@ -93,7 +93,18 @@ description: Soul 创业派对管理端开发规范。在 soul-admin/ 下编辑
---
## 7. 何时使用本 Skill
## 7. 小程序变更驱动的管理端补充(必守)
> **小程序有功能变更时,管理端须根据 C 端能力主动补充管理功能。**
- **触发**:小程序新增/优化任何可配置、可运营的功能(如价格、推荐位、文案、审核流等)
- **动作**:管理端开发工程师分析该功能在后台需要哪些配置、审核、统计,并输出管理端需求
- **后端配合**:需为每个可配置项设计对应的 admin/db 接口(如每个导师独立价格配置)
- **示例**:导师价格 → 后端 mentors 表支持按导师配置价格;管理端导师编辑页增加价格字段
---
## 8. 何时使用本 Skill
-**soul-admin/** 下新增或修改页面、组件、API 调用时。
- 在管理端新增任何网络请求时(必须仅使用 admin/db 等管理端路径)。

View File

@@ -60,8 +60,10 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
## 6. 表单与输入框
- **输入框 padding**设置 padding、背景、边框时,`<view>` 包裹 `<input>`padding/背景/边框写在 view 上input 设置 `width: 100%``font-size``color``background: transparent`。可避免光标截断、布局异常。
- **示例**`<view class="form-input"><input ... /></view>``.form-input { padding: 16rpx 24rpx; ... }``.form-input input { width: 100%; font-size: 28rpx; ... }`
- **input / textarea padding**:给 `<input>``<textarea>` 设置 padding 时,**必须**`<view>` 包裹padding 写在 view 上;不在 input/textarea 自身上设 padding。可避免光标截断、布局异常等原生组件表现问题
- **正确写法**
- input`<view class="form-input"><input class="form-input-inner" ... /></view>``.form-input { padding: 16rpx 24rpx; background: #1F2937; }``.form-input-inner { width: 100%; font-size: 28rpx; background: transparent; }`
- textarea`<view class="form-textarea-wrap"><textarea class="form-textarea-inner" ... /></view>``.form-textarea-wrap { padding: 16rpx 24rpx; background: #1F2937; }``.form-textarea-inner { width: 100%; font-size: 28rpx; background: transparent; min-height: 160rpx; }`
---
@@ -70,6 +72,6 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
-**miniprogram/** 下新增或修改页面、组件、utils 时。
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
- 做阅读、支付、推荐、提现等与 soul-api 对接的功能时。
- 做表单、输入框样式时(遵循 §6 包裹 input 的写法)。
- 做表单、input/textarea 样式时(遵循 §6用 view 包裹padding 写在 view 上)。
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。

View File

@@ -218,6 +218,19 @@ sequenceDiagram
**原则**:不确定时,管理端开发工程师主动与产品/小程序开发工程师确认。
### 4.1 管理端跟进原则(必守)
> **小程序变更 → 管理端须跟进做管理功能补充。**
当小程序有功能新增或变更时,管理端开发工程师**必须**主动分析:该功能在后台需要哪些**配置、审核、统计**能力,并同步提出管理端需求。后端需配合提供相应的 admin/db 接口。
**示例**stitch_soul 导师模块):
- 小程序导师列表展示价格、v2 弹窗选咨询项目(单次/半年/年度)
- 管理端补充:导师 CRUD + **每个导师独立价格配置**(单次、半年、年度)
- 后端mentors 表支持 price_single、price_half_year、price_year 等字段,或 mentor_prices 关联表
**流程**:小程序需求评审时,管理端即参与并输出「管理端补充项」;后端设计接口时一并考虑 admin 配置能力。
---
## 5. 与现有 Skills/Rules 的配合

View File

@@ -9,8 +9,8 @@ App({
globalData: {
// API基础地址 - 连接真实后端
// baseUrl: 'https://soulapi.quwanzhi.com',
baseUrl: 'https://souldev.quwanzhi.com',
// baseUrl: 'http://localhost:8080',
// baseUrl: 'https://souldev.quwanzhi.com',
baseUrl: 'http://localhost:8080',
// 小程序配置 - 真实AppID

View File

@@ -16,7 +16,7 @@
"pages/addresses/edit",
"pages/withdraw-records/withdraw-records",
"pages/vip/vip",
"pages/member-detail/member-detail"
"pages/member-detail/member-detail","pages/mentors/mentors","pages/mentor-detail/mentor-detail","pages/profile-show/profile-show","pages/profile-edit/profile-edit"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -212,6 +212,7 @@ Page({
navBarHeight: app.globalData.navBarHeight
})
this.updateUserStatus()
this.loadBookDataFromServer()
this.loadDailyChapters()
this.loadTotalFromServer()
},
@@ -219,12 +220,64 @@ Page({
async loadTotalFromServer() {
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
if (res && res.total) {
this.setData({ totalSections: res.total })
if (res && (res.total || (res.data && res.data.length))) {
this.setData({ totalSections: res.total || (res.data || []).length })
}
} catch (e) {}
},
// stitch_soul P0-8从服务端加载目录按 part 聚合,带 isNew、免费/¥1
async loadBookDataFromServer() {
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const rows = (res && res.data) || (res && res.chapters) || []
if (rows.length === 0) return
const partMap = new Map()
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
rows.forEach((r, idx) => {
const pid = r.partId || r.part_id || 'part-1'
const cid = r.chapterId || r.chapter_id || 'chapter-1'
if (!partMap.has(pid)) {
const partIdx = partMap.size
partMap.set(pid, {
id: pid,
number: numbers[partIdx] || String(partIdx + 1),
title: r.partTitle || r.part_title || '未分类',
subtitle: r.chapterTitle || r.chapter_title || '',
chapters: new Map()
})
}
const part = partMap.get(pid)
if (!part.chapters.has(cid)) {
part.chapters.set(cid, {
id: cid,
title: r.chapterTitle || r.chapter_title || '未分类',
sections: []
})
}
const ch = part.chapters.get(cid)
ch.sections.push({
id: r.id,
mid: r.mid ?? r.MID ?? 0,
title: r.sectionTitle || r.section_title || r.title || '',
isFree: r.isFree === true || (r.price !== undefined && r.price === 0),
price: r.price ?? 1,
isNew: r.isNew === true || r.is_new === true
})
})
const bookData = Array.from(partMap.values()).map(p => ({
...p,
chapters: Array.from(p.chapters.values())
}))
const firstPart = bookData[0] && bookData[0].id
this.setData({
bookData,
totalSections: rows.length,
expandedPart: firstPart || this.data.expandedPart
})
} catch (e) { console.log('[Chapters] 加载目录失败:', e) }
},
onShow() {
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {

View File

@@ -71,10 +71,11 @@
<view class="chapter-header">{{chapter.title}}</view>
<view class="section-list">
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section">
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}">
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
<view class="section-left">
<text class="section-lock {{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? 'lock-open' : 'lock-closed'}}">{{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? '○' : '●'}}</text>
<text class="section-title {{section.isFree || hasFullBook || purchasedSections.indexOf(section.id) > -1 ? '' : 'text-muted'}}">{{section.id}} {{section.title}}</text>
<text wx:if="{{section.isNew}}" class="tag tag-new">NEW</text>
</view>
<view class="section-right">
<text wx:if="{{section.isFree}}" class="tag tag-free">免费</text>

View File

@@ -224,6 +224,14 @@
color: #00CED1;
}
.tag-new {
background: rgba(0, 206, 209, 0.15);
color: #00CED1;
font-size: 20rpx;
padding: 2rpx 8rpx;
margin-left: 8rpx;
}
.text-brand {
color: #00CED1;
}

View File

@@ -162,60 +162,89 @@ Page({
}
},
// 从服务端获取精选推荐加权算法阅读量50% + 时效30% + 付款率20%)和最新更新
// 从服务端获取精选推荐、最新更新stitch_soulbook/recommended、book/latest-chapters
async loadFeaturedFromServer() {
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const chapters = (res && res.data) ? res.data : (res && res.chapters) ? res.chapters : []
let featured = (res && res.featuredSections) ? res.featuredSections : []
// 服务端未返回精选时从前端按更新时间取前3条有效章节作为回退
if (featured.length === 0 && chapters.length > 0) {
const valid = chapters.filter(c => {
const id = (c.id || '').toLowerCase()
const pt = (c.part_title || c.partTitle || '').toLowerCase()
return !id.includes('preface') && !id.includes('epilogue') && !id.includes('appendix')
&& !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
})
featured = valid
.sort((a, b) => new Date(b.updated_at || b.updatedAt || 0) - new Date(a.updated_at || a.updatedAt || 0))
.slice(0, 5)
}
const tagMap = ['热门', '推荐', '精选']
const tagClassMap = ['tag-hot', 'tag-rec', 'tag-rec']
if (featured.length > 0) {
this.setData({
featuredSections: featured.slice(0, 3).map((s, i) => ({
// 1. 精选推荐:优先用 book/recommended按阅读量+算法,带 热门/推荐/精选 标签)
let featured = []
try {
const recRes = await app.request({ url: '/api/miniprogram/book/recommended', silent: true })
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
featured = recRes.data.map((s, i) => ({
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.cleanPartTitle || s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag: tagMap[i] || '精选',
tagClass: tagClassMap[i] || 'tag-rec'
title: s.sectionTitle || s.section_title || s.title || s.chapterTitle || '',
part: (s.partTitle || s.part_title || '').replace(/[_|]/g, ' ').trim(),
tag: s.tag || ['热门', '推荐', '精选'][i] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
}))
this.setData({ featuredSections: featured })
}
} catch (e) { console.log('[Index] book/recommended 失败:', e) }
// 兜底:无 recommended 时从 all-chapters 按更新时间取前3
if (featured.length === 0) {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const chapters = (res && res.data) || (res && res.chapters) || []
const valid = chapters.filter(c => {
const pt = (c.part_title || c.partTitle || '').toLowerCase()
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
})
if (valid.length > 0) {
const tagMap = ['热门', '推荐', '精选']
featured = valid
.sort((a, b) => new Date(b.updated_at || b.updatedAt || 0) - new Date(a.updated_at || a.updatedAt || 0))
.slice(0, 3)
.map((s, i) => ({
id: s.id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag: tagMap[i] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
}))
this.setData({ featuredSections: featured })
}
}
// 最新更新 = 按 updated_at 排序第1篇排除序言/尾声/附录)
const validChapters = chapters.filter(c => {
const id = (c.id || '').toLowerCase()
const pt = (c.part_title || c.partTitle || '').toLowerCase()
return !id.includes('preface') && !id.includes('epilogue') && !id.includes('appendix')
&& !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
})
if (validChapters.length > 0) {
validChapters.sort((a, b) => new Date(b.updated_at || b.updatedAt || 0) - new Date(a.updated_at || a.updatedAt || 0))
const latest = validChapters[0]
this.setData({
latestSection: {
id: latest.id || latest.section_id,
mid: latest.mid ?? latest.MID ?? 0,
title: latest.section_title || latest.sectionTitle || latest.title || latest.chapterTitle || '',
part: latest.cleanPartTitle || latest.part_title || latest.partTitle || ''
}
// 2. 最新更新:用 book/latest-chapters 取第1条
try {
const latestRes = await app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true })
const latestList = (latestRes && latestRes.data) ? latestRes.data : []
if (latestList.length > 0) {
const l = latestList[0]
this.setData({
latestSection: {
id: l.id,
mid: l.mid ?? l.MID ?? 0,
title: l.section_title || l.sectionTitle || l.title || l.chapterTitle || '',
part: l.part_title || l.partTitle || ''
}
})
}
} catch (e) {
// 兜底:从 all-chapters 取
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const chapters = (res && res.data) || (res && res.chapters) || []
const valid = chapters.filter(c => {
const pt = (c.part_title || c.partTitle || '').toLowerCase()
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
})
if (valid.length > 0) {
valid.sort((a, b) => new Date(b.updated_at || b.updatedAt || 0) - new Date(a.updated_at || a.updatedAt || 0))
const latest = valid[0]
this.setData({
latestSection: {
id: latest.id,
mid: latest.mid ?? latest.MID ?? 0,
title: latest.section_title || latest.sectionTitle || latest.title || latest.chapterTitle || '',
part: latest.part_title || latest.partTitle || ''
}
})
}
}
} catch (e) {
console.log('[Index] 从服务端加载推荐失败,使用默认:', e)
console.log('[Index] 从服务端加载推荐失败:', e)
}
},
@@ -286,16 +315,12 @@ Page({
try {
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
const chapters = (res && res.data) || (res && res.chapters) || []
const sortOrder = c => (c.sortOrder ?? c.sectionOrder ?? c.sort_order ?? 0)
// 优先取 sort_order > 62 的「新增」章节;若无则取最近更新的前 10 章(排除序言/尾声/附录)
let candidates = chapters.filter(c => sortOrder(c) > 62)
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) {
const id = (c) => (c.id || '').toLowerCase()
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
candidates = chapters.filter(c =>
!id(c).includes('preface') && !id(c).includes('epilogue') && !id(c).includes('appendix')
&& !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
)
candidates = chapters.filter(exclude)
}
const latest = candidates
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))

View File

@@ -65,6 +65,12 @@ Page({
// 解锁弹窗
showUnlockModal: false,
// 手机/微信号弹窗stitch_soul
showContactModal: false,
contactPhone: '',
contactWechat: '',
contactSaving: false,
// 匹配价格(可配置)
matchPrice: 1,
@@ -192,8 +198,86 @@ Page({
},
// 点击匹配按钮
handleMatchClick() {
async handleMatchClick() {
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
// stitch_soul导师顾问 → 跳转选择导师页
if (currentType && currentType.id === 'mentor') {
wx.navigateTo({ url: '/pages/mentors/mentors' })
return
}
// 找伙伴/资源对接:需先完善联系方式
if (this.data.isLoggedIn && currentType?.matchFromDB) {
await this.ensureContactInfo(() => this._handleMatchClickInner(currentType))
} else {
this._handleMatchClickInner(currentType)
}
},
async ensureContactInfo(callback) {
const userId = app.globalData.userInfo?.id
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const phone = (res?.data?.phone || '').trim()
const wechat = (res?.data?.wechatId || '').trim()
if (phone || wechat) {
callback()
return
}
this.setData({
showContactModal: true,
contactPhone: phone || '',
contactWechat: wechat || '',
})
this._contactCallback = callback
} catch (e) {
callback()
}
},
closeContactModal() {
this.setData({ showContactModal: false })
this._contactCallback = null
},
onContactPhoneInput(e) { this.setData({ contactPhone: e.detail.value }) },
onContactWechatInput(e) { this.setData({ contactWechat: e.detail.value }) },
async saveContactInfo() {
const phone = (this.data.contactPhone || '').trim()
const wechat = (this.data.contactWechat || '').trim()
if (!phone && !wechat) {
wx.showToast({ title: '请至少填写手机号或微信号', icon: 'none' })
return
}
this.setData({ contactSaving: true })
try {
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: {
userId: app.globalData.userInfo?.id,
phone: phone || undefined,
wechatId: wechat || undefined,
},
})
if (phone) wx.setStorageSync('user_phone', phone)
if (wechat) wx.setStorageSync('user_wechat', wechat)
this.loadStoredContact()
this.closeContactModal()
wx.showToast({ title: '已保存', icon: 'success' })
const cb = this._contactCallback
this._contactCallback = null
if (cb) cb()
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
}
this.setData({ contactSaving: false })
},
_handleMatchClickInner(currentType) {
// 资源对接类型需要登录+购买章节才能使用
if (currentType && currentType.id === 'investor') {

View File

@@ -265,6 +265,32 @@
</view>
</view>
<!-- 手机/微信号弹窗stitch_soul comprehensive_profile_editor_v1_2 -->
<view class="modal-overlay contact-modal-overlay" wx:if="{{showContactModal}}" bindtap="closeContactModal">
<view class="contact-modal" catchtap="preventBubble">
<text class="contact-modal-title">请完善联系方式</text>
<view class="contact-modal-hint">需完善手机号或微信号才能使用找伙伴功能</view>
<view class="form-input-wrap">
<text class="form-label">手机号</text>
<view class="form-input-inner">
<text class="form-icon">📱</text>
<input class="form-input" type="tel" placeholder="请输入您的手机号" value="{{contactPhone}}" bindinput="onContactPhoneInput"/>
</view>
</view>
<view class="form-input-wrap">
<text class="form-label">微信号</text>
<view class="form-input-inner">
<text class="form-icon">💬</text>
<input class="form-input" type="text" placeholder="请输入您的微信号" value="{{contactWechat}}" bindinput="onContactWechatInput"/>
</view>
</view>
<view class="contact-modal-btn" bindtap="saveContactInfo" disabled="{{contactSaving}}">
{{contactSaving ? '保存中...' : '保存'}}
</view>
<text class="contact-modal-cancel" bindtap="closeContactModal">取消</text>
</view>
</view>
<!-- 解锁弹窗 -->
<view class="modal-overlay" wx:if="{{showUnlockModal}}" bindtap="closeUnlockModal">
<view class="modal-content unlock-modal" catchtap="preventBubble">

View File

@@ -1200,3 +1200,26 @@
font-size: 28rpx;
color: #fff;
}
/* 手机/微信号弹窗 - comprehensive_profile_editor_v1_2 */
.contact-modal-overlay { background: rgba(0,0,0,0.85); backdrop-filter: blur(8rpx); }
.contact-modal {
width: 90%; max-width: 600rpx; background: #1A1A1A; border-radius: 48rpx;
padding: 48rpx 40rpx; border: 1rpx solid rgba(255,255,255,0.1);
}
.contact-modal-title { display: block; text-align: center; font-size: 40rpx; font-weight: bold; color: #fff; margin-bottom: 16rpx; }
.contact-modal-hint { display: block; font-size: 24rpx; color: #9CA3AF; text-align: center; margin-bottom: 40rpx; }
.contact-modal .form-input-wrap { margin-bottom: 32rpx; }
.contact-modal .form-label { display: block; font-size: 24rpx; color: #9CA3AF; margin-bottom: 12rpx; margin-left: 8rpx; }
.contact-modal .form-input-inner {
display: flex; align-items: center; padding: 24rpx 32rpx; background: #262626; border-radius: 24rpx;
}
.contact-modal .form-input-inner .form-icon { font-size: 36rpx; margin-right: 16rpx; opacity: 0.7; }
.contact-modal .form-input-inner .form-input { flex: 1; font-size: 28rpx; color: #fff; background: transparent; }
.contact-modal-btn {
width: 100%; height: 96rpx; line-height: 96rpx; text-align: center;
background: #4FD1C5; color: #000; font-size: 32rpx; font-weight: bold; border-radius: 24rpx;
margin-top: 16rpx;
}
.contact-modal-btn[disabled] { opacity: 0.6; }
.contact-modal-cancel { display: block; text-align: center; font-size: 28rpx; color: #9CA3AF; margin-top: 24rpx; padding: 16rpx; }

View File

@@ -0,0 +1,113 @@
/**
* Soul创业派对 - 导师详情stitch_soul
* 联系导师按钮 → 弹出 v2 弹窗(选择咨询项目)
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
mentor: null,
loading: true,
showConsultModal: false,
consultOptions: [],
selectedType: '',
selectedAmount: 0,
creating: false,
},
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
if (options.id) this.loadDetail(options.id)
},
async loadDetail(id) {
this.setData({ loading: true })
try {
const res = await app.request({ url: `/api/miniprogram/mentors/${id}`, silent: true })
if (res?.success && res.data) {
const d = res.data
if (d.judgmentStyle && typeof d.judgmentStyle === 'string') {
d.judgmentStyleArr = d.judgmentStyle.split(/[,]/).map(s => s.trim()).filter(Boolean)
} else {
d.judgmentStyleArr = []
}
const fmt = v => v != null ? String(Math.floor(v)).replace(/\B(?=(\d{3})+(?!\d))/g, ',') : ''
d.priceSingleFmt = fmt(d.priceSingle)
d.priceHalfYearFmt = fmt(d.priceHalfYear)
d.priceYearFmt = fmt(d.priceYear)
const options = []
if (d.priceSingle != null) options.push({ type: 'single', label: '单次咨询', desc: '1小时深度沟通', price: d.priceSingle, priceFmt: fmt(d.priceSingle), original: null })
if (d.priceHalfYear != null) options.push({ type: 'half_year', label: '半年咨询', desc: '不限次数 · 关键节点陪伴', price: d.priceHalfYear, priceFmt: fmt(d.priceHalfYear), original: '98,000' })
if (d.priceYear != null) options.push({ type: 'year', label: '年度咨询', desc: '全年度战略顾问', price: d.priceYear, priceFmt: fmt(d.priceYear), original: '196,000', recommend: true })
this.setData({
mentor: d,
consultOptions: options,
loading: false,
})
} else {
this.setData({ loading: false })
}
} catch (e) {
this.setData({ loading: false })
}
},
onContactTap() {
if (!this.data.mentor || this.data.consultOptions.length === 0) {
wx.showToast({ title: '暂无咨询项目', icon: 'none' })
return
}
this.setData({
showConsultModal: true,
selectedType: this.data.consultOptions[0].type,
selectedAmount: this.data.consultOptions[0].price,
})
},
closeConsultModal() {
this.setData({ showConsultModal: false })
},
onSelectOption(e) {
const item = e.currentTarget.dataset.item
this.setData({ selectedType: item.type, selectedAmount: item.price })
},
async onConfirmConsult() {
const { mentor, selectedType } = this.data
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
wx.navigateTo({ url: '/pages/my/my' })
return
}
this.setData({ creating: true })
try {
const res = await app.request({
url: `/api/miniprogram/mentors/${mentor.id}/book`,
method: 'POST',
data: { userId, consultationType: selectedType },
})
if (res?.success && res.data) {
this.setData({ showConsultModal: false, creating: false })
wx.showToast({ title: '预约创建成功', icon: 'success' })
// TODO: 调起支付 productType: mentor_consultation, productId: res.data.id
wx.showModal({
title: '预约成功',
content: '请联系客服完成后续对接',
showCancel: false,
})
} else {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
}
} catch (e) {
wx.showToast({ title: '创建失败', icon: 'none' })
}
this.setData({ creating: false })
},
goBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1 @@
{"usingComponents":{},"navigationStyle":"custom"}

View File

@@ -0,0 +1,118 @@
<!-- 导师详情 -->
<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-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
<block wx:else>
<view class="mentor-header" wx:if="{{mentor}}">
<view class="mentor-avatar-wrap">
<image wx:if="{{mentor.avatar}}" class="mentor-avatar" src="{{mentor.avatar}}" mode="aspectFill"></image>
<view wx:else class="mentor-avatar-placeholder">{{mentor.name ? mentor.name[0] : '?'}}</view>
</view>
<text class="mentor-name">{{mentor.name}}</text>
<text class="mentor-intro">{{mentor.intro}}</text>
<view class="mentor-quote" wx:if="{{mentor.quote}}">{{mentor.quote}}</view>
</view>
<view class="block" wx:if="{{mentor.whyFind}}">
<view class="block-header"><text class="block-num">01</text><text class="block-title">为什么找{{mentor.name}}?</text></view>
<text class="block-text">{{mentor.whyFind}}</text>
</view>
<view class="block" wx:if="{{mentor.offering}}">
<view class="block-header"><text class="block-num">02</text><text class="block-title">提供什么?</text></view>
<text class="block-text">{{mentor.offering}}</text>
</view>
<view class="block" wx:if="{{mentor.priceSingle || mentor.priceHalfYear || mentor.priceYear}}">
<view class="block-header"><text class="block-num">03</text><text class="block-title">收费标准</text></view>
<view class="price-table">
<view class="price-thead">
<text class="p-col-2">咨询项目</text>
<text class="p-col-center">时长</text>
<text class="p-col-right">价格</text>
</view>
<view class="price-row" wx:if="{{mentor.priceSingle}}">
<text class="p-col-2">单次咨询</text>
<text class="p-col-center">1小时</text>
<text class="p-col-right price-num">¥{{mentor.priceSingleFmt || mentor.priceSingle}}</text>
</view>
<view class="price-row price-row-alt" wx:if="{{mentor.priceHalfYear}}">
<text class="p-col-2">半年咨询</text>
<text class="p-col-center">-</text>
<view class="p-col-right">
<text class="price-original">98,000</text>
<text class="price-num">¥{{mentor.priceHalfYearFmt || mentor.priceHalfYear}}</text>
</view>
</view>
<view class="price-row" wx:if="{{mentor.priceYear}}">
<text class="p-col-2">年度咨询</text>
<text class="p-col-center">-</text>
<view class="p-col-right">
<text class="price-original">196,000</text>
<text class="price-num">¥{{mentor.priceYearFmt || mentor.priceYear}}</text>
</view>
</view>
</view>
</view>
<view class="block" wx:if="{{mentor.judgmentStyle}}">
<view class="block-header"><text class="block-num">04</text><text class="block-title">判断风格</text></view>
<view class="style-tags">
<text class="style-tag" wx:for="{{mentor.judgmentStyleArr}}" wx:key="*this">{{item}}</text>
</view>
</view>
<view class="bottom-btn-area">
<view class="contact-btn" bindtap="onContactTap">
<text class="contact-icon">💬</text>
<text>联系导师</text>
</view>
</view>
</block>
<!-- v2 弹窗:选择咨询项目 -->
<view class="modal-overlay" wx:if="{{showConsultModal}}" bindtap="closeConsultModal">
<view class="modal-content" catchtap="">
<view class="modal-header">
<text class="modal-title">选择咨询项目</text>
<text class="modal-close" bindtap="closeConsultModal">✕</text>
</view>
<view class="consult-options">
<view
wx:for="{{consultOptions}}"
wx:key="type"
class="consult-option {{selectedType === item.type ? 'option-selected' : ''}}"
data-item="{{item}}"
bindtap="onSelectOption"
>
<view class="option-row1">
<text class="option-label">{{item.label}}</text>
<text class="option-rec" wx:if="{{item.recommend}}">推荐</text>
<view class="option-radio {{selectedType === item.type ? 'radio-selected' : ''}}"></view>
</view>
<view class="option-row2">
<text class="option-desc">{{item.desc}}</text>
<view class="option-price-wrap">
<text class="option-price-old" wx:if="{{item.original}}">¥{{item.original}}</text>
<text class="option-price">¥{{item.priceFmt || item.price}}</text>
</view>
</view>
</view>
</view>
<view class="modal-footer">
<view class="confirm-btn" bindtap="onConfirmConsult" disabled="{{creating}}">
{{creating ? '处理中...' : '确认选择 →'}}
</view>
<text class="footer-hint">点击确认即代表同意 <text class="footer-link">服务协议</text></text>
</view>
</view>
</view>
<view class="bottom-space"></view>
</view>

View File

@@ -0,0 +1,70 @@
/* 按 mentor_detail_profile_1 + mentor_detail_profile_2 设计稿 */
.page { background: #000; min-height: 100vh; color: #fff; }
.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(0,0,0,0.9); border-bottom: 1rpx solid #27272a; }
.nav-back { width: 60rpx; }
.back-icon { font-size: 44rpx; color: #fff; }
.nav-title { font-size: 36rpx; font-weight: 500; }
.nav-placeholder-r { width: 60rpx; }
.loading { padding: 96rpx; text-align: center; color: #71717a; }
.mentor-header { display: flex; flex-direction: column; align-items: center; padding: 48rpx 24rpx; text-align: center; }
.mentor-avatar-wrap { margin-bottom: 24rpx; }
.mentor-avatar { width: 192rpx; height: 192rpx; border-radius: 50%; border: 4rpx solid #4FD1C5; display: block; background: rgba(255,255,255,0.1); }
.mentor-avatar-placeholder { width: 192rpx; height: 192rpx; border-radius: 50%; border: 4rpx solid #4FD1C5; background: rgba(79,209,197,0.2); display: flex; align-items: center; justify-content: center; font-size: 72rpx; font-weight: bold; color: #4FD1C5; }
.mentor-name { font-size: 48rpx; font-weight: bold; margin-bottom: 8rpx; }
.mentor-intro { font-size: 28rpx; color: #4FD1C5; font-weight: 500; margin-bottom: 24rpx; }
.mentor-quote { padding: 32rpx; background: #1E1E1E; border-left: 8rpx solid #4FD1C5; border-radius: 24rpx; font-size: 28rpx; color: #d4d4d8; text-align: left; width: 100%; max-width: 600rpx; box-sizing: border-box; }
.block { padding: 0 24rpx 64rpx; }
.block-header { display: flex; align-items: center; margin-bottom: 24rpx; }
.block-num { background: #4FD1C5; color: #000; font-size: 24rpx; font-weight: bold; padding: 8rpx 20rpx; border-radius: 999rpx; margin-right: 16rpx; }
.block-title { font-size: 36rpx; font-weight: bold; }
.block-text { font-size: 26rpx; color: #A0AEC0; line-height: 1.7; display: block; }
/* 03 收费标准 - 表头青色 */
.price-table { background: #1E1E1E; border-radius: 24rpx; overflow: hidden; }
.price-thead { display: flex; align-items: center; padding: 24rpx 32rpx; background: #4FD1C5; color: #000; font-size: 24rpx; font-weight: bold; }
.p-col-2 { flex: 2; }
.p-col-center { flex: 1; text-align: center; }
.p-col-right { flex: 1; text-align: right; min-width: 160rpx; }
.price-row { display: flex; align-items: center; padding: 32rpx; border-bottom: 1rpx solid #27272a; }
.price-row:last-child { border-bottom: none; }
.price-row-alt { background: rgba(255,255,255,0.02); }
.price-row .p-col-2 { font-size: 28rpx; font-weight: 500; }
.price-row .p-col-center { font-size: 24rpx; color: #71717a; }
.price-num { font-size: 28rpx; font-weight: bold; color: #4FD1C5; }
.price-original { font-size: 20rpx; color: #71717a; text-decoration: line-through; display: block; margin-bottom: 4rpx; }
.price-row .p-col-right { display: flex; flex-direction: column; align-items: flex-end; }
.style-tags { display: flex; flex-wrap: wrap; gap: 24rpx; }
.style-tag { padding: 16rpx 32rpx; background: #1E1E1E; border: 1rpx solid #3f3f46; border-radius: 999rpx; font-size: 28rpx; font-weight: 500; }
.bottom-btn-area { position: fixed; bottom: 0; left: 0; right: 0; padding: 24rpx 32rpx; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); background: rgba(0,0,0,0.95); border-top: 1rpx solid #27272a; z-index: 50; }
.contact-btn { display: flex; align-items: center; justify-content: center; gap: 16rpx; width: 100%; height: 96rpx; background: #4FD1C5; color: #000; font-size: 32rpx; font-weight: bold; border-radius: 24rpx; box-shadow: 0 8rpx 32rpx rgba(79,209,197,0.25); }
.contact-icon { font-size: 36rpx; }
/* v2 弹窗 - mentor_detail_profile_2 */
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); z-index: 200; display: flex; align-items: flex-end; justify-content: center; }
.modal-content { width: 100%; max-height: 85vh; background: #121212; border-radius: 32rpx 32rpx 0 0; padding: 32rpx; padding-bottom: calc(48rpx + env(safe-area-inset-bottom)); border: 1rpx solid rgba(255,255,255,0.08); }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32rpx; padding-bottom: 24rpx; border-bottom: 1rpx solid #27272a; }
.modal-title { font-size: 40rpx; font-weight: bold; }
.modal-close { font-size: 40rpx; color: #71717a; padding: 16rpx; }
.consult-options { margin-bottom: 32rpx; }
.consult-option { padding: 32rpx; margin-bottom: 24rpx; background: #1E1E1E; border-radius: 24rpx; border: 2rpx solid #3f3f46; }
.option-selected { border-color: #4FD1C5; background: rgba(79,209,197,0.08); }
.option-row1 { display: flex; align-items: center; margin-bottom: 16rpx; }
.option-label { font-size: 32rpx; font-weight: bold; flex: 1; }
.option-rec { font-size: 20rpx; padding: 6rpx 16rpx; background: #ED8936; color: #fff; border-radius: 8rpx; margin-right: 16rpx; }
.option-radio { width: 40rpx; height: 40rpx; border-radius: 50%; border: 2rpx solid #71717a; flex-shrink: 0; }
.radio-selected { border-color: #4FD1C5; background: #4FD1C5; }
.option-row2 { display: flex; justify-content: space-between; align-items: flex-end; }
.option-desc { font-size: 24rpx; color: #71717a; }
.option-price-wrap { display: flex; flex-direction: column; align-items: flex-end; }
.option-price-old { font-size: 22rpx; color: #71717a; text-decoration: line-through; margin-bottom: 4rpx; }
.option-price { font-size: 36rpx; font-weight: bold; color: #4FD1C5; }
.confirm-btn { width: 100%; height: 100rpx; line-height: 100rpx; text-align: center; background: #4FD1C5; color: #000; font-size: 32rpx; font-weight: bold; border-radius: 24rpx; box-shadow: 0 8rpx 32rpx rgba(79,209,197,0.25); }
.confirm-btn[disabled] { opacity: 0.6; }
.footer-hint { display: block; font-size: 22rpx; color: #71717a; text-align: center; margin-top: 24rpx; }
.footer-link { color: #4FD1C5; }
.bottom-space { height: 180rpx; }

View File

@@ -0,0 +1,65 @@
/**
* Soul创业派对 - 选择导师stitch_soul
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
mentors: [],
loading: true,
searchKw: '',
filterSkill: '',
skills: ['全部', '项目结构判断', '产品架构', 'BP梳理', '职业转型'],
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.loadMentors()
},
onSearchInput(e) {
this.setData({ searchKw: e.detail.value })
this.loadMentors()
},
onFilterTap(e) {
const skill = e.currentTarget.dataset.skill
this.setData({ filterSkill: skill === '全部' ? '' : skill })
this.loadMentors()
},
async loadMentors() {
this.setData({ loading: true })
try {
let url = '/api/miniprogram/mentors'
const params = []
if (this.data.searchKw) params.push(`q=${encodeURIComponent(this.data.searchKw)}`)
if (this.data.filterSkill) params.push(`skill=${encodeURIComponent(this.data.filterSkill)}`)
if (params.length) url += '?' + params.join('&')
const res = await app.request({ url, silent: true })
if (res?.success && res.data) {
this.setData({ mentors: res.data })
} else {
this.setData({ mentors: [] })
}
} catch (e) {
this.setData({ mentors: [] })
}
this.setData({ loading: false })
},
onRefresh() {
this.loadMentors()
},
goDetail(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/mentor-detail/mentor-detail?id=${id}` })
},
goBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1 @@
{"usingComponents":{},"navigationStyle":"custom"}

View File

@@ -0,0 +1,70 @@
<!-- 选择导师 -->
<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-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="search-bar">
<view class="search-input-wrap">
<text class="search-icon">🔍</text>
<input
class="search-input"
placeholder="搜索导师、技能或行业..."
value="{{searchKw}}"
bindinput="onSearchInput"
/>
</view>
</view>
<view class="filter-row">
<view
class="filter-tag {{!filterSkill ? 'filter-active' : ''}}"
data-skill="全部"
bindtap="onFilterTap"
>全部</view>
<view
wx:for="{{skills}}"
wx:key="*this"
wx:if="{{item !== '全部'}}"
class="filter-tag {{filterSkill === item ? 'filter-active' : ''}}"
data-skill="{{item}}"
bindtap="onFilterTap"
>{{item}}</view>
</view>
<view class="section-header">
<text class="section-title">推荐导师</text>
<text class="section-more" bindtap="loadMentors">查看全部 </text>
</view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
<view class="mentor-list" wx:else>
<view class="mentor-card" wx:for="{{mentors}}" wx:key="id">
<view class="mentor-card-inner">
<view class="mentor-avatar-wrap">
<image wx:if="{{item.avatar}}" class="mentor-avatar" src="{{item.avatar}}" mode="aspectFill"></image>
<view wx:else class="mentor-avatar-placeholder">{{item.name ? item.name[0] : '?'}}</view>
</view>
<view class="mentor-info">
<text class="mentor-name">{{item.name}}</text>
<text class="mentor-intro">{{item.intro || ''}}</text>
<view class="mentor-tags" wx:if="{{item.tagsArr && item.tagsArr.length}}">
<text class="mentor-tag" wx:for="{{item.tagsArr}}" wx:key="*this" wx:for-item="tag">{{tag}}</text>
</view>
<view class="mentor-price-row">
<view class="mentor-price-wrap">
<text class="mentor-price-num">¥{{item.priceSingle || 0}}</text>
<text class="mentor-price-unit">起 / 单次咨询</text>
</view>
<view class="mentor-btn" data-id="{{item.id}}" bindtap="goDetail">预约</view>
</view>
</view>
</view>
</view>
</view>
<view class="empty" wx:if="{{!loading && mentors.length === 0}}">暂无导师</view>
<view class="bottom-space"></view>
</view>

View File

@@ -0,0 +1,40 @@
/* 按 mentor_listing_screen 设计稿 */
.page { background: #000; min-height: 100vh; color: #fff; }
.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(0,0,0,0.95); border-bottom: 1rpx solid #27272a; }
.nav-back { width: 60rpx; }
.back-icon { font-size: 44rpx; color: #fff; }
.nav-title { font-size: 34rpx; font-weight: 500; }
.nav-placeholder-r { width: 60rpx; }
.search-bar { padding: 24rpx 24rpx 16rpx; }
.search-input-wrap { display: flex; align-items: center; background: #1C1C1E; border-radius: 24rpx; padding: 24rpx 32rpx; border: 1rpx solid #27272a; }
.search-icon { font-size: 36rpx; color: #71717a; margin-right: 16rpx; flex-shrink: 0; }
.search-input { flex: 1; font-size: 28rpx; color: #fff; background: transparent; }
.filter-row { display: flex; gap: 24rpx; padding: 24rpx; overflow-x: auto; }
.filter-tag { flex-shrink: 0; padding: 12rpx 32rpx; border-radius: 999rpx; font-size: 24rpx; background: #1C1C1E; border: 1rpx solid #27272a; color: #d4d4d8; }
.filter-active { background: #4FD1C5; border-color: #4FD1C5; color: #000; font-weight: 600; }
.section-header { display: flex; justify-content: space-between; align-items: flex-end; padding: 0 24rpx 24rpx; }
.section-title { font-size: 32rpx; font-weight: bold; color: #e4e4e7; }
.section-more { font-size: 24rpx; color: #4FD1C5; font-weight: 500; }
.loading { padding: 48rpx; text-align: center; color: #71717a; }
.mentor-list { padding: 0 24rpx 48rpx; }
.mentor-card { margin-bottom: 24rpx; background: #1C1C1E; border-radius: 32rpx; padding: 32rpx; border: 1rpx solid #27272a; }
.mentor-card-inner { display: flex; gap: 32rpx; }
.mentor-avatar-wrap { width: 112rpx; height: 112rpx; flex-shrink: 0; }
.mentor-avatar { width: 112rpx; height: 112rpx; border-radius: 50%; display: block; border: 1rpx solid #3f3f46; }
.mentor-avatar-placeholder { width: 112rpx; height: 112rpx; border-radius: 50%; background: rgba(79,209,197,0.2); border: 1rpx solid #3f3f46; display: flex; align-items: center; justify-content: center; font-size: 40rpx; font-weight: bold; color: #4FD1C5; }
.mentor-info { flex: 1; min-width: 0; }
.mentor-name { display: block; font-size: 32rpx; font-weight: bold; color: #fff; margin-bottom: 8rpx; }
.mentor-intro { display: block; font-size: 24rpx; color: #A1A1AA; margin-bottom: 16rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mentor-tags { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 24rpx; }
.mentor-tag { font-size: 20rpx; padding: 8rpx 16rpx; background: #2C2C2E; color: #E5E7EB; border-radius: 12rpx; border: 1rpx solid rgba(63,63,70,0.5); }
.mentor-price-row { display: flex; justify-content: space-between; align-items: center; padding-top: 24rpx; border-top: 1rpx solid #27272a; }
.mentor-price-wrap { display: flex; align-items: baseline; gap: 8rpx; }
.mentor-price-num { font-size: 36rpx; font-weight: bold; color: #4FD1C5; }
.mentor-price-unit { font-size: 24rpx; color: #71717a; }
.mentor-btn { padding: 12rpx 32rpx; background: #fff; color: #000; font-size: 24rpx; font-weight: bold; border-radius: 999rpx; }
.empty { padding: 96rpx; text-align: center; color: rgba(255,255,255,0.4); }
.bottom-space { height: 80rpx; }

View File

@@ -56,7 +56,14 @@ Page({
// 修改昵称弹窗
showNicknameModal: false,
editingNickname: ''
editingNickname: '',
// 手机/微信号弹窗stitch_soul comprehensive_profile_editor_v1_2
showContactModal: false,
contactPhone: '',
contactWechat: '',
contactSaving: false,
pendingWithdraw: false,
},
onLoad() {
@@ -687,6 +694,12 @@ Page({
wx.navigateTo({ url: '/pages/vip/vip' })
},
// 进入个人资料编辑页stitch_soul
goToProfileEdit() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
async handleWithdraw() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
const amount = parseFloat(this.data.pendingEarnings)
@@ -694,6 +707,10 @@ Page({
wx.showToast({ title: '暂无可提现金额', icon: 'none' })
return
}
await this.ensureContactInfo(() => this.doWithdraw(amount))
},
async doWithdraw(amount) {
wx.showModal({
title: '申请提现',
content: `确认提现 ¥${amount.toFixed(2)} `,
@@ -714,6 +731,69 @@ Page({
})
},
// 提现/找伙伴前检查手机或微信号未填则弹窗stitch_soul
async ensureContactInfo(callback) {
const userId = app.globalData.userInfo?.id
if (!userId) { callback(); return }
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
const phone = (res?.data?.phone || '').trim()
const wechat = (res?.data?.wechatId || '').trim()
if (phone || wechat) {
callback()
return
}
this.setData({
showContactModal: true,
contactPhone: phone || '',
contactWechat: wechat || '',
pendingWithdraw: true,
})
this._contactCallback = callback
} catch (e) {
callback()
}
},
closeContactModal() {
this.setData({ showContactModal: false, pendingWithdraw: false })
this._contactCallback = null
},
onContactPhoneInput(e) { this.setData({ contactPhone: e.detail.value }) },
onContactWechatInput(e) { this.setData({ contactWechat: e.detail.value }) },
async saveContactInfo() {
const phone = (this.data.contactPhone || '').trim()
const wechat = (this.data.contactWechat || '').trim()
if (!phone && !wechat) {
wx.showToast({ title: '请至少填写手机号或微信号', icon: 'none' })
return
}
this.setData({ contactSaving: true })
try {
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: {
userId: app.globalData.userInfo?.id,
phone: phone || undefined,
wechatId: wechat || undefined,
},
})
if (phone) wx.setStorageSync('user_phone', phone)
if (wechat) wx.setStorageSync('user_wechat', wechat)
this.closeContactModal()
wx.showToast({ title: '已保存', icon: 'success' })
const cb = this._contactCallback
this._contactCallback = null
if (cb) cb()
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
}
this.setData({ contactSaving: false })
},
// 阻止冒泡
stopPropagation() {},

View File

@@ -1,156 +1,108 @@
<!--pages/my/my.wxml-->
<!--Soul创业实验 - 我的页面 1:1还原Web版本-->
<view class="page page-transition">
<!-- 自定义导航栏 -->
<!-- 我的页 - professional_profile_with_earnings_vip 1:1 还原 -->
<view class="page">
<!-- 顶部导航:仅标题(设置已移至用户区右侧,避让胶囊) -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<text class="nav-title-left brand-color">我的</text>
</view>
<text class="nav-title">我的</text>
</view>
<!-- 导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 用户卡片 - 未登录:假资料界面,名字旁点击登录打开弹窗 -->
<view class="user-card card-gradient" wx:if="{{!isLoggedIn}}">
<view class="user-header-row">
<view class="avatar avatar-placeholder">
<image class="avatar-img" wx:if="{{guestAvatar}}" src="{{guestAvatar}}" mode="aspectFill"/>
<text class="avatar-text" wx:else>{{guestNickname[0] || '游'}}</text>
</view>
<view class="user-info-block">
<view class="user-name-row">
<text class="user-name">{{guestNickname}}</text>
<view class="btn-login-inline" bindtap="showLogin">点击登录</view>
</view>
<view class="user-id-row">
<text class="user-id user-id-guest">登录后查看完整信息</text>
</view>
</view>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value brand-color">--</text>
<text class="stat-label">已读章节</text>
</view>
<view class="stat-item">
<text class="stat-value brand-color">--</text>
<text class="stat-label">推荐好友</text>
</view>
<view class="stat-item">
<text class="stat-value gold-color">--</text>
<text class="stat-label">待领收益</text>
</view>
<!-- 未登录:引导登录 -->
<view class="guest-block" wx:if="{{!isLoggedIn}}">
<view class="guest-avatar">
<image wx:if="{{guestAvatar}}" class="guest-avatar-img" src="{{guestAvatar}}" mode="aspectFill"/>
<text wx:else class="guest-avatar-text">{{guestNickname[0] || '游'}}</text>
</view>
<text class="guest-name">{{guestNickname}}</text>
<view class="guest-login-btn" bindtap="showLogin">点击登录</view>
</view>
<!-- 用户卡片 - 已登录状态 -->
<view class="user-card card-gradient" wx:else>
<view class="user-header-row">
<!-- 头像 - 点击进VIP/设置头像 -->
<!-- 已登录:用户区 -->
<view class="header-block" wx:else>
<view class="user-row">
<view class="avatar-wrap" bindtap="onAvatarTap">
<view class="avatar {{isVip ? 'avatar-vip' : ''}}">
<image class="avatar-img" wx:if="{{userInfo.avatar}}" src="{{userInfo.avatar}}" mode="aspectFill"/>
<text class="avatar-text" wx:else>{{userInfo.nickname[0] || ''}}</text>
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
</view>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
<view class="vip-badge vip-badge-gray" wx:else bindtap="goToVip">VIP</view>
</view>
<!-- 用户信息 -->
<view class="user-info-block">
<view class="user-name-row">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<view class="vip-tags-row">
<view class="vip-tag-mini {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</view>
<view class="vip-tag-mini {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">匹配</view>
<view class="vip-tag-mini {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</view>
</view>
<view class="become-vip-chip" wx:if="{{!isVip}}" bindtap="goToVip">
<text class="chip-star">⭐</text><text class="chip-text">成为会员</text>
</view>
</view>
<view class="user-id-row" bindtap="copyUserId">
<text class="user-id">{{userWechat ? '微信: ' + userWechat : 'ID: ' + userIdShort}}</text>
<view class="user-meta">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<view class="vip-tags">
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">匹配</text>
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
</view>
<text class="user-id" bindtap="copyUserId">{{userWechat ? '微信: ' + userWechat : 'ID: ' + userIdShort}}</text>
<text class="vip-expire" wx:if="{{isVip && vipExpireDate}}">会员到期时间:{{vipExpireDate}}</text>
</view>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value brand-color">{{readCount}}</text>
<text class="stat-label">已读章节</text>
</view>
<view class="stat-item">
<text class="stat-value brand-color">{{referralCount}}</text>
<text class="stat-label">推荐好友</text>
</view>
<view class="stat-item" bindtap="goToReferral">
<text class="stat-value gold-color">{{pendingEarnings > 0 ? '¥' + pendingEarnings : '--'}}</text>
<text class="stat-label">我的收益</text>
<view class="user-actions">
<view class="action-btn" bindtap="handleMenuTap" data-id="settings"><text class="action-icon">⚙️</text></view>
<view class="action-btn" catchtap="goToProfileEdit"><text class="action-icon">✎</text></view>
</view>
</view>
</view>
<!-- 统一内容区 - 仅登录用户显示 -->
<view class="tab-content" wx:if="{{isLoggedIn}}">
<!-- 菜单:我的订单 + 设置 -->
<view class="menu-card card">
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
<view class="menu-left">
<view class="menu-icon icon-brand">📦</view>
<text class="menu-title">我的订单</text>
</view>
<view class="menu-right">
<text class="menu-arrow">→</text>
</view>
<!-- 已登录:内容区 -->
<view class="main-content" wx:if="{{isLoggedIn}}">
<!-- 分享收益 -->
<view class="card earnings-card">
<view class="card-header">
<text class="card-icon">💰</text>
<text class="card-title">分享收益</text>
</view>
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
<view class="menu-left">
<view class="menu-icon icon-gray">⚙️</view>
<text class="menu-title">设置</text>
<view class="earnings-grid">
<view class="earnings-col">
<text class="earnings-val primary">{{referralCount}}</text>
<text class="earnings-label">推荐好友</text>
</view>
<view class="menu-right">
<text class="menu-arrow">→</text>
<view class="earnings-col">
<text class="earnings-val primary">{{earnings === '-' ? '--' : earnings}}</text>
<text class="earnings-label">我的收益</text>
</view>
<view class="earnings-col" bindtap="handleWithdraw">
<text class="earnings-val primary">{{pendingEarnings === '-' ? '--' : pendingEarnings}}</text>
<text class="earnings-label">可提现金额</text>
</view>
</view>
</view>
<!-- 阅读统计 -->
<view class="stats-card card">
<view class="card-title">
<text class="title-icon">👁️</text>
<text>阅读统计</text>
<view class="card stats-card">
<view class="card-header">
<text class="card-icon">👁️</text>
<text class="card-title">阅读统计</text>
</view>
<view class="stats-row">
<view class="stats-grid">
<view class="stat-box">
<text class="stat-icon brand-color">📖</text>
<text class="stat-icon">📖</text>
<text class="stat-num">{{readCount}}</text>
<text class="stat-text">已读章节</text>
<text class="stat-label">已读章节</text>
</view>
<view class="stat-box">
<text class="stat-icon gold-color">⏱</text>
<text class="stat-icon">⏱</text>
<text class="stat-num">{{totalReadTime}}</text>
<text class="stat-text">阅读分钟</text>
<text class="stat-label">阅读分钟</text>
</view>
<view class="stat-box" wx:if="{{matchEnabled}}">
<text class="stat-icon pink-color">👥</text>
<view class="stat-box">
<text class="stat-icon">👥</text>
<text class="stat-num">{{matchHistory}}</text>
<text class="stat-text">匹配伙伴</text>
<text class="stat-label">匹配伙伴</text>
</view>
</view>
</view>
<!-- 最近阅读 -->
<view class="recent-card card">
<view class="card-title">
<text class="title-icon">📖</text>
<text>最近阅读</text>
<view class="card recent-card">
<view class="card-header">
<text class="card-icon">📖</text>
<text class="card-title">最近阅读</text>
</view>
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
<view
class="recent-item"
wx:for="{{recentChapters}}"
<view
class="recent-item"
wx:for="{{recentChapters}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
@@ -160,48 +112,46 @@
<text class="recent-index">{{index + 1}}</text>
<text class="recent-title">{{item.title}}</text>
</view>
<text class="recent-btn">继续阅读</text>
<text class="recent-link">继续阅读</text>
</view>
</view>
<view class="empty-state" wx:else>
<text class="empty-icon">📖</text>
<text class="empty-text">暂无阅读记录</text>
<view class="empty-btn" bindtap="goToChapters">去阅读 →</view>
<view class="recent-empty" wx:else>
<text class="recent-empty-text">暂无阅读记录</text>
<view class="recent-empty-btn" bindtap="goToChapters">去阅读 →</view>
</view>
</view>
<!-- 关于作者(最底部) -->
<view class="menu-card card" style="margin-top: 16rpx;">
<!-- 我的订单 + 关于作者 -->
<view class="card menu-card">
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
<view class="menu-left">
<view class="menu-icon-wrap icon-teal"><text class="menu-icon">📦</text></view>
<text class="menu-text">我的订单</text>
</view>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" bindtap="handleMenuTap" data-id="about">
<view class="menu-left">
<view class="menu-icon icon-brand"></view>
<text class="menu-title">关于作者</text>
</view>
<view class="menu-right">
<text class="menu-arrow">→</text>
<view class="menu-icon-wrap icon-blue"><text class="menu-icon"></text></view>
<text class="menu-text">关于作者</text>
</view>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<!-- 登录弹窗:可取消,用户主动选择是否登录 -->
<!-- 登录弹窗 -->
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
<view class="modal-content login-modal-content" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeLoginModal">✕</view>
<view class="login-icon">🔐</view>
<text class="login-title">登录 Soul创业实验</text>
<text class="login-title">登录 Soul创业派对</text>
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
<button
class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}"
bindtap="handleWechatLogin"
disabled="{{isLoggingIn || !agreeProtocol}}"
>
<button class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" bindtap="handleWechatLogin" disabled="{{isLoggingIn || !agreeProtocol}}">
<text class="btn-wechat-icon">微</text>
<text>{{isLoggingIn ? '登录中...' : '微信快捷登录'}}</text>
</button>
<view class="login-modal-cancel" bindtap="closeLoginModal">取消</view>
<view class="login-agree-row" catchtap="toggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}">{{agreeProtocol ? '✓' : ''}}</view>
<text class="agree-text">我已阅读并同意</text>
@@ -212,6 +162,30 @@
</view>
</view>
<!-- 手机/微信号弹窗 -->
<view class="modal-overlay contact-modal-overlay" wx:if="{{showContactModal}}" bindtap="closeContactModal">
<view class="contact-modal" catchtap="stopPropagation">
<text class="contact-modal-title">请完善联系方式</text>
<view class="contact-modal-hint">需完善手机号或微信号才能使用提现和找伙伴功能</view>
<view class="form-input-wrap">
<text class="form-label">手机号</text>
<view class="form-input-inner">
<text class="form-icon">📱</text>
<input class="form-input" type="tel" placeholder="请输入您的手机号" value="{{contactPhone}}" bindinput="onContactPhoneInput"/>
</view>
</view>
<view class="form-input-wrap">
<text class="form-label">微信号</text>
<view class="form-input-inner">
<text class="form-icon">💬</text>
<input class="form-input" type="text" placeholder="请输入您的微信号" value="{{contactWechat}}" bindinput="onContactWechatInput"/>
</view>
</view>
<view class="contact-modal-btn" bindtap="saveContactInfo" disabled="{{contactSaving}}">{{contactSaving ? '保存中...' : '保存'}}</view>
<text class="contact-modal-cancel" bindtap="closeContactModal">取消</text>
</view>
</view>
<!-- 修改昵称弹窗 -->
<view class="modal-overlay" wx:if="{{showNicknameModal}}" bindtap="closeNicknameModal">
<view class="modal-content nickname-modal" catchtap="stopPropagation">
@@ -220,21 +194,10 @@
<text class="modal-icon">✏️</text>
<text class="modal-title">修改昵称</text>
</view>
<view class="nickname-input-wrap">
<input
class="nickname-input"
type="nickname"
value="{{editingNickname}}"
placeholder="点击输入昵称"
placeholder-class="nickname-placeholder"
bindchange="onNicknameChange"
bindinput="onNicknameInput"
maxlength="20"
/>
<input class="nickname-input" type="nickname" value="{{editingNickname}}" placeholder="点击输入昵称" placeholder-class="nickname-placeholder" bindchange="onNicknameChange" bindinput="onNicknameInput" maxlength="20"/>
<text class="input-tip">微信用户可点击自动填充昵称</text>
</view>
<view class="modal-actions">
<view class="modal-btn modal-btn-cancel" bindtap="closeNicknameModal">取消</view>
<view class="modal-btn modal-btn-confirm" bindtap="confirmNickname">确定</view>
@@ -242,6 +205,5 @@
</view>
</view>
<!-- 底部留白 -->
<view class="bottom-space"></view>
</view>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,199 @@
/**
* Soul创业派对 - 资料编辑完整版comprehensive_profile_editor_v1_1
* 温馨提示、头像、基本信息、核心联系方式、个人故事、互助需求、项目介绍
*/
const app = getApp()
const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
Page({
data: {
statusBarHeight: 44,
avatar: '',
nickname: '',
mbti: '',
mbtiIndex: 0,
region: '',
industry: '',
businessScale: '',
position: '',
skills: '',
phone: '',
wechatId: '',
storyBestMonth: '',
storyAchievement: '',
storyTurning: '',
helpOffer: '',
helpNeed: '',
projectIntro: '',
mbtiOptions: MBTI_OPTIONS,
showMbtiPicker: false,
saving: false,
loading: true,
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.loadProfile()
},
async loadProfile() {
const userInfo = app.globalData.userInfo
if (!app.globalData.isLoggedIn || !userInfo?.id) {
this.setData({ loading: false })
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
return
}
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
if (res?.success && res.data) {
const d = res.data
const mbtiIndex = MBTI_OPTIONS.indexOf(d.mbti || '') >= 0 ? MBTI_OPTIONS.indexOf(d.mbti) : 0
this.setData({
avatar: d.avatar || '',
nickname: d.nickname || '',
mbti: d.mbti || '',
mbtiIndex,
region: d.region || '',
industry: d.industry || '',
businessScale: d.businessScale || '',
position: d.position || '',
skills: d.skills || '',
phone: d.phone || '',
wechatId: d.wechatId || wx.getStorageSync('user_wechat') || '',
storyBestMonth: d.storyBestMonth || '',
storyAchievement: d.storyAchievement || '',
storyTurning: d.storyTurning || '',
helpOffer: d.helpOffer || '',
helpNeed: d.helpNeed || '',
projectIntro: d.projectIntro || '',
loading: false,
})
} else {
this.setData({ loading: false })
}
} catch (e) {
this.setData({ loading: false })
}
},
goBack() { wx.navigateBack() },
onNicknameInput(e) { this.setData({ nickname: e.detail.value }) },
onRegionInput(e) { this.setData({ region: e.detail.value }) },
onIndustryInput(e) { this.setData({ industry: e.detail.value }) },
onBusinessScaleInput(e) { this.setData({ businessScale: e.detail.value }) },
onPositionInput(e) { this.setData({ position: e.detail.value }) },
onSkillsInput(e) { this.setData({ skills: e.detail.value }) },
onPhoneInput(e) { this.setData({ phone: e.detail.value }) },
onWechatInput(e) { this.setData({ wechatId: e.detail.value }) },
onStoryBestMonthInput(e) { this.setData({ storyBestMonth: e.detail.value }) },
onStoryAchievementInput(e) { this.setData({ storyAchievement: e.detail.value }) },
onStoryTurningInput(e) { this.setData({ storyTurning: e.detail.value }) },
onHelpOfferInput(e) { this.setData({ helpOffer: e.detail.value }) },
onHelpNeedInput(e) { this.setData({ helpNeed: e.detail.value }) },
onProjectIntroInput(e) { this.setData({ projectIntro: e.detail.value }) },
onMbtiPickerChange(e) {
const i = parseInt(e.detail.value, 10)
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
},
chooseAvatar() {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempPath = res.tempFiles[0].tempFilePath
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
}
this.setData({ saving: true })
try {
const payload = {
userId,
nickname: this.data.nickname.trim() || undefined,
mbti: this.data.mbti || undefined,
region: this.data.region.trim() || undefined,
industry: this.data.industry.trim() || undefined,
businessScale: this.data.businessScale.trim() || undefined,
position: this.data.position.trim() || undefined,
skills: this.data.skills.trim() || undefined,
phone: this.data.phone.trim() || undefined,
wechatId: this.data.wechatId.trim() || undefined,
storyBestMonth: this.data.storyBestMonth.trim() || undefined,
storyAchievement: this.data.storyAchievement.trim() || undefined,
storyTurning: this.data.storyTurning.trim() || undefined,
helpOffer: this.data.helpOffer.trim() || undefined,
helpNeed: this.data.helpNeed.trim() || undefined,
projectIntro: this.data.projectIntro.trim() || undefined,
}
if (payload.wechatId) wx.setStorageSync('user_wechat', payload.wechatId)
if (payload.phone) wx.setStorageSync('user_phone', payload.phone)
const hasUpdate = Object.keys(payload).some(k => k !== 'userId' && payload[k] != null)
if (!hasUpdate) {
wx.showToast({ title: '无变更', icon: 'none' })
this.setData({ saving: false })
return
}
await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: payload,
})
wx.showToast({ title: '保存成功', icon: 'success' })
if (app.globalData.userInfo && payload.nickname) {
app.globalData.userInfo.nickname = payload.nickname
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
setTimeout(() => wx.navigateBack(), 800)
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
}
this.setData({ saving: false })
},
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "编辑资料",
"usingComponents": {}
}

View File

@@ -0,0 +1,135 @@
<!-- 资料编辑 - comprehensive_profile_editor_v1_1 | input/textarea 用 view 包裹,配色 enhanced -->
<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="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 温馨提示 -->
<view class="tip-card">
<text class="tip-icon"></text>
<text class="tip-text">温馨提示:需完善手机号和微信号才能使用提现和找伙伴功能</text>
</view>
<!-- 头像 -->
<view class="avatar-section">
<view class="avatar-wrap" bindtap="chooseAvatar">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
<view class="avatar-camera">📷</view>
</view>
<text class="avatar-change">更换头像</text>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="form-row">
<text class="form-label">昵称</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入昵称" value="{{nickname}}" bindinput="onNicknameInput"/></view>
</view>
<view class="form-row form-row-2">
<view class="form-item">
<text class="form-label">MBTI</text>
<picker mode="selector" range="{{mbtiOptions}}" value="{{mbtiIndex}}" bindchange="onMbtiPickerChange">
<view class="form-input-wrap form-picker">{{mbti || '请选择'}}</view>
</picker>
</view>
<view class="form-item">
<text class="form-label">地区</text>
<view class="form-input-wrap form-input-suffix">
<input class="form-input-inner" placeholder="例如:杭州·余杭区" value="{{region}}" bindinput="onRegionInput"/>
<text class="form-suffix">📍</text>
</view>
</view>
</view>
<view class="form-row">
<text class="form-label">行业</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:新媒体/电商" value="{{industry}}" bindinput="onIndustryInput"/></view>
</view>
<view class="form-row">
<text class="form-label">业务体量</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如年GMV 5000万+" value="{{businessScale}}" bindinput="onBusinessScaleInput"/></view>
</view>
<view class="form-row">
<text class="form-label">职位</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:创始人/联合创始人" value="{{position}}" bindinput="onPositionInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我擅长</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如短视频制作、IP打造、私域运营" value="{{skills}}" bindinput="onSkillsInput"/></view>
</view>
</view>
<!-- 核心联系方式 -->
<view class="section">
<view class="section-title">
<text class="section-icon">📞</text>
<text>核心联系方式</text>
</view>
<view class="form-row">
<text class="form-label">手机号</text>
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="请输入手机号" value="{{phone}}" bindinput="onPhoneInput"/></view>
</view>
<view class="form-row">
<text class="form-label">微信号</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="请输入微信号" value="{{wechatId}}" bindinput="onWechatInput"/></view>
</view>
</view>
<!-- 个人故事 -->
<view class="section">
<view class="section-title">
<text class="section-icon">💡</text>
<text>个人故事</text>
</view>
<view class="form-row">
<text class="form-label">你最赚钱的一个月做的是什么</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如2021年主导电商大促单月GMV突破500W..." value="{{storyBestMonth}}" bindinput="onStoryBestMonthInput"/></view>
</view>
<view class="form-row">
<text class="form-label">最有成就感的一件事</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如帮助3个素人打造个人IP每月稳定变现5万+" value="{{storyAchievement}}" bindinput="onStoryAchievementInput"/></view>
</view>
<view class="form-row">
<text class="form-label">人生的转折点</text>
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="例如:辞去大厂工作开始做自媒体..." value="{{storyTurning}}" bindinput="onStoryTurningInput"/></view>
</view>
</view>
<!-- 互助需求 -->
<view class="section">
<view class="section-title">
<text class="section-icon">🤝</text>
<text>互助需求</text>
</view>
<view class="form-row">
<text class="form-label">我能帮助大家什么</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:短视频脚本、账号冷启动、私域转化" value="{{helpOffer}}" bindinput="onHelpOfferInput"/></view>
</view>
<view class="form-row">
<text class="form-label">我需要什么帮助</text>
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:寻找供应链资源、线下活动合作" value="{{helpNeed}}" bindinput="onHelpNeedInput"/></view>
</view>
</view>
<!-- 项目介绍 -->
<view class="section">
<view class="section-title">
<text class="section-icon">🚀</text>
<text>项目介绍</text>
</view>
<view class="form-row">
<view class="form-textarea-wrap form-textarea-lg"><textarea class="form-textarea-inner" placeholder="详细介绍您的项目,让潜在伙伴更好地了解您..." value="{{projectIntro}}" bindinput="onProjectIntroInput"/></view>
</view>
</view>
<view class="save-btn" bindtap="saveProfile" disabled="{{saving}}">
{{saving ? '保存中...' : '保存'}}
</view>
<view class="bottom-space"></view>
</scroll-view>
</view>

View File

@@ -0,0 +1,99 @@
/* 资料编辑 - comprehensive_profile_editor_v1_1 | 配色 enhancedinput/textarea 用 view 包裹 */
.page {
background: #050B14; min-height: 100vh; color: #fff;
width: 100%; box-sizing: border-box; overflow-x: hidden;
}
.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; }
.loading { padding: 96rpx; text-align: center; color: #94A3B8; }
.scroll-main { width: 100%; height: calc(100vh - 88rpx); padding: 24rpx; box-sizing: border-box; overflow-x: hidden; }
.tip-card {
display: flex; align-items: flex-start; gap: 24rpx;
padding: 32rpx;
background: rgba(94,234,212,0.08); border: 1rpx solid rgba(94,234,212,0.25);
border-radius: 24rpx; margin-bottom: 48rpx;
}
.tip-icon { font-size: 40rpx; color: #5EEAD4; flex-shrink: 0; }
.tip-text { font-size: 26rpx; color: rgba(94,234,212,0.95); line-height: 1.6; }
.avatar-section { display: flex; flex-direction: column; align-items: center; margin-bottom: 48rpx; }
.avatar-wrap {
position: relative; width: 192rpx; height: 192rpx; border-radius: 50%; overflow: hidden;
border: 4rpx solid #5EEAD4; box-shadow: 0 0 30rpx rgba(94,234,212,0.3);
}
.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: 0; right: 0;
width: 56rpx; height: 56rpx; background: #5EEAD4; color: #000;
border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 28rpx;
}
.avatar-change { font-size: 28rpx; color: #5EEAD4; font-weight: 500; margin-top: 16rpx; }
.section {
margin-bottom: 48rpx; padding-top: 32rpx;
border-top: 1rpx solid rgba(255,255,255,0.08);
}
.section-title { display: flex; align-items: center; gap: 16rpx; font-size: 32rpx; font-weight: 600; margin-bottom: 32rpx; }
.section-icon { font-size: 40rpx; }
.form-row { margin-bottom: 32rpx; }
.form-row:last-child { margin-bottom: 0; }
.form-row-2 { display: flex; gap: 24rpx; }
.form-row-2 .form-item { flex: 1; min-width: 0; }
.form-label { display: block; font-size: 24rpx; color: #94A3B8; margin-bottom: 12rpx; margin-left: 8rpx; }
/* input/textarea 用 view 包裹padding 写在 view 上 */
.form-input-wrap {
padding: 24rpx 32rpx;
background: #17212F; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 24rpx;
box-sizing: border-box; min-width: 0; width: 100%;
}
.form-input-suffix { position: relative; padding-right: 64rpx; }
.form-input-suffix .form-suffix {
position: absolute; right: 24rpx; top: 50%; transform: translateY(-50%);
font-size: 32rpx; color: #94A3B8;
}
.form-input-inner {
width: 100%; max-width: 100%; font-size: 28rpx; color: #fff; background: transparent;
box-sizing: border-box; display: block;
}
.form-picker { color: #fff; }
.form-textarea-wrap {
padding: 24rpx 32rpx;
background: #17212F; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 24rpx; min-height: 160rpx;
box-sizing: border-box; min-width: 0; width: 100%;
}
.form-textarea-wrap.form-textarea-lg { min-height: 240rpx; }
.form-textarea-inner {
width: 100%; max-width: 100%; min-height: 112rpx; font-size: 28rpx; color: #fff;
background: transparent; line-height: 1.5; box-sizing: border-box; display: block;
}
.form-textarea-lg .form-textarea-inner { min-height: 192rpx; }
.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: 48rpx; margin-bottom: 48rpx;
}
.save-btn[disabled] { opacity: 0.6; }
.bottom-space { height: 120rpx; }

View File

@@ -0,0 +1,79 @@
/**
* Soul创业派对 - 个人资料展示页stitch_soul enhanced_professional_profile
* 从「我的」页编辑图标进入;展示基本信息、个人故事、互助需求、项目介绍
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
profile: null,
loading: true,
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.loadProfile()
},
onShow() {
if (this.data.profile) this.loadProfile()
},
async loadProfile() {
const userInfo = app.globalData.userInfo
if (!app.globalData.isLoggedIn || !userInfo?.id) {
this.setData({ loading: false })
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => wx.navigateBack(), 1500)
return
}
this.setData({ loading: true })
try {
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userInfo.id}`, silent: true })
if (res?.success && res.data) {
const d = res.data
const phone = d.phone || ''
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
this.setData({
profile: {
...d,
phoneMask: phone ? phone.slice(0, 3) + '****' + phone.slice(-2) : '',
wechatMask: wechat ? (wechat.length > 8 ? wechat.slice(0, 4) + '****' + wechat.slice(-3) : wechat) : '',
phone,
wechat,
},
loading: false,
})
} else {
this.setData({ profile: null, loading: false })
}
} catch (e) {
this.setData({ profile: null, loading: false })
}
},
goBack() {
wx.navigateBack()
},
goToEdit() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
copyPhone() {
const p = this.data.profile?.phone
if (!p) return
wx.setClipboardData({ data: p, success: () => wx.showToast({ title: '已复制', icon: 'success' }) })
},
copyWechat() {
const w = this.data.profile?.wechat
if (!w) return
wx.setClipboardData({ data: w, success: () => wx.showToast({ title: '已复制', icon: 'success' }) })
},
goToVip() {
wx.navigateTo({ url: '/pages/vip/vip' })
},
})

View File

@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "个人资料",
"usingComponents": {}
}

View File

@@ -0,0 +1,135 @@
<!-- 个人资料展示页 - enhanced_professional_profile 1:1 重构 -->
<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-right" bindtap="goToEdit"><text class="nav-more">⋯</text></view>
</view>
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 头像区卡片 -->
<view class="hero-card" wx:if="{{profile}}">
<view class="hero-gradient"></view>
<view class="hero-content">
<view class="hero-avatar">
<image wx:if="{{profile.avatar}}" class="avatar-img" src="{{profile.avatar}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{profile.nickname ? profile.nickname[0] : '?'}}</view>
</view>
<text class="hero-name">{{profile.nickname || '未设置昵称'}}</text>
<view class="hero-tags">
<text class="tag tag-mbti" wx:if="{{profile.mbti}}">{{profile.mbti}}</text>
<text class="tag tag-region" wx:if="{{profile.region}}">📍 {{profile.region}}</text>
</view>
</view>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="section-head">
<text class="section-icon">👤</text>
<text class="section-title">基本信息</text>
</view>
<view class="section-body">
<view class="field" wx:if="{{profile.industry}}">
<text class="field-label">行业</text>
<text class="field-value">{{profile.industry}}</text>
</view>
<view class="field" wx:if="{{profile.position}}">
<text class="field-label">职位</text>
<text class="field-value">{{profile.position}}</text>
</view>
<view class="field" wx:if="{{profile.businessScale}}">
<text class="field-label">业务体量</text>
<text class="field-value">{{profile.businessScale}}</text>
</view>
<view class="field-divider" wx:if="{{profile.industry || profile.position || profile.businessScale}}"></view>
<view class="field" wx:if="{{profile.skills}}">
<text class="field-label">我擅长</text>
<text class="field-value">{{profile.skills}}</text>
</view>
<view class="field" wx:if="{{profile.phoneMask || profile.phone}}">
<text class="field-label">联系方式</text>
<view class="field-value-row" bindtap="copyPhone">
<text class="field-value mono">{{profile.phoneMask || profile.phone || '未填写'}}</text>
<text class="field-hint" wx:if="{{profile.phone}}">复制</text>
</view>
</view>
<view class="field" wx:if="{{profile.wechatMask || profile.wechat}}">
<text class="field-label">微信号</text>
<view class="field-value-row" bindtap="copyWechat">
<text class="field-value mono">{{profile.wechatMask || profile.wechat || '未填写'}}</text>
<text class="field-hint" wx:if="{{profile.wechat}}">复制</text>
</view>
</view>
<view class="field-empty" wx:if="{{!profile.industry && !profile.position && !profile.phone && !profile.wechat && !profile.skills}}">
点击右上角 ⋯ 编辑完善资料
</view>
</view>
</view>
<!-- 个人故事 -->
<view class="section" wx:if="{{profile.storyBestMonth || profile.storyAchievement || profile.storyTurning}}">
<view class="section-head">
<text class="section-icon section-icon-yellow">💡</text>
<text class="section-title">个人故事</text>
</view>
<view class="section-body">
<view class="story-block" wx:if="{{profile.storyBestMonth}}">
<view class="story-head"><text class="story-emoji">🏆</text><text class="story-label">最赚钱的一个月做的是什么</text></view>
<text class="story-text">{{profile.storyBestMonth}}</text>
</view>
<view class="field-divider" wx:if="{{profile.storyBestMonth && (profile.storyAchievement || profile.storyTurning)}}"></view>
<view class="story-block" wx:if="{{profile.storyAchievement}}">
<view class="story-head"><text class="story-emoji">⭐</text><text class="story-label">最有成就感的一件事</text></view>
<text class="story-text">{{profile.storyAchievement}}</text>
</view>
<view class="field-divider" wx:if="{{profile.storyAchievement && profile.storyTurning}}"></view>
<view class="story-block" wx:if="{{profile.storyTurning}}">
<view class="story-head"><text class="story-emoji">🔄</text><text class="story-label">人生的转折点</text></view>
<text class="story-text">{{profile.storyTurning}}</text>
</view>
</view>
</view>
<!-- 互助需求 -->
<view class="section" wx:if="{{profile.helpOffer || profile.helpNeed}}">
<view class="section-head">
<text class="section-icon">🤝</text>
<text class="section-title">互助需求</text>
</view>
<view class="section-body">
<view class="help-block" wx:if="{{profile.helpOffer}}">
<text class="help-tag help-tag-accent">我能帮你</text>
<text class="help-text">{{profile.helpOffer}}</text>
</view>
<view class="help-block" wx:if="{{profile.helpNeed}}">
<text class="help-tag help-tag-orange">我需要帮助</text>
<text class="help-text">{{profile.helpNeed}}</text>
</view>
</view>
</view>
<!-- 项目介绍 -->
<view class="section" wx:if="{{profile.projectIntro}}">
<view class="section-head">
<text class="section-icon">🚀</text>
<text class="section-title">项目介绍</text>
</view>
<view class="section-body">
<text class="project-text">{{profile.projectIntro}}</text>
</view>
</view>
<view class="bottom-spacer"></view>
</scroll-view>
<!-- 底部按钮 - 设计稿为描边橙色 -->
<view class="bottom-bar">
<view class="vip-btn-outline" bindtap="goToVip">
<text>成为超级个体</text>
<text class="vip-btn-arrow">→</text>
</view>
</view>
</view>

View File

@@ -0,0 +1,114 @@
/* 个人资料展示页 - enhanced_professional_profile 1:1 重构 */
.page { background: #050B14; min-height: 100vh; color: #fff; }
.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 32rpx;
background: rgba(5,11,20,0.9); backdrop-filter: blur(8rpx);
border-bottom: 1rpx solid rgba(255,255,255,0.05);
}
.nav-back { padding: 16rpx; margin-left: -8rpx; }
.back-icon { font-size: 40rpx; color: #5EEAD4; }
.nav-title { font-size: 34rpx; font-weight: bold; }
.nav-right { padding: 16rpx; }
.nav-more { font-size: 48rpx; color: #fff; line-height: 1; }
.nav-placeholder { width: 100%; }
.loading { padding: 96rpx; text-align: center; color: #94A3B8; }
.scroll-main { height: calc(100vh - 120rpx); padding: 0 32rpx 32rpx; }
/* 头像区卡片 */
.hero-card {
position: relative; overflow: hidden;
background: #0F1720; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 32rpx; margin-bottom: 32rpx;
padding: 64rpx 32rpx; display: flex; flex-direction: column; align-items: center;
}
.hero-gradient {
position: absolute; top: 0; left: 0; right: 0; height: 128rpx;
background: linear-gradient(to bottom, rgba(30,58,69,0.3) 0%, transparent 100%);
pointer-events: none;
}
.hero-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
.hero-avatar {
width: 176rpx; height: 176rpx; border-radius: 50%;
overflow: hidden; border: 2rpx solid rgba(255,255,255,0.1);
margin-bottom: 32rpx;
}
.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);
}
.hero-name { font-size: 40rpx; font-weight: bold; margin-bottom: 24rpx; }
.hero-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; }
.tag { padding: 8rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 500; }
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94,234,212,0.2); }
.tag-region { background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
/* 通用区块 */
.section {
background: #0F1720; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 32rpx; margin-bottom: 32rpx;
padding: 40rpx; box-shadow: 0 16rpx 32rpx rgba(0,0,0,0.2);
}
.section-head { display: flex; align-items: center; gap: 20rpx; margin-bottom: 40rpx; }
.section-icon { font-size: 40rpx; }
.section-icon-yellow { filter: brightness(1.2); }
.section-title { font-size: 30rpx; font-weight: bold; }
.section-body { }
.field { margin-bottom: 48rpx; }
.field:last-child { margin-bottom: 0; }
.field-label { display: block; font-size: 26rpx; color: #94A3B8; margin-bottom: 16rpx; }
.field-value { font-size: 30rpx; font-weight: 500; color: #fff; line-height: 1.5; }
.field-value.mono { font-family: monospace; letter-spacing: 0.02em; }
.field-value-row { display: flex; align-items: center; gap: 16rpx; }
.field-hint { font-size: 24rpx; color: #5EEAD4; }
.field-divider { height: 1rpx; background: rgba(255,255,255,0.05); margin: 32rpx 0; }
.field-empty { font-size: 26rpx; color: #64748b; }
/* 个人故事 */
.story-block { margin-bottom: 48rpx; }
.story-block:last-child { margin-bottom: 0; }
.story-head { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.story-emoji { font-size: 32rpx; }
.story-label { font-size: 26rpx; font-weight: 500; color: #94A3B8; }
.story-text { font-size: 28rpx; color: #e5e7eb; line-height: 1.6; display: block; }
/* 互助需求 */
.help-block {
background: #17212F; border: 1rpx solid rgba(255,255,255,0.05);
border-radius: 20rpx; padding: 32rpx; margin-bottom: 24rpx;
}
.help-block:last-child { margin-bottom: 0; }
.help-tag {
display: inline-block; font-size: 22rpx; font-weight: 500;
padding: 8rpx 16rpx; border-radius: 8rpx; margin-bottom: 16rpx;
}
.help-tag-accent { background: #112D2A; color: #5EEAD4; }
.help-tag-orange { background: #2D1F0D; color: #F59E0B; }
.help-text { font-size: 26rpx; color: #fff; line-height: 1.6; display: block; }
.project-text { font-size: 28rpx; color: #e5e7eb; line-height: 1.6; }
.bottom-spacer { height: 180rpx; }
/* 底部按钮 - 设计稿:透明背景 + 橙色描边 */
.bottom-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 50;
padding: 32rpx; padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
background: rgba(5,11,20,0.95); backdrop-filter: blur(8rpx);
border-top: 1rpx solid rgba(255,255,255,0.05);
}
.vip-btn-outline {
display: flex; align-items: center; justify-content: center; gap: 16rpx;
width: 100%; height: 96rpx;
background: transparent; color: #F59E0B;
border: 2rpx solid rgba(245,158,11,0.3);
border-radius: 999rpx; font-size: 30rpx; font-weight: 500;
}
.vip-btn-arrow { font-size: 36rpx; }

View File

@@ -8,17 +8,18 @@ Page({
expireDateStr: '',
price: 1980,
originalPrice: 6980,
/* 按 premium_membership_landing_v1 设计稿 */
contentRights: [
{ title: '解锁全部章节', desc: '365天全部章节内容' },
{ title: '案例库', desc: '30-100创业项目案例' },
{ title: '智能纪要', desc: '每天推送派对精华' },
{ title: '会议纪要库', desc: '之前所有场次的会议纪要' }
{ title: '解锁全部章节', desc: '365天全案精读', icon: '📖' },
{ title: '案例库', desc: '100+创业实战案例', icon: '📚' },
{ title: '智能纪要', desc: 'AI每日精华推送', icon: '💡' },
{ title: '会议纪要库', desc: '往期完整沉淀', icon: '📁' }
],
socialRights: [
{ title: '匹配创业伙伴', desc: '匹配所有创业伙伴' },
{ title: '创业老板排行', desc: '排行榜展示您的项目' },
{ title: '链接资源', desc: '进群聊天、链接资源的权利' },
{ title: '专属VIP标识', desc: '头像金色VIP光圈' }
{ title: '匹配创业伙伴', desc: '精准人脉匹配', icon: '👥' },
{ title: '创业老板排行', desc: '项目曝光展示', icon: '📊' },
{ title: '链接资源', desc: '深度私域资源池', icon: '🔗' },
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: '✓' }
],
profile: { vipName: '', vipProject: '', vipContact: '', vipAvatar: '', vipBio: '' },
purchasing: false

View File

@@ -1,4 +1,4 @@
<!-- VIP会员页 -->
<!-- VIP会员页 - 按 premium_membership_landing_v1 设计稿 -->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
@@ -8,55 +8,53 @@
<view class="nav-placeholder-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 会员状态 -->
<!-- 会员宣传区 - 设计稿 premium 卡片 -->
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
<text class="vip-hero-tag">卡若创业派对</text>
<text class="vip-hero-tag">VIP PREMIUM</text>
<text class="vip-hero-title">
加入卡若的
<text class="gold">创业派对</text>
会员
</text>
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
<text class="vip-hero-sub" wx:else>专属会员尊享权益</text>
<text class="vip-hero-sub" wx:else>一次加入 尊享终身陪伴与成长</text>
</view>
<!-- 内容权益 -->
<view class="rights-card">
<text class="rights-section-title">内容权益</text>
<view class="rights-item" wx:for="{{contentRights}}" wx:key="title">
<view class="rights-check-wrap">
<text class="rights-check">✓</text>
<!-- 双列权益:内容权益 + 社交权益 -->
<view class="rights-grid">
<view class="rights-col">
<view class="rights-col-header">
<text class="rights-dot rights-dot-teal"></text>
<text class="rights-col-title">内容权益</text>
</view>
<view class="rights-info">
<text class="rights-title">{{item.title}}</text>
<text class="rights-desc">{{item.desc}}</text>
<view class="benefit-card" wx:for="{{contentRights}}" wx:key="title">
<text class="benefit-icon">{{item.icon || '✓'}}</text>
<view class="benefit-info">
<text class="benefit-title">{{item.title}}</text>
<text class="benefit-desc">{{item.desc}}</text>
</view>
</view>
</view>
<view class="rights-col">
<view class="rights-col-header">
<text class="rights-dot rights-dot-gold"></text>
<text class="rights-col-title rights-col-title-gold">社交权益</text>
</view>
<view class="benefit-card" wx:for="{{socialRights}}" wx:key="title">
<text class="benefit-icon benefit-icon-gold">{{item.icon || '✓'}}</text>
<view class="benefit-info">
<text class="benefit-title">{{item.title}}</text>
<text class="benefit-desc">{{item.desc}}</text>
</view>
</view>
</view>
</view>
<!-- 社交权益 -->
<view class="rights-card">
<text class="rights-section-title">社交权益</text>
<view class="rights-item" wx:for="{{socialRights}}" wx:key="title">
<view class="rights-check-wrap">
<text class="rights-check">✓</text>
</view>
<view class="rights-info">
<text class="rights-title">{{item.title}}</text>
<text class="rights-desc">{{item.desc}}</text>
</view>
</view>
</view>
<!-- 价格区 + 购买按钮 -->
<view class="buy-area" wx:if="{{!isVip}}">
<view class="price-row">
<text class="price-original">¥{{originalPrice}}</text>
<text class="price-current">¥{{price}}</text>
<text class="price-unit">/年</text>
</view>
<button class="buy-btn" bindtap="handlePurchase" disabled="{{purchasing}}">
{{purchasing ? '处理中...' : '¥' + price + ' 加入创业派对'}}
<!-- 底部固定购买按钮(非 VIP 时显示) -->
<view class="buy-footer" wx:if="{{!isVip}}">
<button class="buy-btn-fixed" bindtap="handlePurchase" disabled="{{purchasing}}">
{{purchasing ? "处理中..." : "¥" + price + "/年 加入创业派对"}}
</button>
<text class="buy-sub">加入卡若创业派对,获取创业资讯与优质人脉资源</text>
</view>
<view class="bottom-spacer" wx:if="{{!isVip}}"></view>
<!-- VIP资料填写仅VIP可见 -->
<view class="profile-card" wx:if="{{isVip}}">
<text class="profile-title">会员资料(展示在创业老板排行)</text>

View File

@@ -12,26 +12,28 @@
.gold { color: #FFD700; }
.vip-hero-sub { display: block; font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 12rpx; }
.rights-card { margin: 24rpx; padding: 0 8rpx; }
.rights-item { display: flex; align-items: flex-start; padding: 24rpx; margin-bottom: 16rpx; background: rgba(255,255,255,0.04); border: 1rpx solid rgba(255,255,255,0.06); border-radius: 16rpx; }
.rights-item .rights-check-wrap { margin-right: 20rpx; }
.rights-check-wrap { width: 44rpx; height: 44rpx; border-radius: 50%; background: rgba(0,206,209,0.15); display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 4rpx; }
.rights-check { color: #00CED1; font-size: 24rpx; font-weight: bold; }
.rights-info { display: flex; flex-direction: column; }
.rights-title { font-size: 30rpx; font-weight: 600; color: rgba(255,255,255,0.95); }
.rights-desc { font-size: 24rpx; color: rgba(255,255,255,0.45); margin-top: 6rpx; }
/* 双列权益 - 按 premium_membership_landing_v1 */
.rights-grid { display: flex; gap: 24rpx; margin: 24rpx; }
.rights-col { flex: 1; min-width: 0; }
.rights-col-header { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; padding-left: 8rpx; }
.rights-dot { width: 8rpx; height: 24rpx; border-radius: 4rpx; }
.rights-dot-teal { background: #4FD1C5; }
.rights-dot-gold { background: #FFBD2E; }
.rights-col-title { font-size: 24rpx; font-weight: bold; color: #4FD1C5; letter-spacing: 2rpx; }
.rights-col-title-gold { color: #FFBD2E; }
.benefit-card { display: flex; flex-direction: column; gap: 16rpx; padding: 24rpx; margin-bottom: 16rpx; background: #141414; border: 1rpx solid rgba(255,255,255,0.05); border-radius: 24rpx; }
.benefit-icon { font-size: 36rpx; color: #4FD1C5; }
.benefit-icon-gold { color: #FFBD2E; }
.benefit-info { display: flex; flex-direction: column; }
.benefit-title { font-size: 26rpx; font-weight: bold; color: #fff; }
.benefit-desc { font-size: 20rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; line-height: 1.4; }
.rights-section-title { display: block; font-size: 26rpx; color: #00CED1; font-weight: 600; margin-bottom: 16rpx; margin-left: 16rpx; padding-bottom: 12rpx; border-bottom: 1rpx solid rgba(0,206,209,0.15); }
.buy-area { margin: 24rpx; padding: 32rpx; text-align: center; background: rgba(255,255,255,0.03); border-radius: 20rpx; }
.price-row { display: flex; align-items: baseline; justify-content: center; gap: 12rpx; margin-bottom: 24rpx; }
.price-original { font-size: 28rpx; color: rgba(255,255,255,0.35); text-decoration: line-through; }
.price-current { font-size: 64rpx; font-weight: bold; color: #FF4444; }
.price-unit { font-size: 26rpx; color: rgba(255,255,255,0.5); }
.buy-btn { width: 90%; height: 88rpx; padding: 0; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #FFD700, #FFA500); color: #000; font-size: 32rpx; font-weight: bold; border-radius: 44rpx; border: none; margin: 0 auto; }
.buy-btn::after { border: none; }
.buy-btn[disabled] { opacity: 0.5; }
.buy-sub { display: block; font-size: 22rpx; color: rgba(255,255,255,0.4); margin-top: 16rpx; }
/* 底部固定购买按钮 - 设计稿 */
.buy-footer { position: fixed; bottom: 0; left: 0; right: 0; padding: 24rpx 32rpx; padding-bottom: calc(24rpx + env(safe-area-inset-bottom)); background: rgba(0,0,0,0.95); border-top: 1rpx solid rgba(255,255,255,0.05); z-index: 50; }
.buy-btn-fixed { width: 100%; height: 96rpx; padding: 0; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #FFD700, #FFB000); color: #000; font-size: 32rpx; font-weight: bold; border-radius: 48rpx; border: none; box-shadow: 0 8rpx 32rpx rgba(255,188,46,0.2); }
.buy-btn-fixed::after { border: none; }
.buy-btn-fixed[disabled] { opacity: 0.6; }
.bottom-spacer { height: 180rpx; }
.profile-card { margin: 24rpx; padding: 32rpx; background: rgba(255,255,255,0.04); border: 1rpx solid rgba(255,255,255,0.08); border-radius: 20rpx; }
.profile-title { font-size: 30rpx; font-weight: 600; color: rgba(255,255,255,0.9); display: block; margin-bottom: 24rpx; }

View File

@@ -24,50 +24,15 @@
"miniprogram": {
"list": [
{
"name": "vip资料填写",
"pathName": "pages/vip/vip",
"name": "个人资料",
"pathName": "pages/profile-show/profile-show",
"query": "",
"scene": null,
"launchMode": "default"
},
{
"name": "pages/read/read",
"pathName": "pages/read/read",
"query": "id=1.1",
"launchMode": "default",
"scene": null
},
{
"name": "pages/match/match",
"pathName": "pages/match/match",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "看书",
"pathName": "pages/read/read",
"query": "id=1.4",
"launchMode": "default",
"scene": null
},
{
"name": "分销中心",
"pathName": "pages/referral/referral",
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "阅读",
"pathName": "pages/read/read",
"query": "id=1.1",
"launchMode": "default",
"scene": null
},
{
"name": "分销中心",
"pathName": "pages/referral/referral",
"name": "pages/mentors/mentors",
"pathName": "pages/mentors/mentors",
"query": "",
"launchMode": "default",
"scene": null
@@ -78,13 +43,6 @@
"query": "",
"launchMode": "default",
"scene": null
},
{
"name": "新增地址",
"pathName": "pages/addresses/edit",
"query": "",
"launchMode": "default",
"scene": null
}
]
}

View File

@@ -16,6 +16,8 @@ import { QRCodesPage } from './pages/qrcodes/QRCodesPage'
import { MatchPage } from './pages/match/MatchPage'
import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
@@ -34,6 +36,8 @@ function App() {
<Route path="chapters" element={<ChaptersPage />} />
<Route path="referral-settings" element={<ReferralSettingsPage />} />
<Route path="vip-roles" element={<VipRolesPage />} />
<Route path="mentors" element={<MentorsPage />} />
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} />

View File

@@ -10,6 +10,8 @@ import {
BookOpen,
GitMerge,
Crown,
GraduationCap,
Calendar,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
@@ -19,6 +21,8 @@ const menuItems = [
{ icon: BookOpen, label: '内容管理', href: '/content' },
{ icon: Users, label: '用户管理', href: '/users' },
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
{ icon: Wallet, label: '交易中心', href: '/distribution' },
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },

View File

@@ -49,6 +49,7 @@ interface SectionListItem {
title: string
price: number
isFree?: boolean
isNew?: boolean
partId?: string
partTitle?: string
chapterId?: string
@@ -62,6 +63,7 @@ interface Section {
price: number
filePath?: string
isFree?: boolean
isNew?: boolean
}
interface Chapter {
@@ -83,6 +85,7 @@ interface EditingSection {
content?: string
filePath?: string
isFree?: boolean
isNew?: boolean
}
function buildTree(sections: SectionListItem[]): Part[] {
@@ -108,6 +111,7 @@ function buildTree(sections: SectionListItem[]): Part[] {
price: s.price ?? 1,
filePath: s.filePath,
isFree: s.isFree,
isNew: s.isNew,
})
}
return Array.from(partMap.values()).map((p) => ({
@@ -201,6 +205,7 @@ export function ContentPage() {
`/api/db/book?action=read&id=${encodeURIComponent(section.id)}`,
)
if (data?.success && data.section) {
const sec = data.section as { isNew?: boolean }
setEditingSection({
id: section.id,
title: data.section.title ?? section.title,
@@ -208,6 +213,7 @@ export function ContentPage() {
content: data.section.content ?? '',
filePath: section.filePath,
isFree: section.isFree || section.price === 0,
isNew: sec.isNew ?? section.isNew,
})
} else {
setEditingSection({
@@ -217,6 +223,7 @@ export function ContentPage() {
content: '',
filePath: section.filePath,
isFree: section.isFree,
isNew: section.isNew,
})
if (data && !(data as { success?: boolean }).success) {
alert('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
@@ -256,6 +263,7 @@ export function ContentPage() {
price: editingSection.isFree ? 0 : editingSection.price,
content,
isFree: editingSection.isFree || editingSection.price === 0,
isNew: editingSection.isNew,
saveToFile: true,
})
if (res && (res as { success?: boolean }).success !== false) {
@@ -557,6 +565,25 @@ export function ContentPage() {
</label>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex items-center h-10">
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={editingSection.isNew ?? false}
onChange={(e) =>
setEditingSection({
...editingSection,
isNew: e.target.checked,
})
}
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
<span className="ml-2 text-gray-400 text-sm"> NEW</span>
</label>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>

View File

@@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Calendar, RefreshCw } from 'lucide-react'
import { get } from '@/api/client'
import { Button } from '@/components/ui/button'
interface Consultation {
id: number
userId: number
mentorId: number
consultationType: string
amount: number
status: string
createdAt: string
}
export function MentorConsultationsPage() {
const [list, setList] = useState<Consultation[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
async function load() {
setLoading(true)
try {
const url = statusFilter ? `/api/db/mentor-consultations?status=${statusFilter}` : '/api/db/mentor-consultations'
const data = await get<{ success?: boolean; data?: Consultation[] }>(url)
if (data?.success && data.data) setList(data.data)
} catch (e) {
console.error('Load consultations error:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [statusFilter])
const statusMap: Record<string, string> = {
created: '已创建',
pending_pay: '待支付',
paid: '已支付',
completed: '已完成',
cancelled: '已取消',
}
const typeMap: Record<string, string> = {
single: '单次',
half_year: '半年',
year: '年度',
}
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
stitch_soul
</p>
</div>
<div className="flex items-center gap-2">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-[#0f2137] border border-gray-700 rounded-lg px-3 py-2 text-gray-300 text-sm"
>
<option value=""></option>
{Object.entries(statusMap).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<Button onClick={load} disabled={loading} variant="outline" className="border-gray-600 text-gray-300">
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<TableRow key={r.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{r.id}</TableCell>
<TableCell className="text-gray-400">{r.userId}</TableCell>
<TableCell className="text-gray-400">{r.mentorId}</TableCell>
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
<TableCell className="text-white">¥{r.amount}</TableCell>
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
<TableCell className="text-gray-500 text-sm">{r.createdAt}</TableCell>
</TableRow>
))}
{list.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,429 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Users, Plus, Edit3, Trash2, X, Save } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
interface Mentor {
id: number
name: string
avatar?: string
intro?: string
tags?: string
priceSingle?: number
priceHalfYear?: number
priceYear?: number
quote?: string
whyFind?: string
offering?: string
judgmentStyle?: string
sort: number
enabled?: boolean
}
export function MentorsPage() {
const [mentors, setMentors] = useState<Mentor[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<Mentor | null>(null)
const [form, setForm] = useState({
name: '',
avatar: '',
intro: '',
tags: '',
priceSingle: '',
priceHalfYear: '',
priceYear: '',
quote: '',
whyFind: '',
offering: '',
judgmentStyle: '',
sort: 0,
enabled: true,
})
const [saving, setSaving] = useState(false)
async function load() {
setLoading(true)
try {
const data = await get<{ success?: boolean; data?: Mentor[] }>('/api/db/mentors')
if (data?.success && data.data) setMentors(data.data)
} catch (e) {
console.error('Load mentors error:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const resetForm = () => {
setForm({
name: '',
avatar: '',
intro: '',
tags: '',
priceSingle: '',
priceHalfYear: '',
priceYear: '',
quote: '',
whyFind: '',
offering: '',
judgmentStyle: '',
sort: mentors.length > 0 ? Math.max(...mentors.map((m) => m.sort)) + 1 : 0,
enabled: true,
})
}
const handleAdd = () => {
setEditing(null)
resetForm()
setShowModal(true)
}
const handleEdit = (m: Mentor) => {
setEditing(m)
setForm({
name: m.name,
avatar: m.avatar || '',
intro: m.intro || '',
tags: m.tags || '',
priceSingle: m.priceSingle != null ? String(m.priceSingle) : '',
priceHalfYear: m.priceHalfYear != null ? String(m.priceHalfYear) : '',
priceYear: m.priceYear != null ? String(m.priceYear) : '',
quote: m.quote || '',
whyFind: m.whyFind || '',
offering: m.offering || '',
judgmentStyle: m.judgmentStyle || '',
sort: m.sort,
enabled: m.enabled ?? true,
})
setShowModal(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
alert('导师姓名不能为空')
return
}
setSaving(true)
try {
const num = (s: string) => (s === '' ? undefined : parseFloat(s))
const payload = {
name: form.name.trim(),
avatar: form.avatar.trim() || undefined,
intro: form.intro.trim() || undefined,
tags: form.tags.trim() || undefined,
priceSingle: num(form.priceSingle),
priceHalfYear: num(form.priceHalfYear),
priceYear: num(form.priceYear),
quote: form.quote.trim() || undefined,
whyFind: form.whyFind.trim() || undefined,
offering: form.offering.trim() || undefined,
judgmentStyle: form.judgmentStyle.trim() || undefined,
sort: form.sort,
enabled: form.enabled,
}
if (editing) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/mentors', {
id: editing.id,
...payload,
})
if (data?.success) {
setShowModal(false)
load()
} else {
alert('更新失败: ' + (data as { error?: string })?.error)
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/mentors', payload)
if (data?.success) {
setShowModal(false)
load()
} else {
alert('新增失败: ' + (data as { error?: string })?.error)
}
}
} catch (e) {
console.error('Save error:', e)
alert('保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该导师?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/mentors?id=${id}`)
if (data?.success) load()
else alert('删除失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Delete error:', e)
alert('删除失败')
}
}
const fmt = (v?: number) => (v != null ? `¥${v}` : '-')
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
stitch_soul //
</p>
</div>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mentors.map((m) => (
<TableRow key={m.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{m.id}</TableCell>
<TableCell className="text-white">{m.name}</TableCell>
<TableCell className="text-gray-400 max-w-[200px] truncate">{m.intro || '-'}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceSingle)}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceHalfYear)}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceYear)}</TableCell>
<TableCell className="text-gray-400">{m.sort}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(m)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(m.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{mentors.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
{editing ? '编辑导师' : '新增导师'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"> *</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:卡若"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={form.sort}
onChange={(e) => setForm((f) => ({ ...f, sort: parseInt(e.target.value, 10) || 0 }))}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> URL</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="https://..."
value={form.avatar}
onChange={(e) => setForm((f) => ({ ...f, avatar: 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="如:结构判断型咨询 · Decision > Execution"
value={form.intro}
onChange={(e) => setForm((f) => ({ ...f, intro: 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={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
/>
</div>
<div className="border-t border-gray-700 pt-4">
<Label className="text-gray-300 block mb-2"></Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="980"
value={form.priceSingle}
onChange={(e) => setForm((f) => ({ ...f, priceSingle: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="19800"
value={form.priceHalfYear}
onChange={(e) => setForm((f) => ({ ...f, priceHalfYear: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="29800"
value={form.priceYear}
onChange={(e) => setForm((f) => ({ ...f, priceYear: e.target.value }))}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:大多数人失败,不是因为不努力..."
value={form.quote}
onChange={(e) => setForm((f) => ({ ...f, quote: 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={form.whyFind}
onChange={(e) => setForm((f) => ({ ...f, whyFind: 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={form.offering}
onChange={(e) => setForm((f) => ({ ...f, offering: 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={form.judgmentStyle}
onChange={(e) => setForm((f) => ({ ...f, judgmentStyle: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
className="rounded border-gray-600 bg-[#0a1628]"
/>
<Label htmlFor="enabled" className="text-gray-300 cursor-pointer"></Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -36,6 +36,12 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.Order{}); err != nil {
log.Printf("database: orders migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.Mentor{}); err != nil {
log.Printf("database: mentors migrate warning: %v", err)
}
if err := db.AutoMigrate(&model.MentorConsultation{}); err != nil {
log.Printf("database: mentor_consultations migrate warning: %v", err)
}
log.Println("database: connected")
return nil
}

View File

@@ -12,6 +12,9 @@ import (
"gorm.io/gorm"
)
// excludeParts 排除序言、尾声、附录(不参与精选推荐/热门排序)
var excludeParts = []string{"序言", "尾声", "附录"}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
func BookAllChapters(c *gin.Context) {
var list []model.Chapter
@@ -174,13 +177,96 @@ func BookChapters(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "不支持的请求方法"})
}
// BookHot GET /api/book/hot
// bookHotChaptersSorted 按精选推荐算法排序:阅读量优先,同量按更新时间;排除序言/尾声/附录
func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
q := db.Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
var all []model.Chapter
if err := q.Order("sort_order ASC, id ASC").Find(&all).Error; err != nil || len(all) == 0 {
return nil
}
// 从 reading_progress 统计阅读量
ids := make([]string, 0, len(all))
for _, c := range all {
ids = append(ids, c.ID)
}
var counts []struct {
SectionID string `gorm:"column:section_id"`
Cnt int64 `gorm:"column:cnt"`
}
db.Table("reading_progress").Select("section_id, COUNT(*) as cnt").
Where("section_id IN ?", ids).Group("section_id").Scan(&counts)
countMap := make(map[string]int64)
for _, r := range counts {
countMap[r.SectionID] = r.Cnt
}
// 按阅读量降序、同量按 updated_at 降序
type withSort struct {
ch model.Chapter
cnt int64
}
withCnt := make([]withSort, 0, len(all))
for _, c := range all {
withCnt = append(withCnt, withSort{ch: c, cnt: countMap[c.ID]})
}
for i := 0; i < len(withCnt)-1; i++ {
for j := i + 1; j < len(withCnt); j++ {
if withCnt[j].cnt > withCnt[i].cnt ||
(withCnt[j].cnt == withCnt[i].cnt && withCnt[j].ch.UpdatedAt.After(withCnt[i].ch.UpdatedAt)) {
withCnt[i], withCnt[j] = withCnt[j], withCnt[i]
}
}
}
out := make([]model.Chapter, 0, limit)
for i := 0; i < limit && i < len(withCnt); i++ {
out = append(out, withCnt[i].ch)
}
return out
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录)
func BookHot(c *gin.Context) {
var list []model.Chapter
database.DB().Order("sort_order ASC, id ASC").Limit(10).Find(&list)
list := bookHotChaptersSorted(database.DB(), 10)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章,带 热门/推荐/精选 标签)
func BookRecommended(c *gin.Context) {
list := bookHotChaptersSorted(database.DB(), 3)
if len(list) == 0 {
// 兜底:按 updated_at 取前 3同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
}
q.Order("updated_at DESC, id ASC").Limit(3).Find(&list)
}
tags := []string{"热门", "推荐", "精选"}
out := make([]gin.H, 0, len(list))
for i, ch := range list {
tag := "精选"
if i < len(tags) {
tag = tags[i]
}
out = append(out, gin.H{
"id": ch.ID, "mid": ch.MID, "sectionTitle": ch.SectionTitle, "partTitle": ch.PartTitle,
"chapterTitle": ch.ChapterTitle, "tag": tag,
"isFree": ch.IsFree, "price": ch.Price, "isNew": ch.IsNew,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
// BookLatestChapters GET /api/book/latest-chapters
func BookLatestChapters(c *gin.Context) {
var list []model.Chapter

View File

@@ -16,6 +16,7 @@ type sectionListItem struct {
Title string `json:"title"`
Price float64 `json:"price"`
IsFree *bool `json:"isFree,omitempty"`
IsNew *bool `json:"isNew,omitempty"` // stitch_soul标记最新新增
PartID string `json:"partId"`
PartTitle string `json:"partTitle"`
ChapterID string `json:"chapterId"`
@@ -48,6 +49,7 @@ func DBBookAction(c *gin.Context) {
Title: r.SectionTitle,
Price: price,
IsFree: r.IsFree,
IsNew: r.IsNew,
PartID: r.PartID,
PartTitle: r.PartTitle,
ChapterID: r.ChapterID,
@@ -81,6 +83,7 @@ func DBBookAction(c *gin.Context) {
"title": ch.SectionTitle,
"price": price,
"content": ch.Content,
"isNew": ch.IsNew,
"partId": ch.PartID,
"partTitle": ch.PartTitle,
"chapterId": ch.ChapterID,
@@ -101,7 +104,7 @@ func DBBookAction(c *gin.Context) {
price = *r.Price
}
sections = append(sections, sectionListItem{
ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree,
ID: r.ID, Title: r.SectionTitle, Price: price, IsFree: r.IsFree, IsNew: r.IsNew,
PartID: r.PartID, PartTitle: r.PartTitle, ChapterID: r.ChapterID, ChapterTitle: r.ChapterTitle,
})
}
@@ -181,6 +184,7 @@ func DBBookAction(c *gin.Context) {
Content string `json:"content"`
Price *float64 `json:"price"`
IsFree *bool `json:"isFree"`
IsNew *bool `json:"isNew"` // stitch_soul标记最新新增
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id 或请求体无效"})
@@ -202,6 +206,9 @@ func DBBookAction(c *gin.Context) {
"price": price,
"is_free": isFree,
}
if body.IsNew != nil {
updates["is_new"] = *body.IsNew
}
err := db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})

View File

@@ -207,24 +207,50 @@ func UserProfileGet(c *gin.Context) {
}
profileComplete := (user.Phone != nil && *user.Phone != "") || (user.WechatID != nil && *user.WechatID != "")
hasAvatar := user.Avatar != nil && *user.Avatar != "" && len(*user.Avatar) > 0
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
resp := gin.H{
"id": user.ID, "openId": user.OpenID, "nickname": user.Nickname, "avatar": user.Avatar,
"phone": user.Phone, "wechatId": user.WechatID, "referralCode": user.ReferralCode,
"hasFullBook": user.HasFullBook, "earnings": user.Earnings, "pendingEarnings": user.PendingEarnings,
"referralCount": user.ReferralCount, "profileComplete": profileComplete, "hasAvatar": hasAvatar,
"createdAt": user.CreatedAt,
}})
}
// P3 资料扩展
if user.Mbti != nil { resp["mbti"] = user.Mbti }
if user.Region != nil { resp["region"] = user.Region }
if user.Industry != nil { resp["industry"] = user.Industry }
if user.Position != nil { resp["position"] = user.Position }
if user.BusinessScale != nil { resp["businessScale"] = user.BusinessScale }
if user.Skills != nil { resp["skills"] = user.Skills }
if user.StoryBestMonth != nil { resp["storyBestMonth"] = user.StoryBestMonth }
if user.StoryAchievement != nil { resp["storyAchievement"] = user.StoryAchievement }
if user.StoryTurning != nil { resp["storyTurning"] = user.StoryTurning }
if user.HelpOffer != nil { resp["helpOffer"] = user.HelpOffer }
if user.HelpNeed != nil { resp["helpNeed"] = user.HelpNeed }
if user.ProjectIntro != nil { resp["projectIntro"] = user.ProjectIntro }
c.JSON(http.StatusOK, gin.H{"success": true, "data": resp})
}
// UserProfilePost POST /api/user/profile 更新用户资料
func UserProfilePost(c *gin.Context) {
var body struct {
UserID string `json:"userId"`
OpenID string `json:"openId"`
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
UserID string `json:"userId"`
OpenID string `json:"openId"`
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
Phone *string `json:"phone"`
WechatID *string `json:"wechatId"`
Mbti *string `json:"mbti"`
Region *string `json:"region"`
Industry *string `json:"industry"`
Position *string `json:"position"`
BusinessScale *string `json:"businessScale"`
Skills *string `json:"skills"`
StoryBestMonth *string `json:"storyBestMonth"`
StoryAchievement *string `json:"storyAchievement"`
StoryTurning *string `json:"storyTurning"`
HelpOffer *string `json:"helpOffer"`
HelpNeed *string `json:"helpNeed"`
ProjectIntro *string `json:"projectIntro"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请提供userId或openId"})
@@ -264,6 +290,18 @@ func UserProfilePost(c *gin.Context) {
if body.WechatID != nil {
updates["wechat_id"] = *body.WechatID
}
if body.Mbti != nil { updates["mbti"] = *body.Mbti }
if body.Region != nil { updates["region"] = *body.Region }
if body.Industry != nil { updates["industry"] = *body.Industry }
if body.Position != nil { updates["position"] = *body.Position }
if body.BusinessScale != nil { updates["business_scale"] = *body.BusinessScale }
if body.Skills != nil { updates["skills"] = *body.Skills }
if body.StoryBestMonth != nil { updates["story_best_month"] = *body.StoryBestMonth }
if body.StoryAchievement != nil { updates["story_achievement"] = *body.StoryAchievement }
if body.StoryTurning != nil { updates["story_turning"] = *body.StoryTurning }
if body.HelpOffer != nil { updates["help_offer"] = *body.HelpOffer }
if body.HelpNeed != nil { updates["help_need"] = *body.HelpNeed }
if body.ProjectIntro != nil { updates["project_intro"] = *body.ProjectIntro }
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "没有需要更新的字段"})
return

View File

@@ -17,6 +17,7 @@ type Chapter struct {
Price *float64 `gorm:"column:price;type:decimal(10,2)" json:"price,omitempty"`
SortOrder *int `gorm:"column:sort_order" json:"sortOrder,omitempty"`
Status *string `gorm:"column:status;size:20" json:"status,omitempty"`
IsNew *bool `gorm:"column:is_new" json:"isNew,omitempty"` // stitch_soul目录/首页「最新新增」标记
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

View File

@@ -11,6 +11,19 @@ type User struct {
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
// P3 资料扩展stitch_soul
Mbti *string `gorm:"column:mbti;size:16" json:"mbti,omitempty"`
Region *string `gorm:"column:region;size:100" json:"region,omitempty"`
Industry *string `gorm:"column:industry;size:100" json:"industry,omitempty"`
Position *string `gorm:"column:position;size:100" json:"position,omitempty"`
BusinessScale *string `gorm:"column:business_scale;size:100" json:"businessScale,omitempty"`
Skills *string `gorm:"column:skills;size:500" json:"skills,omitempty"`
StoryBestMonth *string `gorm:"column:story_best_month;type:text" json:"storyBestMonth,omitempty"`
StoryAchievement *string `gorm:"column:story_achievement;type:text" json:"storyAchievement,omitempty"`
StoryTurning *string `gorm:"column:story_turning;type:text" json:"storyTurning,omitempty"`
HelpOffer *string `gorm:"column:help_offer;size:500" json:"helpOffer,omitempty"`
HelpNeed *string `gorm:"column:help_need;size:500" json:"helpNeed,omitempty"`
ProjectIntro *string `gorm:"column:project_intro;type:text" json:"projectIntro,omitempty"`
ReferralCode *string `gorm:"column:referral_code;size:20" json:"referralCode,omitempty"`
HasFullBook *bool `gorm:"column:has_full_book" json:"hasFullBook,omitempty"`
PurchasedSections *string `gorm:"column:purchased_sections;type:json" json:"-"` // 内部字段,实际数据从 orders 表查

View File

@@ -82,6 +82,7 @@ func Setup(cfg *config.Config) *gin.Engine {
api.PUT("/book/chapters", handler.BookChapters)
api.DELETE("/book/chapters", handler.BookChapters)
api.GET("/book/hot", handler.BookHot)
api.GET("/book/recommended", handler.BookRecommended)
api.GET("/book/latest-chapters", handler.BookLatestChapters)
api.GET("/book/search", handler.BookSearch)
api.GET("/book/stats", handler.BookStats)
@@ -136,6 +137,11 @@ func Setup(cfg *config.Config) *gin.Engine {
db.PUT("/vip-roles", handler.DBVipRolesAction)
db.DELETE("/vip-roles", handler.DBVipRolesAction)
db.GET("/match-records", handler.DBMatchRecordsList)
db.GET("/mentors", handler.DBMentorsList)
db.POST("/mentors", handler.DBMentorsAction)
db.PUT("/mentors", handler.DBMentorsAction)
db.DELETE("/mentors", handler.DBMentorsAction)
db.GET("/mentor-consultations", handler.DBMentorConsultationsList)
}
// ----- 分销 -----
@@ -227,6 +233,8 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/book/chapter/:id", handler.BookChapterByID)
miniprogram.GET("/book/chapter/by-mid/:mid", handler.BookChapterByMID)
miniprogram.GET("/book/hot", handler.BookHot)
miniprogram.GET("/book/recommended", handler.BookRecommended)
miniprogram.GET("/book/latest-chapters", handler.BookLatestChapters)
miniprogram.GET("/book/search", handler.BookSearch)
miniprogram.GET("/book/stats", handler.BookStats)
miniprogram.POST("/referral/visit", handler.ReferralVisit)
@@ -264,6 +272,10 @@ func Setup(cfg *config.Config) *gin.Engine {
// 用户列表/单个(首页超级个体、会员详情回退)
miniprogram.GET("/users", handler.MiniprogramUsers)
miniprogram.GET("/orders", handler.MiniprogramOrders)
// 导师stitch_soul
miniprogram.GET("/mentors", handler.MiniprogramMentorsList)
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
}
// ----- 提现 -----