Merge branch 'devlop' into yongxu-dev
# Conflicts: # miniprogram/app.js resolved by devlop version # miniprogram/pages/chapters/chapters.js resolved by devlop version # miniprogram/pages/match/match.js resolved by devlop version # miniprogram/pages/member-detail/member-detail.js resolved by devlop version # miniprogram/pages/my/my.js resolved by devlop version # miniprogram/pages/read/read.js resolved by devlop version # miniprogram/pages/referral/referral.js resolved by devlop version # soul-api/internal/model/person.go resolved by devlop version
This commit is contained in:
13
.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md
Normal file
13
.cursor/agent/开发助理/evolution/2026-03-21-MBTI头像C端全链路兜底.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 2026-03-21 MBTI 头像 C 端全链路兜底
|
||||
|
||||
## 问题
|
||||
系统设置瘦身与 MBTI 映射迁到用户管理后,需在小程序多页面与匹配接口统一「无微信头像 → MBTI 映射」行为,避免仅海报单点生效。
|
||||
|
||||
## 做法
|
||||
- 新增 `miniprogram/utils/mbtiAvatar.js`(`resolveAvatarWithMbti`);`app.resolveAvatarWithMbti` 封装全局 map。
|
||||
- 我的页 `profileAvatarDisplay`;资料编辑 `avatarPreviewUrl`;profile-show、member-detail、referral 海报复用同一逻辑。
|
||||
- 后端 `match.go`:`avatar` 为空时用 `getMbtiAvatar`;响应增加 `mbti` 字段;找伙伴卡片 wxml 增加无图占位。
|
||||
- 管理端 `MbtiAvatarsManager` 补充 downloadFile 域名说明。
|
||||
|
||||
## 可复用规则
|
||||
配置驱动展示:公开 `GET /api/miniprogram/config/mbti-avatars` + 本地短时缓存;业务侧只调 `resolveAvatarWithMbti`,避免重复拼接 baseUrl。
|
||||
@@ -4,4 +4,5 @@
|
||||
|
||||
| 日期 | 摘要 | 文件 |
|
||||
|------|------|------|
|
||||
| 2026-03-21 | MBTI 头像小程序全链路兜底 + 匹配接口回填 | 2026-03-21-MBTI头像C端全链路兜底.md |
|
||||
| 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts) | 2026-03-16-交互习惯分析.md |
|
||||
|
||||
38
.cursor/docs/feishu_开发群与项目复盘.md
Normal file
38
.cursor/docs/feishu_开发群与项目复盘.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 飞书「开发群」与 Soul 项目复盘约定
|
||||
|
||||
## 绑定关系
|
||||
|
||||
- **Soul 创业派对(永平)**、**派对 AI 相关自动化**、**卡若 AI 侧发往本项目的复盘**,默认使用**同一开发群机器人 Webhook**。
|
||||
- Webhook 与项目在配置上是**一一绑定**:换群 = 改环境变量或下方脚本中的默认 URL,并确保飞书里该群已添加对应自定义机器人。
|
||||
|
||||
## 默认 Webhook(开发群)
|
||||
|
||||
环境变量(推荐在本机 shell 或 `scripts/.env.feishu` 同目录的 `.env` 中导出):
|
||||
|
||||
| 变量名 | 用途 |
|
||||
|--------|------|
|
||||
| `FEISHU_DEV_GROUP_WEBHOOK` | **主约定**:开发群统一入口;未设置时各脚本使用内置默认值。 |
|
||||
|
||||
当前默认 URL(与飞书群内机器人一致):
|
||||
|
||||
`https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494`
|
||||
|
||||
## 已接此 Webhook 的脚本(代码内默认或可读此变量)
|
||||
|
||||
| 脚本 | 说明 |
|
||||
|------|------|
|
||||
| `scripts/send_chapter_poster_to_feishu.py` | 章节摘要 + 海报图(小程序码) |
|
||||
| 卡若AI `飞书管理/脚本/send_review_to_feishu_webhook.py` | 卡若 AI 复盘(文本/卡片) |
|
||||
| 卡若AI `飞书管理/脚本/soul_party_to_feishu_sheet.py` | 派对运营表同步后的群推送 |
|
||||
|
||||
**复盘发哪里**:与 Soul 开发相关的**日终/迭代复盘** → 发 **`FEISHU_DEV_GROUP_WEBHOOK` 对应群**。
|
||||
彩民/运营另群如需保留,可通过各脚本 `--webhook` 或单独环境变量覆盖。
|
||||
|
||||
## 界面截图发群说明
|
||||
|
||||
- 飞书自定义机器人发图需先走**应用上传**得到 `image_key`(见 `send_chapter_poster_to_feishu.py` 内逻辑)。
|
||||
- 无现成截图时:在复盘文本中附 **管理端 / 小程序 / API 文档** 等**可点击链接**,与海报一并发出。
|
||||
|
||||
## 与「SKR / 开发群」口头约定
|
||||
|
||||
- 群内链接、机器人由**项目侧**维护;**派对 AI** 与 **卡若 AI** 推送配置统一指向本开发群,避免复盘散落多个群。
|
||||
@@ -7,8 +7,8 @@ description: >
|
||||
triggers: 运营报表、视频切片、多平台分发、飞书视频下载、派对运营、卡若创业派对、派对填表、视频剪辑、一键分发、妙记下载
|
||||
owner: 水岸
|
||||
group: 运营
|
||||
version: "1.1"
|
||||
updated: "2026-03-21"
|
||||
version: "1.3"
|
||||
updated: "2026-03-24"
|
||||
---
|
||||
|
||||
# 卡若创业派对运营 Skill 包
|
||||
@@ -29,7 +29,7 @@ updated: "2026-03-21"
|
||||
|
||||
## 一、技能包组成
|
||||
|
||||
本技能包包含以下 4 个核心子技能:
|
||||
本技能包包含以下 8 个核心子技能:
|
||||
|
||||
| # | 技能名 | 文件路径 | 触发词 | 用途 |
|
||||
|:--|:---|:---|:---|:---|
|
||||
@@ -37,6 +37,10 @@ updated: "2026-03-21"
|
||||
| ② | 飞书视频文字下载 | `skills/飞书视频文字下载_SKILL.md` | 妙记下载、飞书视频、飞书妙记 | 文字+视频→本地 |
|
||||
| ③ | 视频切片 | `skills/视频切片_SKILL.md` | 视频剪辑、切片发布 | 原视频→转录→高光→成片 |
|
||||
| ④ | 多平台分发 | `skills/多平台分发_SKILL.md` | 一键分发、全平台发布 | 成片→抖音/B站/视频号/小红书/快手 |
|
||||
| ⑤ | 视频号发布 | `skills/平台_视频号_SKILL.md` | 视频号发布、视频号重传 | 账号校验→清理→定时发布 |
|
||||
| ⑥ | B站发布 | `skills/平台_B站_SKILL.md` | B站发布、B站补发 | API优先→兜底→定时 |
|
||||
| ⑦ | 小红书发布 | `skills/平台_小红书_SKILL.md` | 小红书发布、小红书补发 | UI自动化定时发布 |
|
||||
| ⑧ | 抖音发布 | `skills/平台_抖音_SKILL.md` | 抖音发布、抖音补发 | API定时发布+失败重登 |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,15 +116,32 @@ python3 "$VIDEO_SCRIPT/soul_slice_pipeline.py" --video "<原视频.mp4>" --clips
|
||||
```bash
|
||||
DIST_SCRIPT="/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本"
|
||||
|
||||
# 定时排期:第1条立即,后续 30-120min 随机间隔
|
||||
# 默认智能错峰 + 静默(不自动弹窗扫视频号)
|
||||
python3 "$DIST_SCRIPT/distribute_all.py" --video-dir "<成片目录>"
|
||||
|
||||
# 旧版随机间隔:加 --legacy-schedule;需要自动扫视频号:加 --auto-channels-login
|
||||
|
||||
# 立即全部发布
|
||||
python3 "$DIST_SCRIPT/distribute_all.py" --now
|
||||
```
|
||||
|
||||
**详细流程**:见 `skills/多平台分发_SKILL.md`
|
||||
|
||||
#### 视频号发布前置(强制)
|
||||
|
||||
在执行视频号发布前,固定做以下 3 步:
|
||||
|
||||
1. **账号信息校验**:调用 `auth_data` 校验 `nickname` 与 `headImgUrl`,不一致先改到目标值再发。
|
||||
2. **线上失败/重复清理**:先查 `post_list`,删除失败条目;同标题仅保留最新一条(去重后再补发)。
|
||||
3. **仅定时发布**:禁止立即发布;若页面定时控件失效,使用 `post_create` 注入定时参数并拦截立即发布。
|
||||
|
||||
#### 分发统一总规则(强制)
|
||||
|
||||
1. **间隔规则**:按“每一条相邻发布时间”计算,必须在 **10 分钟到 120 分钟** 之间。
|
||||
2. **账号状态规则**:发布前做账号可用性检查;若出现封禁/登录失效/鉴权失败,先执行重登,再重试失败条。
|
||||
3. **平台分治规则**:先按平台触发对应子 Skill,再执行发布命令,不混用平台规则。
|
||||
4. **收敛规则**:每轮结束输出成功/失败清单 + 重登命令;失败条必须可继续重试直到收敛。
|
||||
|
||||
---
|
||||
|
||||
## 四、完整流程(派对结束后)
|
||||
@@ -140,7 +161,7 @@ python3 "$DIST_SCRIPT/distribute_all.py" --now
|
||||
### Phase 3:视频生产
|
||||
|
||||
1. **视频切片**:转录 → 高光识别 → 批量切片 → 增强
|
||||
2. **多平台分发**:成片 → 5平台发布(定时排期)
|
||||
2. **多平台分发**:成片 → 5 平台发布(默认智能错峰定时)
|
||||
|
||||
### Phase 4:文章内容
|
||||
|
||||
@@ -167,7 +188,7 @@ python3 auto_log.py
|
||||
|
||||
各平台 Cookie 文件位于 `credentials/cookies/` 目录。更新方式:
|
||||
|
||||
1. **视频号**:浏览器登录后,使用 `cookie_manager.py` 提取
|
||||
1. **视频号**:`channels_login.py`(Cursor Simple Browser + 可选 CDP);详见 `skills/多平台分发_SKILL.md`
|
||||
2. **B站**:使用 `bilibili-api-python` 自动获取
|
||||
3. **小红书/快手**:Playwright 自动化登录后提取
|
||||
|
||||
@@ -277,5 +298,7 @@ curl -sS -X POST -H "Content-Type: application/json" -d "$TEXT" "$FEISHU_PARTY_C
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|:---|:---|:---|
|
||||
| 1.3 | 2026-03-24 | 升级为“总规则+平台子Skill”;统一相邻间隔 10~120 分钟;新增账号封禁/重登/重试收敛规则 |
|
||||
| 1.2 | 2026-03-23 | 新增视频号发布前置三步:头像昵称校验、失败/重复清理、强制定时发布(含请求注入兜底) |
|
||||
| 1.1 | 2026-03-21 | 新增 §九 闭环复盘发群:卡若五块复盘 + 飞书 Webhook v2(msg_type 必填) |
|
||||
| 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 |
|
||||
|
||||
@@ -3,18 +3,25 @@ name: 多平台分发
|
||||
description: >
|
||||
一键将视频分发到 5 个平台(抖音、B站、视频号、小红书、快手)。
|
||||
API 优先策略:视频号纯 API、B站 bilibili-api-python、抖音纯 API。
|
||||
支持定时排期(第1条立即发,后续 30-120 分钟随机间隔)、并行分发、去重、失败自动重试。
|
||||
支持定时排期(默认智能错峰;可选 legacy)、默认静默不弹窗登录、并行分发、去重、失败自动重试。
|
||||
triggers: 多平台分发、一键分发、全平台发布、批量分发、视频分发
|
||||
owner: 木叶
|
||||
group: 木
|
||||
version: "4.0"
|
||||
updated: "2026-03-11"
|
||||
version: "4.5"
|
||||
updated: "2026-03-24"
|
||||
---
|
||||
|
||||
# 多平台分发 Skill(v4.0)
|
||||
# 多平台分发 Skill(v4.5)
|
||||
|
||||
> **核心原则**:API 发布为主,Playwright 为辅。确保确定性地分发到各平台。
|
||||
> **v4.0 变更**:视频号已切换为纯 API、统一元数据生成器、定时排期优化、简介/标签/分区自动填充。
|
||||
> **v4.5**:统一改为“按相邻条目”间隔控制,默认区间 **10~120 分钟**;并要求按平台子 Skill 执行对应规则后再发布。
|
||||
|
||||
## 〇、执行原则(第一性原理)
|
||||
|
||||
- **视频号两步**:先扫码落盘 Cookie 再上传;`video_channels_resume.py` **默认弹 Chromium 窗**扫码,扫完再继续传;`--silent-login` 无头。
|
||||
- **目标优先**:全平台分发 → 直接 `distribute_all.py`;视频号助手态需**事先**手动 `channels_login.py` 或显式 `--auto-channels-login`。
|
||||
- **Cookie 优先**:登录成功必须落盘;视频号双路径同步见 `cookie_manager.sync_channels_cookie_files`。
|
||||
- **默认静默**:无人值守跑命令时不弹浏览器。
|
||||
|
||||
---
|
||||
|
||||
@@ -32,6 +39,8 @@ updated: "2026-03-11"
|
||||
> 按《视频号与腾讯相关 API 整理》结论,微信官方目前**没有开放「短视频上传/发布」接口**;本 Skill 中的视频号发布能力,属于对 `https://channels.weixin.qq.com` 视频号助手网页协议的逆向封装(DFS 上传 + `post_create`),仅在你本机使用,需自行承担协议变更与合规风险。
|
||||
> 官方可控能力(直播记录、橱窗、留资、罗盘数据、本地生活等)的服务端 API 入口为:`https://developers.weixin.qq.com/doc/channels/api/`,如需做直播/橱窗/留资集成,可基于该文档在单独 Skill 中扩展。
|
||||
|
||||
> **「视频号 API token」与成片上传**:公众号 **`access_token`** **不能**替代视频号助手网页态;`channels_api_publish` 依赖 **`channels_storage_state.json`**。127 场静默全平台:`python3 distribute_all.py --video-dir "/Users/karuo/Movies/soul视频/第127场_20260318_output/成片"`(须各平台 Cookie 已就绪)。
|
||||
|
||||
---
|
||||
|
||||
## 二、一键命令
|
||||
@@ -39,10 +48,13 @@ updated: "2026-03-11"
|
||||
```bash
|
||||
cd /Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本
|
||||
|
||||
# 定时排期:第1条立即,后续 30-120min 随机间隔
|
||||
# 默认智能错峰排期
|
||||
python3 distribute_all.py
|
||||
|
||||
# 立即全部发布
|
||||
# 规则间隔(推荐,按相邻条目 10~120 分钟)
|
||||
python3 distribute_all.py --legacy-schedule --min-gap 10 --max-gap 120
|
||||
|
||||
# 立即全部发布(仅在明确要求时)
|
||||
python3 distribute_all.py --now
|
||||
|
||||
# 只发指定平台
|
||||
@@ -54,24 +66,34 @@ python3 distribute_all.py --video-dir "/path/to/videos/"
|
||||
# 检查 Cookie / 重试失败
|
||||
python3 distribute_all.py --check
|
||||
python3 distribute_all.py --retry
|
||||
|
||||
# 需要脚本自动弹窗扫视频号(默认不弹)
|
||||
python3 distribute_all.py --platforms 视频号 --auto-channels-login --video-dir "/path/to/成片"
|
||||
# 独立 channels_api_publish 允许自动登录:CHANNELS_AUTO_LOGIN=1
|
||||
# 强制永不自动登录:NO_AUTO_CHANNELS_LOGIN=1
|
||||
|
||||
# 平台站点上传 CLI(平台级,含账号检测+自动重登+重试)
|
||||
python3 site_upload_cli.py check --platforms 抖音 B站 小红书 快手
|
||||
python3 site_upload_cli.py publish --platforms 抖音 B站 小红书 --video-dir "/path/to/成片" --min-gap 10 --max-gap 120 --until-success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、定时排期(v4.0 优化)
|
||||
## 三、定时排期(v4.2+)
|
||||
|
||||
### 3.1 排期规则
|
||||
- **第 1 条**:立即发布(`first_delay=0`)
|
||||
- **第 2 条起**:前一条 + random(30, 120) 分钟
|
||||
- 若总跨度 > 24h,自动按比例压缩
|
||||
- 12 条视频典型跨度 ~10-14h
|
||||
### 3.1 默认(`generate_smart_schedule`)
|
||||
- 第 1 条立即;间隔与总跨度随条数自适应;本地 0–7 点尽量挪到午间(`SCHEDULE_NO_NIGHT_REFINE=1` 关闭)
|
||||
- `--legacy-schedule` + `--min-gap` / `--max-gap` / `--max-hours` 为固定区间逻辑(建议 `10~120`)
|
||||
- 去重时排期与目录列表下标对齐
|
||||
|
||||
### 3.2 各平台定时实现
|
||||
### 3.2 独立 `channels_api_publish.py`:同上智能排期转 Unix
|
||||
|
||||
### 3.3 各平台定时实现
|
||||
|
||||
| 平台 | 定时方式 | 参数 |
|
||||
|------|----------|------|
|
||||
| B站 | API `meta.dtime` | Unix 时间戳(秒) |
|
||||
| 视频号 | API 暂不支持原生定时 | 描述中标注时间/手动设置 |
|
||||
| 视频号 | API `postTimingInfo.postTime`(秒级 Unix);过近时间自动顺延,不允许立即发 | `channels_api_publish._scheduled_ts_for_channels` |
|
||||
| 抖音 | API `timing_ts` | Unix 时间戳 |
|
||||
| 快手 | Playwright UI | `schedule_helper.py` |
|
||||
| 小红书 | Playwright UI | `schedule_helper.py` |
|
||||
@@ -91,12 +113,12 @@ meta.description("B站") # 标题 + 标签 + 品牌标记
|
||||
meta.tags_str("B站") # AI工具,效率提升,Soul派对,...
|
||||
meta.bilibili_meta() # B站投稿完整 meta(含 tid/tag/desc)
|
||||
meta.title_short() # 小红书短标题(≤20字)
|
||||
meta.hashtags("视频号") # #AI工具 #效率提升 ... #小程序 卡若创业派对
|
||||
meta.hashtags("视频号") # … + #小程序卡若创业派对 #公众号卡若-4点起床的男人
|
||||
```
|
||||
|
||||
### 4.1 内容结构
|
||||
- **标题**:手工优化标题库优先,否则从文件名智能提取
|
||||
- **简介**:标题 + 换行 + 话题标签 + `#小程序 卡若创业派对`
|
||||
- **简介**:标题 + 换行 + 话题标签;**视频号**固定追加 `#小程序卡若创业派对` `#公众号卡若-4点起床的男人`(其它平台仍为 `#小程序 卡若创业派对`)
|
||||
- **标签**:基于关键词匹配(AI/创业/副业/Soul 等 12 类)+ 通用标签
|
||||
- **分区**:B站 tid=160(生活>日常)
|
||||
- **风控过滤**:`content_filter.py` 自动替换敏感词(70+ 映射,严格/宽松分级)
|
||||
@@ -122,8 +144,42 @@ meta.hashtags("视频号") # #AI工具 #效率提升 ... #小程序 卡若创
|
||||
`cookie_manager.py` 统一管理:
|
||||
- 中央存储:`多平台分发/cookies/{平台}_cookies.json`
|
||||
- 自动迁移:旧路径 → 中央存储(首次使用时)
|
||||
- API 预检:5 平台各自 auth API 校验有效性
|
||||
- 防重复登录:有效 Cookie 不触发重新获取
|
||||
- **视频号双路径**:预检读中央、发布读 legacy;`sync_channels_cookie_files()` 按 **mtime 新者覆盖旧者**,避免两份不一致
|
||||
- **登录后必存**:`channels_login.py` 保存 `channels_storage_state.json` 后 **立即 copy** 到 `cookies/视频号_cookies.json`
|
||||
- **登录页只在 Cursor 内**:`channels_login.py` v7 用 `cursor://vscode.simple-browser/show?url=…` 打开 **Simple Browser**,不用系统默认浏览器;**无额外 Chromium** 时需 Cursor 带 `--remote-debugging-port=9223`(或 `CHANNELS_CDP_URL`),脚本 CDP 附着后导出会话;否则 **自动回退** 本机 Chromium 仅用于写 Cookie(`--playwright-only` 强制只走 Chromium)。
|
||||
- **视频号登录**:默认不自动执行 `channels_login.py`;需要时加 `--auto-channels-login`(或事先手动登录)
|
||||
- API 预检:各平台 auth API 校验有效性
|
||||
|
||||
---
|
||||
|
||||
## 六点五、视频号发布前置检查(强制)
|
||||
|
||||
每次发布视频号前,必须先跑:
|
||||
|
||||
1. `auth/auth_data`:校验 `nickname` 与 `headImgUrl`(不一致先改号资料,再执行发布)。
|
||||
2. `post/post_list`:筛查失败条目并删除。
|
||||
3. 同标题去重:若存在多条,仅保留最新 `objectId`,其余调用 `post/post_delete` 删除。
|
||||
4. 发布阶段若页面定时控件失败,改为 `post_create` 请求注入 `postTimingInfo`,继续定时发布;注入也失败则中止该条(防止误发立即)。
|
||||
|
||||
---
|
||||
|
||||
## 六点六、账号异常自动处理(强制)
|
||||
|
||||
1. 发布前先执行 `--check`;若平台 Cookie 无效,先走对应登录脚本。
|
||||
2. 发布中若出现封禁/风控/鉴权失败,立即输出处理建议与重登命令。
|
||||
3. 重登完成后优先执行 `--retry`,只重试失败条,不重复成功条。
|
||||
4. 失败超过 2 轮仍未收敛时,输出平台级阻塞原因并停止盲目重试。
|
||||
|
||||
---
|
||||
|
||||
## 六点七、平台子Skill分流(强制)
|
||||
|
||||
- 视频号:按 `平台_视频号_SKILL.md` 执行。
|
||||
- B站:按 `平台_B站_SKILL.md` 执行。
|
||||
- 小红书:按 `平台_小红书_SKILL.md` 执行。
|
||||
- 抖音:按 `平台_抖音_SKILL.md` 执行。
|
||||
|
||||
多平台联发时,先加载各平台子Skill,再统一调度 `distribute_all.py`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
34
.cursor/skills/karuo-party/skills/平台_B站_SKILL.md
Normal file
34
.cursor/skills/karuo-party/skills/平台_B站_SKILL.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: 平台_B站发布
|
||||
description: B站发布专用规则。API优先,失败降级 Playwright,按相邻间隔 10~120 分钟定时发布。
|
||||
triggers: B站发布、B站补发、B站重试
|
||||
owner: 木叶
|
||||
group: 木
|
||||
version: "1.0"
|
||||
updated: "2026-03-24"
|
||||
---
|
||||
|
||||
# 平台子Skill:B站发布
|
||||
|
||||
## 一、强制规则
|
||||
|
||||
1. 默认 API 投稿,失败才降级 Playwright。
|
||||
2. 发布间隔按相邻条目控制在 `10~120` 分钟。
|
||||
3. 出现 406/超时等异常时先重试失败条,不重复成功条。
|
||||
|
||||
## 二、标准命令
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本/distribute_all.py" \
|
||||
--platforms B站 \
|
||||
--video-dir "<成片目录>" \
|
||||
--legacy-schedule --min-gap 10 --max-gap 120
|
||||
```
|
||||
|
||||
## 三、登录异常处理
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/B站发布/脚本/bilibili_login.py"
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本/distribute_all.py" --retry
|
||||
```
|
||||
|
||||
34
.cursor/skills/karuo-party/skills/平台_小红书_SKILL.md
Normal file
34
.cursor/skills/karuo-party/skills/平台_小红书_SKILL.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: 平台_小红书发布
|
||||
description: 小红书发布专用规则。UI自动化定时发布,按相邻间隔 10~120 分钟执行。
|
||||
triggers: 小红书发布、小红书补发
|
||||
owner: 木叶
|
||||
group: 木
|
||||
version: "1.0"
|
||||
updated: "2026-03-24"
|
||||
---
|
||||
|
||||
# 平台子Skill:小红书发布
|
||||
|
||||
## 一、强制规则
|
||||
|
||||
1. 逐条定时发布,禁止批量立即发布。
|
||||
2. 相邻发布时间间隔必须在 `10~120` 分钟。
|
||||
3. 若仅返回 likely_published,需在下一轮巡检确认状态。
|
||||
|
||||
## 二、标准命令
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本/distribute_all.py" \
|
||||
--platforms 小红书 \
|
||||
--video-dir "<成片目录>" \
|
||||
--legacy-schedule --min-gap 10 --max-gap 120
|
||||
```
|
||||
|
||||
## 三、登录异常处理
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/小红书发布/脚本/xiaohongshu_login.py"
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本/distribute_all.py" --retry
|
||||
```
|
||||
|
||||
34
.cursor/skills/karuo-party/skills/平台_抖音_SKILL.md
Normal file
34
.cursor/skills/karuo-party/skills/平台_抖音_SKILL.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: 平台_抖音发布
|
||||
description: 抖音发布专用规则。API定时发布,按相邻间隔 10~120 分钟;遇风控/封禁优先账号处理。
|
||||
triggers: 抖音发布、抖音补发、抖音重试
|
||||
owner: 木叶
|
||||
group: 木
|
||||
version: "1.0"
|
||||
updated: "2026-03-24"
|
||||
---
|
||||
|
||||
# 平台子Skill:抖音发布
|
||||
|
||||
## 一、强制规则
|
||||
|
||||
1. 仅按定时发布,不做整批立即。
|
||||
2. 相邻发布时间间隔 `10~120` 分钟。
|
||||
3. 若出现封禁/风控提示,立刻停止盲目重试并提示账号处理。
|
||||
|
||||
## 二、标准命令
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本/distribute_all.py" \
|
||||
--platforms 抖音 \
|
||||
--video-dir "<成片目录>" \
|
||||
--legacy-schedule --min-gap 10 --max-gap 120
|
||||
```
|
||||
|
||||
## 三、登录异常处理
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/抖音发布/脚本/douyin_login.py"
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本/distribute_all.py" --retry
|
||||
```
|
||||
|
||||
38
.cursor/skills/karuo-party/skills/平台_视频号_SKILL.md
Normal file
38
.cursor/skills/karuo-party/skills/平台_视频号_SKILL.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: 平台_视频号发布
|
||||
description: 视频号发布专用规则。发布前校验账号,清理密集/失败条目,按相邻间隔 10~120 分钟定时发布。
|
||||
triggers: 视频号发布、视频号重传、视频号补发
|
||||
owner: 木叶
|
||||
group: 木
|
||||
version: "1.0"
|
||||
updated: "2026-03-24"
|
||||
---
|
||||
|
||||
# 平台子Skill:视频号发布
|
||||
|
||||
## 一、强制规则
|
||||
|
||||
1. 发布前必须校验 `auth_data`(昵称、头像、登录态)。
|
||||
2. 发布前必须检查 `post_list`:失败条目删除、同标题去重。
|
||||
3. 仅允许定时发布,按相邻条目间隔 `10~120` 分钟。
|
||||
4. 定时控件失败时允许请求注入;注入失败则中止该条,禁止误发立即。
|
||||
|
||||
## 二、标准命令
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/视频号发布/脚本/channels_web_cli.py" \
|
||||
publish-dir \
|
||||
--video-dir "<成片目录>" \
|
||||
--legacy-schedule --min-gap 10 --max-gap 120 \
|
||||
--start-after-min 10
|
||||
```
|
||||
|
||||
## 三、登录异常处理
|
||||
|
||||
- 若出现 `300334` / `300002` / `finder_raw` 缺失:立即执行重登。
|
||||
- 重登命令:
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/视频号发布/脚本/channels_login.py" --playwright-only
|
||||
```
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -46,3 +46,6 @@ soul-api/soul-api-new
|
||||
|
||||
# Cursor 索引减负:db-exec 依赖(仓库根已有 node_modules/ 规则,此处显式强调子路径)
|
||||
.cursor/scripts/db-exec/node_modules/
|
||||
|
||||
# 本地技能包临时打包目录
|
||||
.tmp_skill_bundle/
|
||||
|
||||
@@ -9,10 +9,10 @@ const { checkAndExecute } = require('./utils/ruleEngine.js')
|
||||
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
|
||||
const DEFAULT_MCH_ID = '1318592501'
|
||||
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
|
||||
const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com'
|
||||
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
|
||||
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version(正式版),否则用本字段
|
||||
const APP_DISPLAY_VERSION = '1.7.1'
|
||||
// 章节总数:API 获取失败时的统一兜底,避免 90/62 混用
|
||||
const FALLBACK_TOTAL_SECTIONS = 62
|
||||
const APP_DISPLAY_VERSION = '1.7.2'
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
@@ -42,7 +42,7 @@ App({
|
||||
|
||||
// 书籍数据(bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters)
|
||||
bookData: null,
|
||||
totalSections: FALLBACK_TOTAL_SECTIONS, // 来自 book/parts 或 book/stats,失败时用常量
|
||||
totalSections: 90,
|
||||
|
||||
// 购买记录
|
||||
purchasedSections: [],
|
||||
@@ -98,32 +98,34 @@ App({
|
||||
lastVipContactCheck: 0,
|
||||
// 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
|
||||
lastAvatarNicknameCheck: 0,
|
||||
// 登录过期:401 后用户点「去登录」时设为 true,我的页 onShow 会检测并自动弹登录
|
||||
pendingLoginAfterExpire: false,
|
||||
/** MBTI → 默认头像 URL(/api/miniprogram/config/mbti-avatars),供推广海报等 */
|
||||
mbtiAvatarsMap: {},
|
||||
mbtiAvatarsExpires: 0,
|
||||
},
|
||||
|
||||
|
||||
/** 正式版强制生产 API,避免误传 localhost 导致审核/线上全挂 */
|
||||
initApiBaseUrl() {
|
||||
const PRODUCTION = 'https://soulapi.quwanzhi.com'
|
||||
const KEY = 'apiBaseUrl'
|
||||
try {
|
||||
const info = wx.getAccountInfoSync?.()
|
||||
const env = info?.miniProgram?.envVersion || 'release'
|
||||
if (env === 'release') {
|
||||
this.globalData.baseUrl = PRODUCTION
|
||||
this.globalData.baseUrl = PRODUCTION_BASE_URL
|
||||
try {
|
||||
const saved = wx.getStorageSync(KEY)
|
||||
if (saved && saved !== PRODUCTION) wx.removeStorageSync(KEY)
|
||||
if (saved && saved !== PRODUCTION_BASE_URL) wx.removeStorageSync(KEY)
|
||||
} catch (_) {}
|
||||
return
|
||||
}
|
||||
const saved = wx.getStorageSync(KEY)
|
||||
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
|
||||
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
|
||||
} else {
|
||||
this.globalData.baseUrl = PRODUCTION_BASE_URL
|
||||
}
|
||||
} catch (_) {
|
||||
this.globalData.baseUrl = PRODUCTION
|
||||
this.globalData.baseUrl = PRODUCTION_BASE_URL
|
||||
}
|
||||
},
|
||||
|
||||
@@ -397,8 +399,8 @@ App({
|
||||
if (!this.globalData.isSinglePageMode) return true
|
||||
|
||||
wx.showModal({
|
||||
title: '请前往完整小程序',
|
||||
content: '当前为朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。',
|
||||
title: '请打开完整小程序',
|
||||
content: '当前是朋友圈预览,无法在这里登录或付款。请先点击屏幕底部「前往小程序」,进入完整版后再解锁本章。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
})
|
||||
@@ -414,7 +416,7 @@ App({
|
||||
},
|
||||
|
||||
/**
|
||||
* 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname;新用户由登录处强制 redirectTo
|
||||
* 头像/昵称未改:老用户弹窗后跳 avatar-nickname;新用户由登录处强制 redirectTo
|
||||
* VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit,避免乱跳
|
||||
*/
|
||||
checkAvatarNicknameAndGuide() {
|
||||
@@ -433,10 +435,10 @@ App({
|
||||
if (lastDate === today) return
|
||||
wx.setStorageSync('lastAvatarGuideDate', today)
|
||||
wx.showModal({
|
||||
title: '完善个人资料',
|
||||
content: '请设置头像和昵称,让其他创业者更好地认识你',
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后',
|
||||
title: '设置头像与昵称',
|
||||
content: '头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
|
||||
}
|
||||
@@ -594,7 +596,7 @@ App({
|
||||
|
||||
/**
|
||||
* VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit,避免与 avatar-nickname 乱跳。
|
||||
* 旧数据(VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit
|
||||
* 旧数据(VIP 但头像昵称未改):说明原因后 redirectTo profile-edit
|
||||
*/
|
||||
async checkVipContactRequiredAndGuide() {
|
||||
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
|
||||
@@ -623,12 +625,12 @@ App({
|
||||
const wechatId = (profileData.wechatId || profileData.wechat_id || this.globalData.userInfo?.wechatId || this.globalData.userInfo?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
||||
const needsAvatarNickname = this._needsAvatarNickname(profileData)
|
||||
|
||||
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit,弹窗「为了更好服务,请完善资料」
|
||||
// VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit
|
||||
if (needsAvatarNickname) {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: '为了更好为您服务,请完善资料',
|
||||
confirmText: '去完善',
|
||||
title: '补全对外展示信息',
|
||||
content: 'VIP 名片与派对场景会展示头像与昵称,补全后对方更容易认出你。',
|
||||
confirmText: '去填写',
|
||||
showCancel: false,
|
||||
success: () => {
|
||||
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
@@ -641,9 +643,9 @@ App({
|
||||
// VIP 无手机号:弹窗说明后跳转
|
||||
if (!phone) {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: 'VIP会员需完善手机号,以便使用找伙伴、提现等功能',
|
||||
confirmText: '去完善',
|
||||
title: '补全手机号',
|
||||
content: '手机号用于找伙伴、提现验证与重要通知,仅本人可见。',
|
||||
confirmText: '去填写',
|
||||
showCancel: false,
|
||||
success: () => {
|
||||
wx.redirectTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
@@ -651,12 +653,12 @@ App({
|
||||
})
|
||||
return
|
||||
}
|
||||
// 有手机号但缺微信号:弹窗引导(非强制)
|
||||
// 有手机号但缺微信号:可选补全(非强制)
|
||||
wx.showModal({
|
||||
title: '完善联系方式',
|
||||
content: '请到资料页完善微信号,便于他人联系您',
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后',
|
||||
title: '补全微信号(可选)',
|
||||
content: '填写微信号后,对方在允许的场景下能更快加到你;不填也可继续使用。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '关闭',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
}
|
||||
@@ -695,6 +697,17 @@ App({
|
||||
if (!forceRefresh && this.globalData.configCache && now < this.globalData.configCacheExpires) {
|
||||
return this.globalData.configCache
|
||||
}
|
||||
if (!forceRefresh && !this.globalData.configCache) {
|
||||
try {
|
||||
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
|
||||
const exp = Number(local && local.expiresAt)
|
||||
if (local && local.data && exp > now) {
|
||||
this.globalData.configCache = local.data
|
||||
this.globalData.configCacheExpires = exp
|
||||
return local.data
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
try {
|
||||
const [coreRes, auditRes] = await Promise.all([
|
||||
this.request({ url: '/api/miniprogram/config/core', silent: true, timeout: 5000 }),
|
||||
@@ -713,11 +726,45 @@ App({
|
||||
}
|
||||
this.globalData.configCache = res
|
||||
this.globalData.configCacheExpires = now + CACHE_TTL
|
||||
try {
|
||||
wx.setStorageSync(CONFIG_CACHE_KEY, {
|
||||
data: res,
|
||||
expiresAt: this.globalData.configCacheExpires
|
||||
})
|
||||
} catch (_) {}
|
||||
return res
|
||||
}
|
||||
} catch (e) {
|
||||
if (this.globalData.configCache) return this.globalData.configCache
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const legacy = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
|
||||
if (legacy) {
|
||||
const cfg = (legacy.configs && legacy.configs.mp_config) || legacy.mpConfig || {}
|
||||
const res = {
|
||||
success: legacy.success !== false,
|
||||
prices: legacy.prices || (legacy.configs && legacy.configs.chapter_config && legacy.configs.chapter_config.prices) || {},
|
||||
features: legacy.features || (legacy.configs && legacy.configs.feature_config) || {},
|
||||
userDiscount: legacy.userDiscount,
|
||||
mpConfig: cfg,
|
||||
configs: legacy.configs || {}
|
||||
}
|
||||
this.globalData.configCache = res
|
||||
this.globalData.configCacheExpires = now + CACHE_TTL
|
||||
try {
|
||||
wx.setStorageSync(CONFIG_CACHE_KEY, {
|
||||
data: res,
|
||||
expiresAt: this.globalData.configCacheExpires
|
||||
})
|
||||
} catch (_) {}
|
||||
return res
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (this.globalData.configCache) return this.globalData.configCache
|
||||
try {
|
||||
const local = wx.getStorageSync(CONFIG_CACHE_KEY)
|
||||
if (local && local.data) return local.data
|
||||
} catch (_) {}
|
||||
return null
|
||||
},
|
||||
|
||||
@@ -780,27 +827,58 @@ App({
|
||||
async loadMpConfig() {
|
||||
try {
|
||||
const res = await this.getConfig()
|
||||
if (!res) return
|
||||
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
|
||||
if (mp && typeof mp === 'object') {
|
||||
if (mp.appId) this.globalData.appId = mp.appId
|
||||
if (mp.mchId) this.globalData.mchId = mp.mchId
|
||||
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
|
||||
this.globalData.auditMode = !!mp.auditMode
|
||||
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
|
||||
// 通知当前已加载的页面刷新 auditMode(从后台切回时配置更新后立即生效)
|
||||
try {
|
||||
const pages = getCurrentPages()
|
||||
pages.forEach(p => {
|
||||
if (p && p.data && 'auditMode' in p.data) {
|
||||
p.setData({ auditMode: this.globalData.auditMode || false })
|
||||
}
|
||||
})
|
||||
} catch (_) {}
|
||||
if (res) {
|
||||
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
|
||||
if (mp && typeof mp === 'object') {
|
||||
if (mp.appId) this.globalData.appId = mp.appId
|
||||
if (mp.mchId) this.globalData.mchId = mp.mchId
|
||||
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
|
||||
this.globalData.auditMode = !!mp.auditMode
|
||||
this.globalData.supportWechat = mp.supportWechat || mp.customerWechat || mp.serviceWechat || ''
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
|
||||
}
|
||||
// 审核模式不走 5min 本地 config 缓存:始终以独立接口为准,避免后台已开审核端仍显示支付入口
|
||||
try {
|
||||
await this.getAuditMode()
|
||||
} catch (_) {}
|
||||
this.loadMbtiAvatarsMap()
|
||||
},
|
||||
|
||||
/** 拉取后台配置的 16 型 MBTI 默认头像(公开接口,约 5 分钟本地缓存) */
|
||||
async loadMbtiAvatarsMap() {
|
||||
try {
|
||||
const now = Date.now()
|
||||
if (this.globalData.mbtiAvatarsExpires && this.globalData.mbtiAvatarsExpires > now) return
|
||||
const res = await this.request({
|
||||
url: '/api/miniprogram/config/mbti-avatars',
|
||||
silent: true,
|
||||
timeout: 8000,
|
||||
})
|
||||
if (res && res.success && res.avatars && typeof res.avatars === 'object') {
|
||||
this.globalData.mbtiAvatarsMap = res.avatars
|
||||
this.globalData.mbtiAvatarsExpires = now + 5 * 60 * 1000
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[App] loadMbtiAvatarsMap:', e?.message || e)
|
||||
}
|
||||
},
|
||||
|
||||
/** 展示用头像:优先用户头像,否则 MBTI 映射(需已 loadMbtiAvatarsMap) */
|
||||
resolveAvatarWithMbti(avatar, mbti) {
|
||||
try {
|
||||
const { resolveAvatarWithMbti } = require('./utils/mbtiAvatar.js')
|
||||
return resolveAvatarWithMbti(
|
||||
avatar,
|
||||
mbti,
|
||||
this.globalData.mbtiAvatarsMap || {},
|
||||
this.globalData.baseUrl || ''
|
||||
)
|
||||
} catch (_) {
|
||||
return (avatar && String(avatar).trim()) || ''
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -855,6 +933,79 @@ App({
|
||||
return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg
|
||||
},
|
||||
|
||||
_shouldFallbackToProduction(err) {
|
||||
const msg = String((err && err.errMsg) || (err && err.message) || '').toLowerCase()
|
||||
return (
|
||||
msg.includes('connection_failed') ||
|
||||
msg.includes('err_proxy_connection_failed') ||
|
||||
msg.includes('dns') ||
|
||||
msg.includes('name not resolved') ||
|
||||
msg.includes('failed to fetch') ||
|
||||
msg.includes('econnrefused') ||
|
||||
msg.includes('network') ||
|
||||
msg.includes('timeout')
|
||||
)
|
||||
},
|
||||
|
||||
_switchBaseUrlToProduction() {
|
||||
this.globalData.baseUrl = PRODUCTION_BASE_URL
|
||||
try {
|
||||
wx.setStorageSync('apiBaseUrl', PRODUCTION_BASE_URL)
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
_requestOnce(url, options = {}, silent = false) {
|
||||
const showError = (msg) => {
|
||||
if (!silent && msg) wx.showToast({ title: msg, icon: 'none', duration: 2500 })
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token')
|
||||
wx.request({
|
||||
url: this.globalData.baseUrl + url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
timeout: options.timeout || 15000,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
...options.header
|
||||
},
|
||||
success: (res) => {
|
||||
const data = res.data
|
||||
if (res.statusCode === 200) {
|
||||
if (data && data.success === false) {
|
||||
const msg = this._getApiErrorMsg(data, '操作失败')
|
||||
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
|
||||
this.logout()
|
||||
}
|
||||
showError(msg)
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
if (res.statusCode === 401) {
|
||||
this.logout()
|
||||
showError('未授权,请重新登录')
|
||||
reject(new Error('未授权'))
|
||||
return
|
||||
}
|
||||
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
|
||||
showError(msg)
|
||||
reject(new Error(msg))
|
||||
},
|
||||
fail: (err) => {
|
||||
const msg = (err && err.errMsg)
|
||||
? (String(err.errMsg).indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试')
|
||||
: '网络异常,请重试'
|
||||
showError(msg)
|
||||
reject(err || new Error(msg))
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。
|
||||
* GET 请求 200ms 内相同 url 去重,避免并发重复请求。
|
||||
@@ -874,98 +1025,21 @@ App({
|
||||
}
|
||||
const method = (options.method || 'GET').toUpperCase()
|
||||
const silent = !!options.silent
|
||||
const showError = (msg) => {
|
||||
if (!silent && msg) {
|
||||
wx.showToast({ title: msg, icon: 'none', duration: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
// GET 短时去重:相同 url 的并发请求共享同一 promise
|
||||
if (method === 'GET') {
|
||||
const dedupKey = url + (options.data ? JSON.stringify(options.data) : '')
|
||||
const pending = this._requestPending || (this._requestPending = {})
|
||||
if (pending[dedupKey]) {
|
||||
return pending[dedupKey].promise
|
||||
}
|
||||
if (pending[dedupKey]) return pending[dedupKey].promise
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
const token = wx.getStorageSync('token')
|
||||
wx.request({
|
||||
url: this.globalData.baseUrl + url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data || {},
|
||||
timeout: options.timeout || 15000,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': token ? `Bearer ${token}` : '',
|
||||
...options.header
|
||||
},
|
||||
success: (res) => {
|
||||
const data = res.data
|
||||
if (res.statusCode === 200) {
|
||||
// 业务失败:success === false,soul-api 用 message 或 error 返回原因
|
||||
if (data && data.success === false) {
|
||||
const msg = this._getApiErrorMsg(data, '操作失败')
|
||||
// 登录态不一致:本地有 token/userInfo,但后端查不到该用户
|
||||
// 典型原因:切换环境(baseUrl)、换库/清库、用户被删除、token 与用户不匹配
|
||||
if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) {
|
||||
this.logout()
|
||||
}
|
||||
const err = new Error(msg)
|
||||
err.response = data
|
||||
const skipToast = data.needBindWechat === true || data.needBind === true ||
|
||||
(data.errorCode && String(data.errorCode).indexOf('ERR_') === 0)
|
||||
if (!silent && !skipToast) showError(msg)
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
if (res.statusCode === 401) {
|
||||
this.logout()
|
||||
if (!silent) {
|
||||
wx.showModal({
|
||||
title: '登录已过期',
|
||||
content: '请重新登录后继续使用',
|
||||
confirmText: '去登录',
|
||||
cancelText: '稍后',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
const pages = getCurrentPages()
|
||||
const cur = pages[pages.length - 1]
|
||||
const route = (cur && cur.route) || ''
|
||||
if (route === 'pages/my/my' && typeof cur.showLogin === 'function') {
|
||||
cur.showLogin()
|
||||
} else {
|
||||
this.globalData.pendingLoginAfterExpire = true
|
||||
wx.switchTab({ url: '/pages/my/my' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
showError('未授权,请重新登录')
|
||||
}
|
||||
reject(new Error('未授权'))
|
||||
return
|
||||
}
|
||||
// 4xx/5xx:优先用返回体的 message/error
|
||||
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
|
||||
const err = new Error(msg)
|
||||
if (data && typeof data === 'object') err.response = data
|
||||
const skipToast = data && (data.needBindWechat === true || data.needBind === true ||
|
||||
(data.errorCode && String(data.errorCode).indexOf('ERR_') === 0))
|
||||
if (!silent && !skipToast) showError(msg)
|
||||
reject(err)
|
||||
},
|
||||
fail: (err) => {
|
||||
const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试'
|
||||
showError(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
})
|
||||
const promise = this._requestOnce(url, options, silent).catch(async (err) => {
|
||||
const currentBase = String(this.globalData.baseUrl || '').replace(/\/$/, '')
|
||||
if (currentBase !== PRODUCTION_BASE_URL && this._shouldFallbackToProduction(err)) {
|
||||
this._switchBaseUrlToProduction()
|
||||
return this._requestOnce(url, options, silent)
|
||||
}
|
||||
const msg = (err && err.message) ? err.message : '网络异常,请重试'
|
||||
throw new Error(msg)
|
||||
})
|
||||
|
||||
if (method === 'GET') {
|
||||
@@ -1134,11 +1208,6 @@ App({
|
||||
|
||||
if (res.success && res.data) {
|
||||
const user = res.data.user
|
||||
const oid = res.data.openId || user.openId
|
||||
if (oid) {
|
||||
this.globalData.openId = oid
|
||||
wx.setStorageSync('openId', oid)
|
||||
}
|
||||
this.globalData.userInfo = user
|
||||
this.globalData.isLoggedIn = true
|
||||
this.globalData.purchasedSections = user.purchasedSections || []
|
||||
@@ -1169,7 +1238,6 @@ App({
|
||||
} else {
|
||||
checkAndExecute('after_login', null)
|
||||
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
|
||||
setTimeout(() => this.connectWsHeartbeat(), 2000)
|
||||
}
|
||||
|
||||
return res.data
|
||||
@@ -1209,15 +1277,30 @@ App({
|
||||
wx.setStorageSync('readSectionIds', list)
|
||||
},
|
||||
|
||||
/**
|
||||
* 记录「最近打开过」某章节(含仅预览/未登录),供「我的」最近阅读展示;
|
||||
* 不写入 readSectionIds,避免把「点开预览」算进已读章节数。
|
||||
*/
|
||||
touchRecentSection(sectionId) {
|
||||
if (!sectionId) return
|
||||
try {
|
||||
let list = wx.getStorageSync('recent_section_opens')
|
||||
if (!Array.isArray(list)) list = []
|
||||
const now = Date.now()
|
||||
list = list.filter((x) => x && x.id !== sectionId)
|
||||
list.unshift({ id: String(sectionId), t: now })
|
||||
wx.setStorageSync('recent_section_opens', list.slice(0, 40))
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// 已读章节数(用于首页展示)
|
||||
getReadCount() {
|
||||
return (this.globalData.readSectionIds || []).length
|
||||
},
|
||||
|
||||
// 获取章节总数(优先 API 已加载值,失败时返回统一兜底常量)
|
||||
// 获取章节总数
|
||||
getTotalSections() {
|
||||
const v = this.globalData.totalSections
|
||||
return (v != null && v > 0) ? v : FALLBACK_TOTAL_SECTIONS
|
||||
return this.globalData.totalSections
|
||||
},
|
||||
|
||||
// 切换TabBar
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"login-modal": "/components/login-modal/login-modal"
|
||||
},
|
||||
"pages": [
|
||||
"pages/chapters/chapters",
|
||||
"pages/index/index",
|
||||
"pages/chapters/chapters",
|
||||
"pages/match/match",
|
||||
"pages/my/my",
|
||||
"pages/read/read",
|
||||
@@ -14,6 +14,7 @@
|
||||
"pages/privacy/privacy",
|
||||
"pages/referral/referral",
|
||||
"pages/purchases/purchases",
|
||||
"pages/reading-records/reading-records",
|
||||
"pages/settings/settings",
|
||||
"pages/search/search",
|
||||
"pages/addresses/addresses",
|
||||
@@ -29,7 +30,8 @@
|
||||
"pages/avatar-nickname/avatar-nickname",
|
||||
"pages/gift-pay/detail",
|
||||
"pages/gift-pay/list",
|
||||
"pages/gift-pay/redemption-detail"
|
||||
"pages/gift-pay/redemption-detail",
|
||||
"pages/dev-login/dev-login"
|
||||
],
|
||||
"window": {
|
||||
"backgroundTextStyle": "light",
|
||||
|
||||
BIN
miniprogram/assets/images/karuo-link-avatar.png
Normal file
BIN
miniprogram/assets/images/karuo-link-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
miniprogram/assets/images/part-books/0.png
Normal file
BIN
miniprogram/assets/images/part-books/0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
miniprogram/assets/images/part-books/1.png
Normal file
BIN
miniprogram/assets/images/part-books/1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
miniprogram/assets/images/part-books/2.png
Normal file
BIN
miniprogram/assets/images/part-books/2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
miniprogram/assets/images/part-books/3.png
Normal file
BIN
miniprogram/assets/images/part-books/3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
miniprogram/assets/images/part-books/4.png
Normal file
BIN
miniprogram/assets/images/part-books/4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -6,14 +6,14 @@
|
||||
<text class="login-title">登录 卡若创业派对</text>
|
||||
<text class="login-desc">{{desc}}</text>
|
||||
|
||||
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}">
|
||||
<button id="agree-phone-btn" class="btn-wechat {{agreeProtocol ? '' : 'btn-wechat-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}" hover-class="btn-wechat-hover">
|
||||
<text class="btn-wechat-icon">微</text>
|
||||
<text>{{isLoggingIn ? '登录中...' : '手机号一键登录'}}</text>
|
||||
</button>
|
||||
|
||||
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
|
||||
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
|
||||
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy">同意</button>
|
||||
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy" hover-class="privacy-agree-btn-hover">同意</button>
|
||||
</view>
|
||||
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
|
||||
<view class="login-agree-row" catchtap="onToggleAgree">
|
||||
|
||||
@@ -134,3 +134,7 @@
|
||||
text-decoration: underline;
|
||||
padding: 0 4rpx;
|
||||
}
|
||||
|
||||
/* 显式 hover 类名,避免基础库 3.x 报 hoverClass / hoverClassDisable 类型非法 */
|
||||
.btn-wechat-hover { opacity: 0.92; }
|
||||
.privacy-agree-btn-hover { opacity: 0.88; }
|
||||
|
||||
@@ -87,7 +87,14 @@ Component({
|
||||
console.log('[TabBar] res对象keys:', Object.keys(res || {}))
|
||||
}
|
||||
|
||||
this.setData({ matchEnabled }, () => {
|
||||
const tabUi = app.globalData.configCache?.mpConfig?.mpUi?.tabBar || {}
|
||||
const list = [...this.data.list]
|
||||
if (tabUi.home) list[0] = { ...list[0], text: String(tabUi.home) }
|
||||
if (tabUi.chapters) list[1] = { ...list[1], text: String(tabUi.chapters) }
|
||||
if (tabUi.match) list[2] = { ...list[2], text: String(tabUi.match) }
|
||||
if (tabUi.my) list[3] = { ...list[3], text: String(tabUi.my) }
|
||||
|
||||
this.setData({ matchEnabled, list }, () => {
|
||||
console.log('[TabBar] ✅ matchEnabled已设置为:', this.data.matchEnabled)
|
||||
// 配置加载完成后,根据当前路由设置选中状态
|
||||
this.updateSelected()
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 联系方式 - 引导到Soul派对房 -->
|
||||
<!-- 联系方式 - Soul 派对房 -->
|
||||
<view class="contact-card" wx:if="{{!authorLoading}}">
|
||||
<text class="card-title">联系作者</text>
|
||||
<view class="contact-item">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 卡若创业派对 - 头像昵称引导页
|
||||
* 登录后资料未完善时引导用户修改默认头像和昵称,仅包含头像+昵称两项
|
||||
* 卡若创业派对 - 头像与昵称设置页
|
||||
* 登录后若仍为默认头像/昵称,在此修改;仅头像与昵称两项
|
||||
*/
|
||||
const app = getApp()
|
||||
|
||||
@@ -12,11 +12,29 @@ Page({
|
||||
saving: false,
|
||||
showPrivacyModal: false,
|
||||
nicknameInputFocus: false,
|
||||
/** 规则引擎传入:avatar | nickname,用于高亮对应区块 */
|
||||
uiFocus: '',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
|
||||
const focus = String(options.focus || '').toLowerCase()
|
||||
if (focus === 'avatar' || focus === 'nickname') {
|
||||
this.setData({ uiFocus: focus })
|
||||
}
|
||||
this.loadFromUser()
|
||||
if (focus === 'nickname') {
|
||||
setTimeout(() => {
|
||||
if (typeof wx.requirePrivacyAuthorize === 'function') {
|
||||
wx.requirePrivacyAuthorize({
|
||||
success: () => this.setData({ nicknameInputFocus: true }),
|
||||
fail: () => {},
|
||||
})
|
||||
} else {
|
||||
this.setData({ nicknameInputFocus: true })
|
||||
}
|
||||
}, 400)
|
||||
}
|
||||
},
|
||||
|
||||
loadFromUser() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "完善资料",
|
||||
"navigationBarTitleText": "头像与昵称",
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<!--卡若创业派对 - 头像昵称引导页,仅头像+昵称-->
|
||||
<!--卡若创业派对 - 头像与昵称设置页-->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">完善资料</text>
|
||||
<text class="nav-title">头像与昵称</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<view class="content">
|
||||
<!-- 引导文案 -->
|
||||
<view class="guide-card">
|
||||
<icon name="handshake" size="64" color="#00CED1" customClass="guide-icon"></icon>
|
||||
<text class="guide-title">完善头像和昵称</text>
|
||||
<text class="guide-desc">让他人更好地认识你,展示更专业的形象</text>
|
||||
<text class="guide-title">设置对外展示信息</text>
|
||||
<text class="guide-desc" wx:if="{{uiFocus === 'avatar'}}">请先换一张清晰头像,伙伴更容易认出你。</text>
|
||||
<text class="guide-desc" wx:elif="{{uiFocus === 'nickname'}}">请改一个真实好记的昵称,方便伙伴称呼你。</text>
|
||||
<text class="guide-desc" wx:else>头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-section {{uiFocus === 'avatar' ? 'section-focus' : ''}}">
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
@@ -29,7 +30,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 昵称:点击前先请求隐私授权,解决 errno:104 昵称选择器无法弹出 -->
|
||||
<view class="form-section">
|
||||
<view class="form-section {{uiFocus === 'nickname' ? 'section-focus' : ''}}">
|
||||
<text class="form-label">昵称</text>
|
||||
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
|
||||
<input
|
||||
@@ -62,7 +63,7 @@
|
||||
</view>
|
||||
|
||||
<view class="link-row" bindtap="goToFullProfile">
|
||||
<text class="link-text">完善更多资料</text>
|
||||
<text class="link-text">编辑完整档案</text>
|
||||
<icon name="chevron-right" size="28" color="#00CED1" customClass="link-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* 卡若创业派对 - 头像昵称引导页 */
|
||||
/* 卡若创业派对 - 头像与昵称设置页 */
|
||||
.page {
|
||||
background: #050B14;
|
||||
min-height: 100vh;
|
||||
@@ -78,6 +78,13 @@
|
||||
gap: 32rpx;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
.avatar-section.section-focus .avatar-wrap {
|
||||
box-shadow: 0 0 0 6rpx rgba(94, 234, 212, 0.45), 0 0 36rpx rgba(94, 234, 212, 0.45);
|
||||
}
|
||||
.form-section.section-focus .form-input-wrap {
|
||||
border-color: rgba(94, 234, 212, 0.55);
|
||||
box-shadow: 0 0 0 2rpx rgba(94, 234, 212, 0.25);
|
||||
}
|
||||
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
|
||||
.avatar-wrap-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
const app = getApp()
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { partEmojiForBodyIndex } = require('../../utils/partIcons.js')
|
||||
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -26,6 +28,7 @@ Page({
|
||||
|
||||
// 展开状态
|
||||
expandedPart: null,
|
||||
bookCollapsed: false,
|
||||
|
||||
// 已加载的篇章章节缓存 { partId: chapters }
|
||||
_loadedChapters: {},
|
||||
@@ -47,7 +50,11 @@ Page({
|
||||
partsLoading: true,
|
||||
|
||||
// 功能配置(搜索开关)
|
||||
searchEnabled: true
|
||||
searchEnabled: true,
|
||||
|
||||
// mp_config.mpUi.chaptersPage
|
||||
chaptersBookTitle: '一场SOUL的创业实验场',
|
||||
chaptersBookSubtitle: '来自Soul派对房的真实商业故事'
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -62,10 +69,20 @@ Page({
|
||||
this.loadFeatureConfig()
|
||||
},
|
||||
|
||||
_applyChaptersMpUi() {
|
||||
const c = app.globalData.configCache?.mpConfig?.mpUi?.chaptersPage || {}
|
||||
this.setData({
|
||||
chaptersBookTitle: String(c.bookTitle || '一场SOUL的创业实验场').trim() || '一场SOUL的创业实验场',
|
||||
chaptersBookSubtitle: String(c.bookSubtitle || '来自Soul派对房的真实商业故事').trim() ||
|
||||
'来自Soul派对房的真实商业故事'
|
||||
})
|
||||
},
|
||||
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
|
||||
this.setData({ searchEnabled: app.globalData.features.searchEnabled })
|
||||
this._applyChaptersMpUi()
|
||||
return
|
||||
}
|
||||
const res = await app.getConfig()
|
||||
@@ -74,8 +91,10 @@ Page({
|
||||
if (!app.globalData.features) app.globalData.features = {}
|
||||
app.globalData.features.searchEnabled = searchEnabled
|
||||
this.setData({ searchEnabled })
|
||||
this._applyChaptersMpUi()
|
||||
} catch (e) {
|
||||
this.setData({ searchEnabled: true })
|
||||
this._applyChaptersMpUi()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -92,7 +111,6 @@ Page({
|
||||
totalSections = res.totalSections ?? 0
|
||||
fixedSections = res.fixedSections || []
|
||||
}
|
||||
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
||||
const fixedMap = {}
|
||||
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
|
||||
const appendixList = [
|
||||
@@ -100,22 +118,21 @@ Page({
|
||||
{ id: 'appendix-2', title: '附录2|创业者自检清单', mid: fixedMap['appendix-2'] },
|
||||
{ id: 'appendix-3', title: '附录3|本书提到的工具和资源', mid: fixedMap['appendix-3'] }
|
||||
]
|
||||
const getPartBadge = (p, idx) => {
|
||||
if (p.partLabel) return p.partLabel
|
||||
const title = p.title || ''
|
||||
const m = title.match(/^第(.+?)篇/)
|
||||
if (m) return m[1].trim()
|
||||
if (p.id === 'part-2026-daily') return '派'
|
||||
return numbers[idx] || String(idx + 1)
|
||||
}
|
||||
const bookData = parts.map((p, idx) => ({
|
||||
id: p.id,
|
||||
number: getPartBadge(p, idx),
|
||||
title: p.title,
|
||||
subtitle: p.subtitle || '',
|
||||
chapterCount: p.chapterCount || 0,
|
||||
chapters: [] // 展开时懒加载
|
||||
}))
|
||||
const bookData = parts.map((p, idx) => {
|
||||
let icon = String(p.icon || '').trim()
|
||||
if (icon && !isSafeImageSrc(icon)) icon = ''
|
||||
const iconEmoji = icon ? '' : partEmojiForBodyIndex(idx)
|
||||
return {
|
||||
id: p.id,
|
||||
icon,
|
||||
iconEmoji,
|
||||
title: p.title,
|
||||
subtitle: p.subtitle || '',
|
||||
chapterCount: p.chapterCount || 0,
|
||||
chapters: [],
|
||||
alwaysShow: (p.title || '').indexOf('每日派对干货') > -1
|
||||
}
|
||||
})
|
||||
app.globalData.totalSections = totalSections
|
||||
this.setData({
|
||||
bookData,
|
||||
@@ -194,6 +211,7 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this._applyChaptersMpUi()
|
||||
// 设置TabBar选中状态
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
const tabBar = this.getTabBar()
|
||||
@@ -233,7 +251,11 @@ Page({
|
||||
this.setData({ isLoggedIn, hasFullBook, purchasedSections, isVip })
|
||||
},
|
||||
|
||||
// 切换展开状态,展开时懒加载该篇章章节
|
||||
toggleBookCollapse() {
|
||||
trackClick('chapters', 'btn_click', '折叠书名')
|
||||
this.setData({ bookCollapsed: !this.data.bookCollapsed })
|
||||
},
|
||||
|
||||
async togglePart(e) {
|
||||
trackClick('chapters', 'tab_click', e.currentTarget.dataset.id || '篇章')
|
||||
const partId = e.currentTarget.dataset.id
|
||||
|
||||
@@ -34,18 +34,21 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 书籍信息卡 -->
|
||||
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}">
|
||||
<!-- 书籍信息卡(点击折叠/展开除"每日派对干货"外的篇章) -->
|
||||
<view class="book-info-card card-gradient" wx:if="{{!partsLoading}}" bindtap="toggleBookCollapse">
|
||||
<view class="book-icon">
|
||||
<view class="book-icon-inner"><icon name="book-open" size="56" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
<view class="book-info">
|
||||
<text class="book-title">一场SOUL的创业实验场</text>
|
||||
<text class="book-subtitle">来自Soul派对房的真实商业故事</text>
|
||||
<text class="book-title">{{chaptersBookTitle}}</text>
|
||||
<text class="book-subtitle">{{chaptersBookSubtitle}}</text>
|
||||
</view>
|
||||
<view class="book-count">
|
||||
<text class="count-value brand-color">{{totalSections}}</text>
|
||||
<text class="count-label">章节</text>
|
||||
<view class="book-right-area">
|
||||
<view class="book-count">
|
||||
<text class="count-value brand-color">{{totalSections}}</text>
|
||||
<text class="count-label">章节</text>
|
||||
</view>
|
||||
<text class="book-collapse-hint">{{bookCollapsed ? '展开 ▸' : '折叠 ▾'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -65,11 +68,13 @@
|
||||
|
||||
<!-- 篇章列表 -->
|
||||
<view class="part-list">
|
||||
<view class="part-item" wx:for="{{bookData}}" wx:key="id">
|
||||
<view class="part-item" wx:for="{{bookData}}" wx:key="id" wx:if="{{!bookCollapsed || item.alwaysShow}}">
|
||||
<!-- 篇章标题 -->
|
||||
<view class="part-header" bindtap="togglePart" data-id="{{item.id}}">
|
||||
<view class="part-left">
|
||||
<view class="part-icon">{{item.number}}</view>
|
||||
<image wx:if="{{item.icon}}" class="part-icon-img" src="{{item.icon}}" mode="aspectFill"/>
|
||||
<view wx:elif="{{item.iconEmoji}}" class="part-icon part-icon-emoji">{{item.iconEmoji}}</view>
|
||||
<view wx:else class="part-icon">{{item.title[0] || '篇'}}</view>
|
||||
<view class="part-info">
|
||||
<text class="part-title">{{item.title}}</text>
|
||||
<text class="part-subtitle">{{item.subtitle}}</text>
|
||||
|
||||
@@ -195,20 +195,11 @@
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.book-count {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.count-value {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.book-right-area { display: flex; flex-direction: column; align-items: flex-end; gap: 8rpx; }
|
||||
.book-count { text-align: right; }
|
||||
.count-value { font-size: 40rpx; font-weight: 700; display: block; }
|
||||
.count-label { font-size: 20rpx; color: rgba(255, 255, 255, 0.4); }
|
||||
.book-collapse-hint { font-size: 20rpx; color: #00CED1; opacity: 0.7; }
|
||||
|
||||
/* ===== 目录内容 ===== */
|
||||
.chapters-content {
|
||||
@@ -365,6 +356,18 @@
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* 与管理端 ChapterTree 篇头 emoji 一致 */
|
||||
.part-icon-emoji {
|
||||
font-size: 34rpx;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, #1e3a4a 0%, #0f172a 100%);
|
||||
border: 2rpx solid rgba(0, 206, 209, 0.35);
|
||||
}
|
||||
.part-icon-img {
|
||||
width: 64rpx; height: 64rpx; border-radius: 16rpx; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.part-info {
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
const app = getApp()
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { cleanSingleLineField } = require('../../utils/contentParser')
|
||||
const { navigateMpPath } = require('../../utils/mpNavigate.js')
|
||||
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
|
||||
|
||||
const DEFAULT_KARUO_LINK_AVATAR = '/assets/images/karuo-link-avatar.png'
|
||||
const KARUO_USER_ID = 'ogpTW5Wbbo9DfSyB3-xCWN6EGc-g'
|
||||
|
||||
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
|
||||
function isKaruoHostDuplicateName(displayName) {
|
||||
@@ -81,7 +86,20 @@ Page({
|
||||
searchEnabled: true,
|
||||
|
||||
// 审核模式:隐藏支付相关入口
|
||||
auditMode: false
|
||||
auditMode: false,
|
||||
|
||||
// mp_config.mpUi.homePage(后台系统设置 mpUi)
|
||||
mpUiLogoTitle: '卡若创业派对',
|
||||
mpUiLogoSubtitle: '来自派对房的真实故事',
|
||||
mpUiLinkKaruoText: '点击链接卡若',
|
||||
/** 最终展示:后台 linkKaruoAvatar 或本包默认卡若照片 */
|
||||
mpUiLinkKaruoDisplay: DEFAULT_KARUO_LINK_AVATAR,
|
||||
mpUiSearchPlaceholder: '搜索章节标题或内容...',
|
||||
mpUiBannerTag: '推荐',
|
||||
mpUiBannerReadMore: '点击阅读',
|
||||
mpUiSuperTitle: '超级个体',
|
||||
mpUiPickTitle: '精选推荐',
|
||||
mpUiLatestTitle: '最新新增'
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -107,6 +125,7 @@ Page({
|
||||
onShow() {
|
||||
console.log('[Index] onShow 触发')
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
this._applyHomeMpUi()
|
||||
|
||||
// 设置TabBar选中状态
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
@@ -301,27 +320,65 @@ Page({
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
_applyHomeMpUi() {
|
||||
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
|
||||
let linkKaruoAvatar = String(h.linkKaruoAvatar || h.linkKaruoImage || '').trim()
|
||||
if (linkKaruoAvatar && !isSafeImageSrc(linkKaruoAvatar)) linkKaruoAvatar = ''
|
||||
this.setData({
|
||||
mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对',
|
||||
mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事',
|
||||
mpUiLinkKaruoText: String(h.linkKaruoText || '点击链接卡若').trim() || '点击链接卡若',
|
||||
mpUiLinkKaruoDisplay: linkKaruoAvatar || DEFAULT_KARUO_LINK_AVATAR,
|
||||
mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...',
|
||||
mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐',
|
||||
mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读',
|
||||
mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体',
|
||||
mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐',
|
||||
mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增'
|
||||
})
|
||||
if (!linkKaruoAvatar) this._loadKaruoAvatarLazy()
|
||||
},
|
||||
|
||||
_loadKaruoAvatarLazy() {
|
||||
app.request({ url: `/api/miniprogram/user/profile?userId=${KARUO_USER_ID}`, silent: true, timeout: 3000 })
|
||||
.then(res => {
|
||||
if (res?.success && res.data?.avatar && isSafeImageSrc(res.data.avatar)) {
|
||||
this.setData({ mpUiLinkKaruoDisplay: res.data.avatar })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
|
||||
if (hasCachedFeatures) {
|
||||
this.setData({
|
||||
searchEnabled: app.globalData.features.searchEnabled,
|
||||
auditMode: app.globalData.auditMode || false
|
||||
})
|
||||
return
|
||||
if (!hasCachedFeatures) {
|
||||
const res = await app.getConfig()
|
||||
const features = (res && res.features) || (res && res.data && res.data.features) || {}
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
if (!app.globalData.features) app.globalData.features = {}
|
||||
app.globalData.features.searchEnabled = searchEnabled
|
||||
if (typeof features.matchEnabled === 'boolean') app.globalData.features.matchEnabled = features.matchEnabled
|
||||
if (typeof features.referralEnabled === 'boolean') app.globalData.features.referralEnabled = features.referralEnabled
|
||||
const mp = (res && res.mpConfig) || {}
|
||||
app.globalData.auditMode = !!mp.auditMode
|
||||
}
|
||||
const res = await app.getConfig()
|
||||
const features = (res && res.features) || {}
|
||||
const mp = (res && res.mpConfig) || {}
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
const auditMode = !!mp.auditMode
|
||||
if (!app.globalData.features) app.globalData.features = {}
|
||||
app.globalData.features.searchEnabled = searchEnabled
|
||||
app.globalData.auditMode = auditMode
|
||||
this.setData({ searchEnabled, auditMode })
|
||||
await app.getAuditMode()
|
||||
const searchEnabled = app.globalData.features?.searchEnabled !== false
|
||||
this.setData({
|
||||
searchEnabled,
|
||||
auditMode: app.globalData.auditMode || false
|
||||
})
|
||||
this._applyHomeMpUi()
|
||||
} catch (e) {
|
||||
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
|
||||
try {
|
||||
await app.getAuditMode()
|
||||
} catch (_) {}
|
||||
this.setData({
|
||||
searchEnabled: app.globalData.features?.searchEnabled !== false,
|
||||
auditMode: app.globalData.auditMode || false
|
||||
})
|
||||
this._applyHomeMpUi()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -414,6 +471,22 @@ Page({
|
||||
// 阻止弹窗内部点击事件冒泡到遮罩层
|
||||
stopPropagation() {},
|
||||
|
||||
preventMove() {},
|
||||
|
||||
onLeadPrivacyAuthorize() {
|
||||
this.onAgreePrivacyForLead()
|
||||
},
|
||||
|
||||
onDisagreePrivacyForLead() {
|
||||
if (app._privacyResolve) {
|
||||
try {
|
||||
app._privacyResolve({ event: 'disagree' })
|
||||
} catch (_) {}
|
||||
app._privacyResolve = null
|
||||
}
|
||||
this.setData({ showPrivacyModal: false })
|
||||
},
|
||||
|
||||
onLeadPhoneInput(e) {
|
||||
this.setData({ leadPhone: (e.detail.value || '').trim() })
|
||||
},
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
<text class="logo-text">派</text>
|
||||
</view>
|
||||
<view class="logo-info">
|
||||
<text class="logo-title-text">卡若创业派对</text>
|
||||
<text class="logo-subtitle">来自派对房的真实故事</text>
|
||||
<text class="logo-title-text">{{mpUiLogoTitle}}</text>
|
||||
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-right">
|
||||
<view class="contact-btn" bindtap="onLinkKaruo">
|
||||
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
|
||||
<text class="contact-name">点击链接卡若</text>
|
||||
<view class="header-right" wx:if="{{!auditMode}}">
|
||||
<view class="contact-btn" catchtap="onLinkKaruo" hover-class="none">
|
||||
<image class="contact-avatar" src="{{mpUiLinkKaruoDisplay}}" mode="aspectFill"/>
|
||||
<text class="contact-name">{{mpUiLinkKaruoText}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -29,7 +29,7 @@
|
||||
<view class="search-icon-wrap">
|
||||
<icon name="search" size="40" color="#8e8e93" customClass="search-icon-text"></icon>
|
||||
</view>
|
||||
<text class="search-placeholder">搜索章节标题或内容...</text>
|
||||
<text class="search-placeholder">{{mpUiSearchPlaceholder}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -38,26 +38,25 @@
|
||||
<!-- Banner 推荐卡片(优先 recommended API 第一条) -->
|
||||
<view class="banner-card" wx:if="{{bannerSection}}" bindtap="goToRead" data-id="{{bannerSection.id}}" data-mid="{{bannerSection.mid}}">
|
||||
<view class="banner-glow"></view>
|
||||
<view class="banner-tag">推荐</view>
|
||||
<view class="banner-tag">{{mpUiBannerTag}}</view>
|
||||
<view class="banner-title">{{bannerSection.title}}</view>
|
||||
<view class="banner-action">
|
||||
<text class="banner-action-text">点击阅读</text>
|
||||
<text class="banner-action-text">{{mpUiBannerReadMore}}</text>
|
||||
<icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon>
|
||||
</view>
|
||||
</view>
|
||||
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
|
||||
<view class="banner-glow"></view>
|
||||
<view class="banner-tag">推荐</view>
|
||||
<view class="banner-tag">{{mpUiBannerTag}}</view>
|
||||
<view class="banner-title">加载中...</view>
|
||||
<view class="banner-action"><text class="banner-action-text">点击阅读</text><icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon></view>
|
||||
<view class="banner-action"><text class="banner-action-text">{{mpUiBannerReadMore}}</text><icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon></view>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 超级个体:与匹配页一致,仅 VIP 横向列表(无首位特例) -->
|
||||
<view class="section" wx:if="{{!auditMode}}">
|
||||
<view class="section-header">
|
||||
<text class="section-title">超级个体</text>
|
||||
<text class="section-subtitle">获客入口</text>
|
||||
<text class="section-title">{{mpUiSuperTitle}}</text>
|
||||
</view>
|
||||
<!-- 加载中:骨架动画 -->
|
||||
<view wx:if="{{superMembersLoading}}" class="super-loading">
|
||||
@@ -100,7 +99,7 @@
|
||||
<!-- 精选推荐:默认 3 条,列表下三角展开更多(与最新新增一致) -->
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">精选推荐</text>
|
||||
<text class="section-title">{{mpUiPickTitle}}</text>
|
||||
</view>
|
||||
<view class="featured-list">
|
||||
<view
|
||||
@@ -134,7 +133,7 @@
|
||||
<!-- 最新新增(时间线样式;超过 5 条时在列表下方点小三角展开,展开后随页面滚动看全部) -->
|
||||
<view class="section" wx:if="{{latestChapters.length > 0}}">
|
||||
<view class="section-header latest-header">
|
||||
<text class="section-title">最新新增</text>
|
||||
<text class="section-title">{{mpUiLatestTitle}}</text>
|
||||
</view>
|
||||
<view class="timeline-wrap">
|
||||
<view class="timeline-line"></view>
|
||||
@@ -165,17 +164,22 @@
|
||||
<!-- 底部留白 -->
|
||||
<view class="bottom-space"></view>
|
||||
|
||||
<!-- 链接卡若 - 留资弹窗(未填手机/微信号时):一键获取 + 手动输入 -->
|
||||
<!-- 隐私授权(首页在 needPrivacy 列表内,需有遮罩否则无法完成 agree) -->
|
||||
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
|
||||
<view class="privacy-modal">
|
||||
<text class="privacy-title">温馨提示</text>
|
||||
<text class="privacy-desc">使用手机号能力前,请先同意《用户隐私保护指引》</text>
|
||||
<button id="agree-privacy-btn" class="privacy-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onLeadPrivacyAuthorize">同意</button>
|
||||
<view class="privacy-cancel" bindtap="onDisagreePrivacyForLead">拒绝</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 链接卡若 - 留资弹窗 -->
|
||||
<view class="lead-mask" wx:if="{{showLeadModal}}" catchtap="closeLeadModal">
|
||||
<!-- 使用 catchtap="stopPropagation" 阻止内部点击冒泡到遮罩层,避免点击输入框时弹窗被关闭 -->
|
||||
<view class="lead-box" catchtap="stopPropagation">
|
||||
<text class="lead-title">留下联系方式</text>
|
||||
<text class="lead-desc">方便卡若与您联系</text>
|
||||
<button id="agree-lead-phone-btn" class="lead-get-phone-btn" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onGetPhoneNumberForLead" bindagreeprivacyauthorization="onAgreePrivacyForLead">一键获取手机号</button>
|
||||
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
|
||||
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
|
||||
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacyForLead">同意</button>
|
||||
</view>
|
||||
<button class="lead-get-phone-btn" open-type="getPhoneNumber" bindgetphonenumber="onGetPhoneNumberForLead">一键获取手机号</button>
|
||||
<text class="lead-divider">或手动输入</text>
|
||||
<view class="lead-input-wrap">
|
||||
<input class="lead-input" placeholder="请输入手机号" type="number" maxlength="11" value="{{leadPhone}}" bindinput="onLeadPhoneInput"/>
|
||||
|
||||
@@ -85,6 +85,10 @@
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
white-space: nowrap;
|
||||
max-width: 140rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo-title {
|
||||
@@ -963,6 +967,61 @@
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
/* ===== 隐私授权(与 avatar-nickname 对齐) ===== */
|
||||
.privacy-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.privacy-modal {
|
||||
width: 100%;
|
||||
max-width: 560rpx;
|
||||
background: #17212F;
|
||||
border-radius: 24rpx;
|
||||
padding: 48rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.privacy-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.privacy-desc {
|
||||
font-size: 28rpx;
|
||||
color: #94A3B8;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
.privacy-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
text-align: center;
|
||||
background: #5EEAD4;
|
||||
color: #000;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 16rpx;
|
||||
border: none;
|
||||
}
|
||||
.privacy-btn::after { border: none; }
|
||||
.privacy-cancel {
|
||||
margin-top: 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #64748B;
|
||||
}
|
||||
|
||||
/* ===== 链接卡若 - 留资弹窗 ===== */
|
||||
.lead-mask {
|
||||
position: fixed;
|
||||
@@ -971,7 +1030,7 @@
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 1000;
|
||||
z-index: 2100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -5,17 +5,28 @@
|
||||
*/
|
||||
|
||||
const app = getApp()
|
||||
const soulBridge = require('../../utils/soulBridge.js')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
|
||||
/** 是否视为未设置头像(勿用 includes('132'):微信 CDN 合法头像 URL 普遍含 /132/ 尺寸段) */
|
||||
function isMissingOrPlaceholderAvatar(avatarUrl, hasAvatarFromServer) {
|
||||
if (hasAvatarFromServer === true || hasAvatarFromServer === 1) return false
|
||||
const a = (avatarUrl || '').trim()
|
||||
if (!a) return true
|
||||
const u = a.toLowerCase()
|
||||
if (u.includes('default')) return true
|
||||
// 微信默认占位常见以 /0 结尾;/132/ 为正常尺寸路径,不能当作占位
|
||||
if (/\/0($|[?#])/.test(u)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// 默认匹配类型配置
|
||||
// 找伙伴:真正的匹配功能,匹配数据库中的真实用户
|
||||
// 资源对接:需要登录+购买章节才能使用,填写2项信息(我能帮到你什么、我需要什么帮助)
|
||||
// 导师顾问:跳转到存客宝添加微信
|
||||
// 团队招募:跳转到存客宝添加微信
|
||||
let MATCH_TYPES = [
|
||||
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: 'handshake', matchFromDB: true, showJoinAfterMatch: false },
|
||||
{ id: 'partner', label: '找伙伴', matchLabel: '找伙伴', icon: 'star', matchFromDB: true, showJoinAfterMatch: false },
|
||||
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: 'users', matchFromDB: true, showJoinAfterMatch: true, requirePurchase: true },
|
||||
{ id: 'mentor', label: '导师顾问', matchLabel: '导师顾问', icon: 'heart', matchFromDB: true, showJoinAfterMatch: true },
|
||||
{ id: 'team', label: '团队招募', matchLabel: '团队招募', icon: 'gamepad', matchFromDB: true, showJoinAfterMatch: true }
|
||||
@@ -227,19 +238,21 @@ Page({
|
||||
if (!userId) { callback(); return }
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
|
||||
const avatar = res?.data?.avatarUrl || app.globalData.userInfo?.avatarUrl || ''
|
||||
const isDefaultAvatar = !avatar || avatar.includes('default') || avatar.includes('132')
|
||||
if (isDefaultAvatar) {
|
||||
const d = res?.data || {}
|
||||
const avatar = (d.avatar || d.avatarUrl || app.globalData.userInfo?.avatar || app.globalData.userInfo?.avatarUrl || '').trim()
|
||||
const hasAvatarFlag = d.hasAvatar === true || d.hasAvatar === 1
|
||||
if (isMissingOrPlaceholderAvatar(avatar, hasAvatarFlag)) {
|
||||
wx.showModal({
|
||||
title: '完善头像',
|
||||
content: '请先设置头像后再使用匹配功能',
|
||||
confirmText: '去设置',
|
||||
cancelText: '取消',
|
||||
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
const phone = (res?.data?.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
||||
const wechat = (res?.data?.wechatId || res?.data?.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
||||
const phone = (d.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
||||
const wechat = (d.wechatId || d.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
||||
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
|
||||
callback()
|
||||
return
|
||||
@@ -414,6 +427,16 @@ Page({
|
||||
|
||||
// 开始匹配 - 只匹配数据库中的真实用户
|
||||
async startMatch() {
|
||||
const uidEarly = app.globalData.userInfo?.id
|
||||
if (!uidEarly) {
|
||||
wx.showModal({
|
||||
title: '需要登录',
|
||||
content: '找伙伴匹配需登录账号,请先登录',
|
||||
confirmText: '去登录',
|
||||
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) },
|
||||
})
|
||||
return
|
||||
}
|
||||
this.setData({
|
||||
isMatching: true,
|
||||
matchAttempts: 0,
|
||||
@@ -425,55 +448,52 @@ Page({
|
||||
this.setData({ matchAttempts: this.data.matchAttempts + 1 })
|
||||
}, 1000)
|
||||
|
||||
// 从数据库获取真实用户匹配
|
||||
// 从数据库获取真实用户匹配(带上手机/微信写入 match_records,与流量池运营对齐)
|
||||
let matchedUser = null
|
||||
let matchProfileError = ''
|
||||
let matchFailHint = ''
|
||||
const uid = app.globalData.userInfo?.id || ''
|
||||
const phoneForMatch = (this.data.phoneNumber || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
||||
const wechatForMatch = (this.data.wechatId || wx.getStorageSync('user_wechat') || '').trim()
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/match/users', silent: true,
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/match/users',
|
||||
silent: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
matchType: this.data.selectedType,
|
||||
userId: app.globalData.userInfo?.id || ''
|
||||
}
|
||||
userId: uid,
|
||||
phone: phoneForMatch || undefined,
|
||||
wechatId: wechatForMatch || undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (res.success && res.data) {
|
||||
matchedUser = res.data
|
||||
console.log('[Match] 从数据库匹配到用户:', matchedUser.nickname)
|
||||
} else if (res && !res.success) {
|
||||
matchFailHint = res.message || res.error || ''
|
||||
if (res.code === 'QUOTA_EXCEEDED') {
|
||||
matchFailHint = matchFailHint || '今日免费次数已用完,可购买额外匹配次数后再试'
|
||||
} else if (res.code === 'NO_USERS') {
|
||||
matchFailHint = matchFailHint || '当前流量池暂无可匹配用户,可稍后再试;补全档案后匹配范围通常更大。'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Match] 数据库匹配失败:', e)
|
||||
const r = e.response || {}
|
||||
if (r.errorCode === 'ERR_PROFILE_INCOMPLETE') {
|
||||
matchProfileError = r.message || '请先完善手机号或微信号后再发起匹配'
|
||||
}
|
||||
matchFailHint = (e && e.message) ? String(e.message) : '网络异常,请稍后重试'
|
||||
}
|
||||
|
||||
|
||||
// 延迟显示结果(模拟匹配过程)
|
||||
const delay = Math.random() * 2000 + 2000
|
||||
setTimeout(() => {
|
||||
clearInterval(timer)
|
||||
|
||||
if (matchProfileError) {
|
||||
this.setData({ isMatching: false })
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: matchProfileError,
|
||||
confirmText: '去完善',
|
||||
showCancel: false,
|
||||
success: (mr) => {
|
||||
if (mr.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 如果没有匹配到用户,提示用户
|
||||
if (!matchedUser) {
|
||||
this.setData({ isMatching: false })
|
||||
wx.showModal({
|
||||
title: '暂无匹配',
|
||||
content: '当前暂无合适的匹配用户,请稍后再试',
|
||||
content: matchFailHint || '当前暂无合适的匹配用户,请稍后再试',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
@@ -484,13 +504,6 @@ Page({
|
||||
const newCount = this.data.todayMatchCount + 1
|
||||
const matchesRemaining = this.data.hasFullBook ? 999999 : Math.max(0, this.data.totalMatchesAllowed - newCount)
|
||||
|
||||
// 规范化 commonInterests:emoji 或无效 icon 转为 SVG 图标名
|
||||
const normalized = matchedUser.commonInterests?.map(item => ({
|
||||
...item,
|
||||
icon: /^[a-z0-9-]+$/i.test(item.icon) ? item.icon : 'target'
|
||||
})) || []
|
||||
if (normalized.length) matchedUser = { ...matchedUser, commonInterests: normalized }
|
||||
|
||||
this.setData({
|
||||
isMatching: false,
|
||||
currentMatch: matchedUser,
|
||||
@@ -525,7 +538,7 @@ Page({
|
||||
}
|
||||
}
|
||||
})
|
||||
// 匹配后规则:引导填写 MBTI/行业信息
|
||||
// 匹配后规则:资料未齐时提示补全(服务端 profile 合并,见 ruleEngine)
|
||||
checkAndExecute('after_match', this)
|
||||
} catch (e) {
|
||||
console.log('上报匹配失败:', e)
|
||||
@@ -652,27 +665,7 @@ Page({
|
||||
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
const r = e.response || {}
|
||||
if (r.errorCode === 'ERR_REQUIRE_PURCHASE') {
|
||||
wx.showModal({
|
||||
title: '需要先购买',
|
||||
content: r.message || '请先购买章节或解锁全书后再使用资源对接',
|
||||
confirmText: '去购买',
|
||||
cancelText: '取消',
|
||||
success: (mr) => { if (mr.confirm) this.goToChapters() }
|
||||
})
|
||||
} else if (r.errorCode === 'ERR_PROFILE_INCOMPLETE') {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: r.message || '请先完善资料',
|
||||
confirmText: '去完善',
|
||||
success: (mr) => {
|
||||
if (mr.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: e.message || '网络异常,请重试', icon: 'none' })
|
||||
}
|
||||
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
|
||||
} finally {
|
||||
this.setData({ isJoining: false })
|
||||
}
|
||||
@@ -710,7 +703,9 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
const referralCode = soulBridge.getReferralCodeForPay(app)
|
||||
// 邀请码:与章节支付一致,写入订单便于分销归属与对账
|
||||
const referralCode = wx.getStorageSync('referral_code') || ''
|
||||
// 调用支付接口购买匹配次数
|
||||
const res = await app.request('/api/miniprogram/pay', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
@@ -723,11 +718,17 @@ Page({
|
||||
referralCode: referralCode || undefined
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (res.success && res.data?.payParams) {
|
||||
await soulBridge.requestWxJsapiPayment(res.data.payParams)
|
||||
await soulBridge.syncOrderStatusQuery(app, res.data.orderSn)
|
||||
|
||||
// 调用微信支付
|
||||
await new Promise((resolve, reject) => {
|
||||
wx.requestPayment({
|
||||
...res.data.payParams,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
// 支付成功,增加匹配次数
|
||||
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
|
||||
wx.setStorageSync('extra_match_count', extraMatches)
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
<!-- 用户卡片 -->
|
||||
<view class="match-card">
|
||||
<view class="card-header">
|
||||
<image class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
|
||||
<image wx:if="{{currentMatch.avatar}}" class="match-avatar" src="{{currentMatch.avatar}}" mode="aspectFill"></image>
|
||||
<view wx:else class="match-avatar match-avatar-fallback"><text>{{currentMatch.nickname ? currentMatch.nickname[0] : '?'}}</text></view>
|
||||
<view class="match-info">
|
||||
<text class="match-name">{{currentMatch.nickname}}</text>
|
||||
<view class="match-tags">
|
||||
|
||||
@@ -445,6 +445,20 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.match-avatar-fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 206, 209, 0.15);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.match-avatar-fallback text {
|
||||
font-size: 48rpx;
|
||||
font-weight: 600;
|
||||
color: #00CED1;
|
||||
}
|
||||
|
||||
.match-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -5,22 +5,74 @@
|
||||
* mbti, region, industry, position, businessScale, skills,
|
||||
* storyBestMonth→bestMonth, storyAchievement→achievement, storyTurning→turningPoint,
|
||||
* helpOffer→canHelp, helpNeed→needHelp
|
||||
* 点头像:若后台 persons.user_id 已绑定则带 ckbLeadToken,走存客宝 CKBLead(与阅读页 @ 一致)
|
||||
* 点头像:登录后依次校验本人头像(非默认)、微信号、绑定手机号,再弹「链接「昵称」」;有 ckbLeadToken 走人物获客计划,否则走全局留资
|
||||
*/
|
||||
const app = getApp()
|
||||
const soulBridge = require('../../utils/soulBridge.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
|
||||
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
|
||||
|
||||
Page({
|
||||
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true },
|
||||
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false },
|
||||
|
||||
onLoad(options) {
|
||||
wx.showShareMenu({ withShareTimeline: true })
|
||||
const sb = app.globalData.statusBarHeight || 44
|
||||
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44 })
|
||||
const myId = app.globalData.userInfo?.id
|
||||
const isOwnProfile = !!(options.id && myId && String(options.id) === String(myId))
|
||||
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44, isOwnProfile })
|
||||
if (options.id) this.loadMember(options.id)
|
||||
},
|
||||
|
||||
/** 本人名片:去完整编辑资料(单页) */
|
||||
goMyProfileEdit() {
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1' })
|
||||
},
|
||||
|
||||
async loadMember(id) {
|
||||
try {
|
||||
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
|
||||
} catch (_) {}
|
||||
const myId = app.globalData.userInfo?.id
|
||||
const isOwn = !!(myId && id != null && String(id) === String(myId))
|
||||
if (isOwn && app.globalData.isLoggedIn && myId) {
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: `/api/miniprogram/user/profile?userId=${encodeURIComponent(String(id))}`,
|
||||
silent: true
|
||||
})
|
||||
if (res?.success && res.data) {
|
||||
const d = res.data
|
||||
this.setData({
|
||||
member: this.enrichAndFormat({
|
||||
id: d.id,
|
||||
nickname: d.nickname,
|
||||
name: d.nickname,
|
||||
avatar: d.avatar,
|
||||
phone: d.phone,
|
||||
wechatId: d.wechatId || d.wechat_id,
|
||||
isVip: !!app.globalData.isVip,
|
||||
mbti: d.mbti,
|
||||
region: d.region,
|
||||
industry: d.industry,
|
||||
position: d.position,
|
||||
businessScale: d.businessScale || d.business_scale,
|
||||
skills: d.skills,
|
||||
storyBestMonth: d.storyBestMonth || d.story_best_month,
|
||||
storyAchievement: d.storyAchievement || d.story_achievement,
|
||||
storyTurning: d.storyTurning || d.story_turning,
|
||||
helpOffer: d.helpOffer || d.help_offer,
|
||||
helpNeed: d.helpNeed || d.help_need,
|
||||
projectIntro: d.projectIntro || d.project_intro,
|
||||
ckbLeadToken: d.ckbLeadToken || d.ckb_lead_token
|
||||
}),
|
||||
loading: false
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/vip/members?id=${id}`, silent: true })
|
||||
if (res?.success && res.data) {
|
||||
@@ -65,10 +117,20 @@ Page({
|
||||
|
||||
enrichAndFormat(raw) {
|
||||
const e = (v) => this._emptyIfPlaceholder(v)
|
||||
const rawAv = raw.avatar || raw.vipAvatar || raw.vip_avatar || ''
|
||||
let dispAv = isSafeImageSrc(rawAv) ? String(rawAv).trim() : ''
|
||||
if (!dispAv) {
|
||||
dispAv = resolveAvatarWithMbti(
|
||||
'',
|
||||
raw.mbti,
|
||||
app.globalData.mbtiAvatarsMap || {},
|
||||
app.globalData.baseUrl || ''
|
||||
)
|
||||
}
|
||||
const merged = {
|
||||
id: raw.id,
|
||||
name: raw.nickname || raw.name || raw.vipName || raw.vip_name || '创业者',
|
||||
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
|
||||
avatar: dispAv,
|
||||
isVip: !!(raw.isVip || raw.is_vip),
|
||||
mbti: e(raw.mbti),
|
||||
region: e(raw.region),
|
||||
@@ -153,50 +215,233 @@ Page({
|
||||
return false
|
||||
},
|
||||
|
||||
/** 链接前:头像需非空且非默认图;微信号需已填写(与 app._needsAvatarNickname 中头像规则一致) */
|
||||
_hasCustomAvatarForLink(u) {
|
||||
const avatar = (u && (u.avatar || u.avatarUrl) || '').trim()
|
||||
return !!avatar && !avatar.includes('default')
|
||||
},
|
||||
|
||||
_hasWechatFilledForLink(u) {
|
||||
const w = (u && (u.wechatId || u.wechat_id) || wx.getStorageSync('user_wechat') || '').trim()
|
||||
return w.length > 0
|
||||
},
|
||||
|
||||
/** 拉取最新 profile 写回 globalData,返回合并后的访客资料片段 */
|
||||
async _refreshVisitorProfileForLink() {
|
||||
const base = app.globalData.userInfo
|
||||
if (!base?.id) return base || {}
|
||||
try {
|
||||
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${base.id}`, silent: true })
|
||||
if (profileRes?.success && profileRes.data) {
|
||||
const d = profileRes.data
|
||||
const updated = { ...app.globalData.userInfo }
|
||||
if (d.avatar != null && String(d.avatar).trim()) updated.avatar = String(d.avatar).trim()
|
||||
if (d.wechatId != null) updated.wechatId = d.wechatId
|
||||
if (d.wechat_id != null) updated.wechat_id = d.wechat_id
|
||||
app.globalData.userInfo = updated
|
||||
wx.setStorageSync('userInfo', updated)
|
||||
if (d.wechatId) wx.setStorageSync('user_wechat', String(d.wechatId).trim())
|
||||
return updated
|
||||
}
|
||||
} catch (e) {}
|
||||
return base
|
||||
},
|
||||
|
||||
async _ensureVisitorReadyForMemberLink() {
|
||||
const u = await this._refreshVisitorProfileForLink()
|
||||
const avatarOk = this._hasCustomAvatarForLink(u)
|
||||
const wechatOk = this._hasWechatFilledForLink(u)
|
||||
if (avatarOk && wechatOk) return true
|
||||
const miss = []
|
||||
if (!avatarOk) miss.push('头像')
|
||||
if (!wechatOk) miss.push('微信号')
|
||||
wx.showModal({
|
||||
title: '补全本人档案',
|
||||
content: `链接前请补全本人${miss.join('与')},便于对方识别与安全对接。`,
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
|
||||
})
|
||||
return false
|
||||
},
|
||||
|
||||
/** 链接前:必须已绑定大陆手机号(与留资接口校验一致) */
|
||||
async _ensurePhoneBoundForLink(myUserId) {
|
||||
const { phone } = await this._resolveLeadPhoneWechat(myUserId)
|
||||
if (phone && /^1[3-9]\d{9}$/.test(phone)) return true
|
||||
wx.showModal({
|
||||
title: '请先绑定手机号',
|
||||
content: '链接对方前需绑定本人手机号,便于跟进与对接。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '取消',
|
||||
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
|
||||
})
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* 点头像:有存客宝人物 token 时优先 POST /api/miniprogram/ckb/lead(与阅读页 @ 同链路,匹配 persons.ckb_api_key 计划)
|
||||
* 否则:解锁后复制微信/手机号并引导
|
||||
* 点头像:登录 → 头像+微信号 → 手机号 均校验通过后弹窗说明;确认后 POST ckb/lead。
|
||||
* 有 ckbLeadToken 走人物计划;无 token 走全局留资。对方已公开联系方式时可取消后在下方自行添加。
|
||||
*/
|
||||
startLinkFlow() {
|
||||
async startLinkFlow() {
|
||||
if (this.data.isOwnProfile) return
|
||||
const member = this.data.member
|
||||
if (!member) return
|
||||
const leadTok = (member.ckbLeadToken || '').trim()
|
||||
if (leadTok) {
|
||||
const nickname = ((member.name || 'TA').trim() || 'TA')
|
||||
const nickname = (member.name || 'TA').trim() || 'TA'
|
||||
trackClick('member_detail', 'avatar_click', '链接头像_' + (member.id || ''))
|
||||
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showModal({
|
||||
title: '添加好友',
|
||||
content: `是否通过获客计划联系 ${nickname}?提交后将按对方在存客宝后台配置的计划执行。`,
|
||||
confirmText: '确定',
|
||||
title: `链接「${nickname}」`,
|
||||
content: '请先登录后再发起链接。',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) this._doCkbLeadSubmit(leadTok, nickname)
|
||||
}
|
||||
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
if (member.wechatRaw || member.wechatDisplay) {
|
||||
if (!this._ensureUnlockedForLink('wechat')) return
|
||||
const m = this.data.member
|
||||
if (m.wechatFull) this._copyAndGuideWechat(m.wechatFull)
|
||||
return
|
||||
|
||||
const myUserId = app.globalData.userInfo.id
|
||||
wx.showLoading({ title: '请稍候', mask: true })
|
||||
let profileOk = false
|
||||
let phoneOk = false
|
||||
try {
|
||||
profileOk = await this._ensureVisitorReadyForMemberLink()
|
||||
if (profileOk) phoneOk = await this._ensurePhoneBoundForLink(myUserId)
|
||||
} finally {
|
||||
wx.hideLoading()
|
||||
}
|
||||
if (member.contactRaw || member.contactDisplay) {
|
||||
if (!this._ensureUnlockedForLink('contact')) return
|
||||
const m = this.data.member
|
||||
if (m.contactFull) this._copyAndGuidePhone(m.contactFull)
|
||||
return
|
||||
}
|
||||
wx.showToast({ title: '暂未公开联系方式', icon: 'none' })
|
||||
if (!profileOk || !phoneOk) return
|
||||
|
||||
const leadTok = (member.ckbLeadToken || '').trim()
|
||||
const content = leadTok
|
||||
? `确定后提交联系方式,平台将按对方配置跟进;智能助手与人工协同协助对接。`
|
||||
: `智能助手与人工会协同跟进,协助您对接「${nickname}」。\n\n若对方已公开手机或微信,可先点「取消」,在页面下方自行添加。`
|
||||
|
||||
wx.showModal({
|
||||
title: `链接「${nickname}」`,
|
||||
content,
|
||||
confirmText: '确定链接',
|
||||
cancelText: '取消',
|
||||
success: (r) => {
|
||||
if (!r.confirm) return
|
||||
if (leadTok) this._doCkbLeadSubmit(leadTok, nickname, member.id, nickname)
|
||||
else this._doGlobalMemberLeadSubmit(member)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** 与阅读页 @mention 同链路:soulBridge.submitCkbLead */
|
||||
async _doCkbLeadSubmit(targetUserId, targetNickname) {
|
||||
await soulBridge.submitCkbLead(app, {
|
||||
targetUserId,
|
||||
targetNickname,
|
||||
source: 'member_detail_avatar',
|
||||
phoneModalContent: '请先填写手机号(必填),以便对方通过获客计划联系您'
|
||||
})
|
||||
/** 无人物 token 时:全局留资,便于运营侧主动加好友并协助链接该会员 */
|
||||
async _doGlobalMemberLeadSubmit(member) {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
|
||||
const myUserId = app.globalData.userInfo.id
|
||||
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
wx.showModal({
|
||||
title: '补全手机号',
|
||||
content: '请填写手机号(必填),便于工作人员联系您、协助链接该超级个体。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/ckb/lead',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId: myUserId,
|
||||
phone: phone || undefined,
|
||||
wechatId: wechatId || undefined,
|
||||
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
|
||||
targetNickname: '',
|
||||
targetMemberId: member.id || undefined,
|
||||
targetMemberName: (member.name || '').trim() || undefined,
|
||||
source: 'member_detail_global',
|
||||
}
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功,请留意工作人员联系', icon: 'success' })
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
async _resolveLeadPhoneWechat(myUserId) {
|
||||
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
||||
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
try {
|
||||
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
|
||||
if (profileRes?.success && profileRes.data) {
|
||||
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
||||
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return { phone, wechatId }
|
||||
},
|
||||
|
||||
/** 与 read 页 _doMentionAddFriend 一致:targetUserId = Person.token;可选带超级个体 userId 写入留资 params */
|
||||
async _doCkbLeadSubmit(targetUserId, targetNickname, targetMemberId, targetMemberName) {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录后再添加好友',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
const myUserId = app.globalData.userInfo.id
|
||||
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
wx.showModal({
|
||||
title: '补全手机号',
|
||||
content: '请填写手机号(必填),便于对方通过获客计划联系您。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/ckb/lead',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId: myUserId,
|
||||
phone: phone || undefined,
|
||||
wechatId: wechatId || undefined,
|
||||
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
|
||||
targetUserId,
|
||||
targetNickname: targetNickname || undefined,
|
||||
targetMemberId: targetMemberId || undefined,
|
||||
targetMemberName: targetMemberName || undefined,
|
||||
source: 'member_detail_avatar'
|
||||
}
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
_ensureUnlockedForLink(field) {
|
||||
@@ -321,11 +566,56 @@ Page({
|
||||
goToVip() { wx.navigateTo({ url: '/pages/vip/vip' }) },
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
/**
|
||||
* 分享标题:姓名|MBTI(有则加)|擅长;无擅长时用个人故事或职业一行。总长控制避免微信卡片截断难看。
|
||||
*/
|
||||
_buildMemberShareTitle(maxLen = 56) {
|
||||
const m = this.data.member
|
||||
if (!m) return '卡若创业派对 · 超级个体'
|
||||
|
||||
const clip = (s, n) => {
|
||||
if (!s) return ''
|
||||
const t = String(s).replace(/\s+/g, ' ').trim()
|
||||
return t.length <= n ? t : t.slice(0, n - 1) + '…'
|
||||
}
|
||||
|
||||
const name = clip((m.name || '创业者').trim() || '创业者', 14)
|
||||
const mbti = (m.mbti || '').trim()
|
||||
const skills = (m.skills || '').trim()
|
||||
const achievement = (m.achievement || '').trim()
|
||||
const bestMonth = (m.bestMonth || '').trim()
|
||||
const turning = (m.turningPoint || '').trim()
|
||||
const story = achievement || bestMonth || turning || ''
|
||||
const jobLine = [m.industry, m.position].filter(Boolean).join('·')
|
||||
|
||||
const sep = '|'
|
||||
const nameSeg = name
|
||||
const mbtiSeg = mbti ? clip(mbti, 8) : ''
|
||||
const usedBase = nameSeg.length + (mbtiSeg ? sep.length + mbtiSeg.length : 0)
|
||||
const willTail = !!(skills || story || jobLine || m.region)
|
||||
const restBudget = Math.max(6, maxLen - usedBase - (willTail ? sep.length : 0))
|
||||
|
||||
let tail = ''
|
||||
if (skills) tail = clip(skills, restBudget)
|
||||
else if (story) tail = clip(story, restBudget)
|
||||
else if (jobLine) tail = clip(jobLine, restBudget)
|
||||
else if (m.region) tail = clip(m.region, restBudget)
|
||||
|
||||
let title = nameSeg
|
||||
if (mbtiSeg) title += sep + mbtiSeg
|
||||
if (tail) title += sep + tail
|
||||
|
||||
title = clip(title.replace(/\s+/g, ' '), maxLen)
|
||||
if (!title || title.length < 3) title = clip(`${nameSeg}${sep}超级个体`, maxLen)
|
||||
return title
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const ref = app.getMyReferralCode()
|
||||
const id = this.data.member?.id
|
||||
const title = this._buildMemberShareTitle(64)
|
||||
return {
|
||||
title: '卡若创业派对 - 创业者详情',
|
||||
title,
|
||||
path: id && ref ? `/pages/member-detail/member-detail?id=${id}&ref=${ref}` : id ? `/pages/member-detail/member-detail?id=${id}` : ref ? `/pages/member-detail/member-detail?ref=${ref}` : '/pages/member-detail/member-detail'
|
||||
}
|
||||
},
|
||||
@@ -333,7 +623,13 @@ Page({
|
||||
onShareTimeline() {
|
||||
const ref = app.getMyReferralCode()
|
||||
const id = this.data.member?.id
|
||||
const m = this.data.member
|
||||
const q = id ? (ref ? `id=${id}&ref=${ref}` : `id=${id}`) : (ref ? `ref=${ref}` : '')
|
||||
return { title: '卡若创业派对 - 创业者详情', query: q }
|
||||
const title = this._buildMemberShareTitle(56)
|
||||
const res = { title, query: q }
|
||||
if (m && m.avatar && /^https?:\/\//.test(String(m.avatar))) {
|
||||
res.imageUrl = m.avatar
|
||||
}
|
||||
return res
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
<!-- 卡若创业派对 - 超级个体详情(居中头像区 + 低调联系方式 + 信息卡) -->
|
||||
<!-- 卡若创业派对 - 超级个体详情(点头像申请对接 + 有则展示联系方式 + 信息卡) -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack">
|
||||
<icon name="chevron-left" size="44" color="#5EEAD4" customClass="nav-icon"></icon>
|
||||
</view>
|
||||
<text class="nav-title">个人资料</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
<text class="nav-title">{{isOwnProfile ? '我的名片' : '个人资料'}}</text>
|
||||
<view class="nav-edit-wrap" wx:if="{{isOwnProfile}}" bindtap="goMyProfileEdit">
|
||||
<text class="nav-edit-text">编辑</text>
|
||||
</view>
|
||||
<view class="nav-placeholder" wx:else></view>
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<scroll-view scroll-y class="scroll-wrap" style="height: calc(100vh - {{navBarTotalPx}}px);" wx:if="{{member}}">
|
||||
<!-- 首屏:居中头像 + 昵称 + 标签;点头像走添加微信引导(无独立「链接 TA」大按钮) -->
|
||||
<!-- 首屏:点头像申请对接;超级个体未填手机/微信则整块不展示联系方式 -->
|
||||
<view class="shell">
|
||||
<view class="shell-glow"></view>
|
||||
<view class="hero-profile">
|
||||
<view class="hero-avatar-block" bindtap="startLinkFlow" hover-class="hero-avatar-block-hover" hover-stay-time="80">
|
||||
<view
|
||||
class="hero-avatar-block"
|
||||
wx:if="{{!isOwnProfile}}"
|
||||
bindtap="startLinkFlow"
|
||||
hover-class="hero-avatar-block-hover"
|
||||
hover-stay-time="80"
|
||||
>
|
||||
<view class="avatar-outer">
|
||||
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
|
||||
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
|
||||
@@ -30,10 +39,39 @@
|
||||
<text>{{member.region}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="avatar-link-hint-one">点头像 · 申请对接</text>
|
||||
</view>
|
||||
<view
|
||||
class="hero-avatar-block hero-avatar-block-self"
|
||||
wx:else
|
||||
bindtap="goMyProfileEdit"
|
||||
hover-class="hero-avatar-block-hover"
|
||||
hover-stay-time="80"
|
||||
>
|
||||
<view class="avatar-outer">
|
||||
<view class="avatar-wrap {{member.isVip ? 'vip-ring' : ''}}">
|
||||
<image class="avatar-img" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
|
||||
<view class="avatar-ph" wx:else><text>{{(member.name && member.name[0]) || '创'}}</text></view>
|
||||
</view>
|
||||
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
|
||||
</view>
|
||||
<text class="profile-name">{{member.name}}</text>
|
||||
<view class="profile-tags profile-tags-modern" wx:if="{{member.mbti || member.region}}">
|
||||
<text class="tag tag-mbti" wx:if="{{member.mbti}}">{{member.mbti}}</text>
|
||||
<view class="tag tag-region" wx:if="{{member.region}}">
|
||||
<icon name="map-pin" size="22" color="currentColor" customClass="pin-icon"></icon>
|
||||
<text>{{member.region}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="avatar-link-hint-one self-hint">这是我的超级个体名片 · 可转发分享 · 点头像去编辑</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="contact-rows contact-rows-subtle">
|
||||
<view
|
||||
class="contact-rows contact-rows-subtle"
|
||||
wx:if="{{member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}"
|
||||
>
|
||||
<view class="contact-sec-label">联系方式</view>
|
||||
<view
|
||||
class="link-chip link-chip-subtle {{member.contactUnlocked ? 'link-chip-open' : ''}}"
|
||||
wx:if="{{member.contactRaw || member.contactDisplay}}"
|
||||
@@ -69,10 +107,6 @@
|
||||
<icon wx:if="{{!member.wechatUnlocked}}" name="chevron-right" size="22" color="rgba(100,116,139,0.8)" customClass="lc-arr"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="link-empty link-empty-subtle" wx:if="{{!(member.contactRaw || member.contactDisplay) && !(member.wechatRaw || member.wechatDisplay)}}">
|
||||
<text class="link-empty-txt">暂未公开联系方式</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -43,6 +43,23 @@
|
||||
.nav-placeholder {
|
||||
width: 72rpx;
|
||||
}
|
||||
.nav-edit-wrap {
|
||||
min-width: 72rpx;
|
||||
padding: 12rpx 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.nav-edit-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #5eead4;
|
||||
}
|
||||
.self-hint {
|
||||
font-size: 22rpx !important;
|
||||
line-height: 1.45;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.scroll-wrap {
|
||||
box-sizing: border-box;
|
||||
@@ -75,7 +92,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-bottom: 8rpx;
|
||||
padding-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.hero-avatar-block {
|
||||
@@ -88,6 +105,27 @@
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.avatar-link-hint-one {
|
||||
margin-top: 16rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 400;
|
||||
color: rgba(148, 163, 184, 0.88);
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.contact-sec-label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: rgba(148, 163, 184, 0.7);
|
||||
letter-spacing: 4rpx;
|
||||
margin-bottom: 8rpx;
|
||||
padding-left: 6rpx;
|
||||
}
|
||||
|
||||
.contact-rows {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -98,8 +136,8 @@
|
||||
}
|
||||
|
||||
.contact-rows-subtle {
|
||||
margin-top: 24rpx;
|
||||
padding-top: 24rpx;
|
||||
margin-top: 28rpx;
|
||||
padding-top: 28rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
@@ -153,7 +191,7 @@
|
||||
font-weight: 800;
|
||||
padding: 6rpx 12rpx;
|
||||
border-radius: 12rpx;
|
||||
z-index: 2;
|
||||
z-index: 5;
|
||||
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
@@ -273,26 +311,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.link-empty {
|
||||
padding: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border: 1rpx dashed rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.link-empty-subtle {
|
||||
padding: 16rpx 8rpx;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.link-empty-txt {
|
||||
font-size: 24rpx;
|
||||
color: #64748b;
|
||||
}
|
||||
.link-empty-subtle .link-empty-txt {
|
||||
font-size: 22rpx;
|
||||
color: rgba(100, 116, 139, 0.75);
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
@@ -8,27 +8,8 @@ const app = getApp()
|
||||
const { formatStatNum } = require('../../utils/util.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { cleanSingleLineField } = require('../../utils/contentParser.js')
|
||||
|
||||
/** 与 referral 一致:提现需已绑定微信号(便于到账核对) */
|
||||
function hasWechatIdBound() {
|
||||
const ui = app.globalData.userInfo
|
||||
return !!(ui && (ui.wechat || ui.wechatId || wx.getStorageSync('user_wechat')))
|
||||
}
|
||||
|
||||
/** 是否视为「单章解锁」类订单(排除全书/VIP 等聚合商品名) */
|
||||
function isSectionUnlockOrder(o) {
|
||||
const name = String(o.product_name || o.title || '').trim()
|
||||
if (/全书|全書|VIP|会员|年费|买断/.test(name)) return false
|
||||
const pid = String(o.product_id || o.section_id || o.sectionId || '')
|
||||
if (/^\d+\.\d+/.test(pid)) return true
|
||||
return !!pid && pid.length > 0
|
||||
}
|
||||
|
||||
function parseOrderTimeMs(o) {
|
||||
const raw = o.created_at || o.createdAt || o.pay_time || 0
|
||||
const t = new Date(raw).getTime()
|
||||
return Number.isFinite(t) ? t : 0
|
||||
}
|
||||
const { navigateMpPath } = require('../../utils/mpNavigate.js')
|
||||
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -39,9 +20,11 @@ Page({
|
||||
// 用户状态
|
||||
isLoggedIn: false,
|
||||
userInfo: null,
|
||||
/** 我的页头像展示:微信头像或 MBTI 映射图 */
|
||||
profileAvatarDisplay: '',
|
||||
|
||||
// 统计数据
|
||||
totalSections: 0, // 来自 app.getTotalSections() 或 dashboard-stats
|
||||
totalSections: 62,
|
||||
readCount: 0,
|
||||
referralCount: 0,
|
||||
earnings: '-',
|
||||
@@ -103,10 +86,12 @@ Page({
|
||||
// 我的余额
|
||||
walletBalanceText: '--',
|
||||
|
||||
// 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开)
|
||||
unlockedChaptersFull: [],
|
||||
displayUnlockedChapters: [],
|
||||
unlockedExpanded: false,
|
||||
// mp_config.mpUi.myPage(后台可改文案/跳转)
|
||||
mpUiCardLabel: '名片',
|
||||
mpUiVipLabelVip: '会员中心',
|
||||
mpUiVipLabelGuest: '成为会员',
|
||||
mpUiReadStatLabel: '已读章节',
|
||||
mpUiRecentTitle: '最近阅读',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -136,11 +121,37 @@ Page({
|
||||
}
|
||||
}
|
||||
this.initUserStatus()
|
||||
// 登录过期后用户点「去登录」跳转过来,自动弹出登录弹窗
|
||||
if (app.globalData.pendingLoginAfterExpire) {
|
||||
app.globalData.pendingLoginAfterExpire = false
|
||||
setTimeout(() => this.showLogin(), 100)
|
||||
}
|
||||
this._applyMyMpUiLabels()
|
||||
},
|
||||
|
||||
_getMyPageUi() {
|
||||
const cache = app.globalData.configCache || {}
|
||||
const fromNew = cache?.mpConfig?.mpUi?.myPage
|
||||
if (fromNew && typeof fromNew === 'object') return fromNew
|
||||
const fromLegacy = cache?.configs?.mp_config?.mpUi?.myPage
|
||||
if (fromLegacy && typeof fromLegacy === 'object') return fromLegacy
|
||||
return {}
|
||||
},
|
||||
|
||||
_applyMyMpUiLabels() {
|
||||
const my = this._getMyPageUi()
|
||||
this.setData({
|
||||
mpUiCardLabel: String(my.cardLabel || '名片').trim() || '名片',
|
||||
mpUiVipLabelVip: String(my.vipLabelVip || '会员中心').trim() || '会员中心',
|
||||
mpUiVipLabelGuest: String(my.vipLabelGuest || '成为会员').trim() || '成为会员',
|
||||
mpUiReadStatLabel: String(my.readStatLabel || '已读章节').trim() || '已读章节',
|
||||
mpUiRecentTitle: String(my.recentReadTitle || '最近阅读').trim() || '最近阅读'
|
||||
})
|
||||
},
|
||||
|
||||
async _refreshMyAvatarDisplay(safeUser) {
|
||||
if (!safeUser || !app.globalData.isLoggedIn) return
|
||||
try {
|
||||
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
|
||||
} catch (_) {}
|
||||
const url = app.resolveAvatarWithMbti ? app.resolveAvatarWithMbti(safeUser.avatar, safeUser.mbti) : ''
|
||||
if (!this.data.isLoggedIn) return
|
||||
this.setData({ profileAvatarDisplay: url || '' })
|
||||
},
|
||||
|
||||
async loadFeatureConfig() {
|
||||
@@ -151,13 +162,16 @@ Page({
|
||||
const referralEnabled = features.referralEnabled !== false
|
||||
const searchEnabled = features.searchEnabled !== false
|
||||
const mp = (res && res.mpConfig) || {}
|
||||
const auditMode = !!mp.auditMode
|
||||
app.globalData.auditMode = auditMode
|
||||
app.globalData.auditMode = !!mp.auditMode
|
||||
await app.getAuditMode()
|
||||
const auditMode = app.globalData.auditMode || false
|
||||
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
|
||||
this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode })
|
||||
this._applyMyMpUiLabels()
|
||||
} catch (error) {
|
||||
console.log('加载功能配置失败:', error)
|
||||
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
|
||||
this._applyMyMpUiLabels()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -169,15 +183,22 @@ Page({
|
||||
const userId = userInfo.id || ''
|
||||
const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId
|
||||
const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
|
||||
|
||||
const safeUser = { ...userInfo }
|
||||
if (!isSafeImageSrc(safeUser.avatar)) safeUser.avatar = ''
|
||||
app.globalData.userInfo = safeUser
|
||||
try {
|
||||
wx.setStorageSync('userInfo', safeUser)
|
||||
} catch (_) {}
|
||||
|
||||
// 先设基础信息;阅读统计与收益再分别从后端刷新
|
||||
this.setData({
|
||||
isLoggedIn: true,
|
||||
userInfo,
|
||||
userInfo: safeUser,
|
||||
profileAvatarDisplay: '',
|
||||
userIdShort,
|
||||
userWechat,
|
||||
readCount: 0,
|
||||
referralCount: userInfo.referralCount || 0,
|
||||
referralCount: 0,
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: true,
|
||||
@@ -193,12 +214,14 @@ Page({
|
||||
this.loadPendingConfirm()
|
||||
this.loadVipStatus()
|
||||
this.loadWalletBalance()
|
||||
this.loadUnlockedChapters()
|
||||
this._refreshMyAvatarDisplay(safeUser)
|
||||
} else {
|
||||
const guestReadCount = app.getReadCount()
|
||||
const guestRecent = this._mergeRecentChaptersFromLocal([])
|
||||
this.setData({
|
||||
isLoggedIn: false,
|
||||
userInfo: null,
|
||||
profileAvatarDisplay: '',
|
||||
userIdShort: '',
|
||||
readCount: guestReadCount,
|
||||
readCountText: formatStatNum(guestReadCount),
|
||||
@@ -206,10 +229,7 @@ Page({
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: false,
|
||||
recentChapters: [],
|
||||
unlockedChaptersFull: [],
|
||||
displayUnlockedChapters: [],
|
||||
unlockedExpanded: false,
|
||||
recentChapters: guestRecent,
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0,
|
||||
totalReadTimeText: '0',
|
||||
@@ -218,88 +238,83 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 已解锁章节:优先订单接口(按支付时间倒序);失败时用 purchasedSections + bookData 兜底
|
||||
*/
|
||||
async loadUnlockedChapters() {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo?.id) {
|
||||
this.setData({
|
||||
unlockedChaptersFull: [],
|
||||
displayUnlockedChapters: [],
|
||||
unlockedExpanded: false
|
||||
})
|
||||
return
|
||||
}
|
||||
const userId = app.globalData.userInfo.id
|
||||
const expanded = this.data.unlockedExpanded
|
||||
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
|
||||
const metaById = (id) => {
|
||||
const row = bookFlat.find((s) => s.id === id)
|
||||
return {
|
||||
mid: row?.mid ?? row?.MID ?? 0,
|
||||
title: cleanSingleLineField(row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || '')
|
||||
}
|
||||
}
|
||||
/** 本地已打开的章节 id(reading_progress 键 + 历史 readSectionIds),用于与服务端合并展示 */
|
||||
_localSectionIdsFromStorage() {
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`, silent: true })
|
||||
let rows = []
|
||||
if (res && res.success && Array.isArray(res.data)) {
|
||||
rows = res.data
|
||||
.map((item) => ({
|
||||
id: item.product_id || item.section_id,
|
||||
mid: item.section_mid ?? item.mid ?? item.MID ?? 0,
|
||||
title: cleanSingleLineField(item.product_name || ''),
|
||||
_ts: parseOrderTimeMs(item)
|
||||
}))
|
||||
.filter((r) => r.id && isSectionUnlockOrder({ product_id: r.id, product_name: r.title }))
|
||||
}
|
||||
rows.sort((a, b) => b._ts - a._ts)
|
||||
const seen = new Set()
|
||||
const deduped = []
|
||||
for (const r of rows) {
|
||||
if (seen.has(r.id)) continue
|
||||
seen.add(r.id)
|
||||
const meta = metaById(r.id)
|
||||
deduped.push({
|
||||
id: r.id,
|
||||
mid: r.mid || meta.mid,
|
||||
title: cleanSingleLineField(r.title || meta.title || `章节 ${r.id}`)
|
||||
})
|
||||
}
|
||||
if (deduped.length === 0) {
|
||||
const ids = [...(app.globalData.purchasedSections || [])]
|
||||
ids.reverse()
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const meta = metaById(id)
|
||||
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
|
||||
}
|
||||
}
|
||||
const display = expanded ? deduped : deduped.slice(0, 5)
|
||||
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
|
||||
} catch (e) {
|
||||
const ids = [...(app.globalData.purchasedSections || [])].reverse()
|
||||
const seen = new Set()
|
||||
const deduped = []
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const meta = metaById(id)
|
||||
deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) })
|
||||
}
|
||||
const display = expanded ? deduped : deduped.slice(0, 5)
|
||||
this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display })
|
||||
const progressData = wx.getStorageSync('reading_progress') || {}
|
||||
const fromProgress = Object.keys(progressData).filter(Boolean)
|
||||
let fromReadList = []
|
||||
try {
|
||||
const rs = wx.getStorageSync('readSectionIds')
|
||||
if (Array.isArray(rs)) fromReadList = rs.filter(Boolean)
|
||||
} catch (_) {}
|
||||
return [...new Set([...fromProgress, ...fromReadList])]
|
||||
} catch (_) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
expandUnlockedChapters() {
|
||||
if (this.data.unlockedExpanded) return
|
||||
trackClick('my', 'tab_click', '已解锁章节_展开')
|
||||
const full = this.data.unlockedChaptersFull || []
|
||||
/** 接口无最近阅读时,用本地 reading_progress + recent_section_opens + bookData 补全 */
|
||||
_mergeRecentChaptersFromLocal(apiList) {
|
||||
const normalized = Array.isArray(apiList)
|
||||
? apiList.map((item) => ({
|
||||
id: item.id,
|
||||
mid: item.mid,
|
||||
title: cleanSingleLineField(item.title || '') || `章节 ${item.id}`
|
||||
}))
|
||||
: []
|
||||
if (normalized.length > 0) return normalized
|
||||
try {
|
||||
const progressData = wx.getStorageSync('reading_progress') || {}
|
||||
let opens = wx.getStorageSync('recent_section_opens')
|
||||
if (!Array.isArray(opens)) opens = []
|
||||
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
|
||||
const titleOf = (id) => {
|
||||
const row = bookFlat.find((s) => s.id === id)
|
||||
return cleanSingleLineField(
|
||||
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
|
||||
) || `章节 ${id}`
|
||||
}
|
||||
const midOf = (id) => {
|
||||
const row = bookFlat.find((s) => s.id === id)
|
||||
return row?.mid ?? row?.MID ?? 0
|
||||
}
|
||||
const latest = new Map()
|
||||
const bump = (sid, ts) => {
|
||||
if (!sid) return
|
||||
const id = String(sid)
|
||||
const t = typeof ts === 'number' && !isNaN(ts) ? ts : 0
|
||||
const prev = latest.get(id) || 0
|
||||
if (t >= prev) latest.set(id, t)
|
||||
}
|
||||
Object.keys(progressData).forEach((id) => {
|
||||
const row = progressData[id]
|
||||
bump(id, row?.lastOpenAt || row?.last_open_at || 0)
|
||||
})
|
||||
opens.forEach((o) => bump(o && o.id, o && o.t))
|
||||
return [...latest.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([id]) => ({ id, mid: midOf(id), title: titleOf(id) }))
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
/** 接口失败或无 data 时,仅用本地 reading_progress / readSectionIds 刷新已读与最近 */
|
||||
_hydrateReadStatsFromLocal() {
|
||||
const localExtra = this._localSectionIdsFromStorage()
|
||||
const readSectionIds = [...new Set(localExtra.filter(Boolean))]
|
||||
app.globalData.readSectionIds = readSectionIds
|
||||
try {
|
||||
wx.setStorageSync('readSectionIds', readSectionIds)
|
||||
} catch (_) {}
|
||||
const recentChapters = this._mergeRecentChaptersFromLocal([])
|
||||
const readCount = readSectionIds.length
|
||||
this.setData({
|
||||
unlockedExpanded: true,
|
||||
displayUnlockedChapters: full
|
||||
readCount,
|
||||
readCountText: formatStatNum(readCount),
|
||||
recentChapters
|
||||
})
|
||||
},
|
||||
|
||||
@@ -313,21 +328,29 @@ Page({
|
||||
silent: true
|
||||
})
|
||||
|
||||
if (!res?.success || !res.data) return
|
||||
if (!res?.success || !res.data) {
|
||||
this._hydrateReadStatsFromLocal()
|
||||
return
|
||||
}
|
||||
|
||||
const apiIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : []
|
||||
const localExtra = this._localSectionIdsFromStorage()
|
||||
const prevGlobal = Array.isArray(app.globalData.readSectionIds) ? app.globalData.readSectionIds.filter(Boolean) : []
|
||||
const readSectionIds = [...new Set([...apiIds, ...prevGlobal, ...localExtra])]
|
||||
|
||||
const readSectionIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds : []
|
||||
app.globalData.readSectionIds = readSectionIds
|
||||
wx.setStorageSync('readSectionIds', readSectionIds)
|
||||
|
||||
const recentChapters = Array.isArray(res.data.recentChapters)
|
||||
const apiRecent = Array.isArray(res.data.recentChapters)
|
||||
? res.data.recentChapters.map((item) => ({
|
||||
id: item.id,
|
||||
mid: item.mid,
|
||||
title: item.title || `章节 ${item.id}`
|
||||
}))
|
||||
: []
|
||||
const recentChapters = this._mergeRecentChaptersFromLocal(apiRecent)
|
||||
|
||||
const readCount = Number(res.data.readCount || 0)
|
||||
const readCount = readSectionIds.length
|
||||
const totalReadTime = Number(res.data.totalReadMinutes || 0)
|
||||
const matchHistory = Number(res.data.matchHistory || 0)
|
||||
const orderCount = Number(res.data.orderCount || 0)
|
||||
@@ -345,6 +368,7 @@ Page({
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('[My] 拉取阅读统计失败:', e && e.message)
|
||||
this._hydrateReadStatsFromLocal()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -585,7 +609,11 @@ Page({
|
||||
wx.showToast({ title: '已刷新', icon: 'success' })
|
||||
},
|
||||
|
||||
// 微信原生获取头像(button open-type="chooseAvatar" 回调,点击头像直接唤起选择器)
|
||||
tapAvatar() {
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
},
|
||||
|
||||
async onChooseAvatar(e) {
|
||||
const tempAvatarUrl = e.detail?.avatarUrl
|
||||
if (!tempAvatarUrl) return
|
||||
@@ -632,6 +660,7 @@ Page({
|
||||
const userInfo = this.data.userInfo
|
||||
userInfo.avatar = avatarUrl
|
||||
this.setData({ userInfo })
|
||||
this._refreshMyAvatarDisplay(userInfo)
|
||||
app.globalData.userInfo = userInfo
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
|
||||
@@ -677,9 +706,9 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 点击昵称:跳转资料编辑页(type="nickname" 在弹窗内无法触发微信昵称选择器,需在主页面)
|
||||
// 点击昵称:先进个人资料名片页,再在右上角进入编辑(与需求「编辑收进名片流」一致)
|
||||
editNickname() {
|
||||
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
wx.navigateTo({ url: '/pages/profile-show/profile-show' })
|
||||
},
|
||||
|
||||
// 关闭昵称弹窗
|
||||
@@ -867,9 +896,33 @@ Page({
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
// 跳转到目录
|
||||
goToChapters() {
|
||||
// 已读章节:进入阅读记录页(有列表);路径可由 mpUi.myPage.readStatPath 配置
|
||||
goToReadStat() {
|
||||
trackClick('my', 'nav_click', '已读章节')
|
||||
if (!this.data.isLoggedIn) {
|
||||
this.showLogin()
|
||||
return
|
||||
}
|
||||
const p = String(this._getMyPageUi().readStatPath || '').trim()
|
||||
if (p && navigateMpPath(p)) return
|
||||
navigateMpPath('/pages/reading-records/reading-records?focus=all')
|
||||
},
|
||||
|
||||
/** 最近阅读区块标题点击:进入阅读记录(最近维度) */
|
||||
goToRecentReadHub() {
|
||||
trackClick('my', 'nav_click', '最近阅读区块')
|
||||
if (!this.data.isLoggedIn) {
|
||||
this.showLogin()
|
||||
return
|
||||
}
|
||||
const p = String(this._getMyPageUi().recentReadPath || '').trim()
|
||||
if (p && navigateMpPath(p)) return
|
||||
navigateMpPath('/pages/reading-records/reading-records?focus=recent')
|
||||
},
|
||||
|
||||
// 去目录(空状态等)
|
||||
goToChapters() {
|
||||
trackClick('my', 'nav_click', '去目录')
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
@@ -880,14 +933,17 @@ Page({
|
||||
},
|
||||
|
||||
// 跳转到推广中心(需登录)
|
||||
goToReferral() {
|
||||
trackClick('my', 'nav_click', '推广中心')
|
||||
goToReferral(e) {
|
||||
const focus = e && e.currentTarget && e.currentTarget.dataset ? (e.currentTarget.dataset.focus || '') : ''
|
||||
const action = focus === 'bindings' ? '推荐好友' : focus === 'earnings' ? '我的收益' : '推广中心'
|
||||
trackClick('my', 'nav_click', action)
|
||||
if (!this.data.isLoggedIn) {
|
||||
this.showLogin()
|
||||
return
|
||||
}
|
||||
if (!this.data.referralEnabled) return
|
||||
wx.navigateTo({ url: '/pages/referral/referral' })
|
||||
const url = focus ? `/pages/referral/referral?focus=${focus}` : '/pages/referral/referral'
|
||||
wx.navigateTo({ url })
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
@@ -943,10 +999,22 @@ Page({
|
||||
goToVip() {
|
||||
trackClick('my', 'btn_click', '会员中心')
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
const p = String(this._getMyPageUi().vipPath || '').trim()
|
||||
if (p && navigateMpPath(p)) return
|
||||
wx.navigateTo({ url: '/pages/vip/vip' })
|
||||
},
|
||||
|
||||
// 进入个人资料编辑页(stitch_soul)
|
||||
// 本人对外名片:默认与「超级个体」同款 member-detail;mpUi.myPage.cardPath 可覆盖(需含完整 query)
|
||||
goToMySuperCard() {
|
||||
trackClick('my', 'btn_click', '名片')
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
const uid = this.data.userInfo?.id
|
||||
if (!uid) return
|
||||
const p = String(this._getMyPageUi().cardPath || '').trim()
|
||||
if (p && navigateMpPath(p)) return
|
||||
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` })
|
||||
},
|
||||
|
||||
goToProfileEdit() {
|
||||
trackClick('my', 'nav_click', '资料编辑')
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
@@ -967,19 +1035,7 @@ Page({
|
||||
wx.showToast({ title: '暂无可提现金额', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!hasWechatIdBound()) {
|
||||
wx.showModal({
|
||||
title: '请先绑定微信号',
|
||||
content: '提现需先绑定微信号,便于到账核对。请到「设置」中绑定后再提现。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
this.doWithdraw(amount)
|
||||
await this.ensureContactInfo(() => this.doWithdraw(amount))
|
||||
},
|
||||
|
||||
async doWithdraw(amount) {
|
||||
@@ -998,16 +1054,6 @@ Page({
|
||||
this.loadWalletBalance()
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
const r = e.response || {}
|
||||
if (r.needBind || r.needBindWechat) {
|
||||
wx.showModal({
|
||||
title: r.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
|
||||
content: r.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
|
||||
confirmText: '去绑定',
|
||||
success: (mr) => { if (mr.confirm) wx.navigateTo({ url: '/pages/settings/settings' }) }
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.showToast({ title: e.message || '提现失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<!-- 我的页 - 设计稿 1:1 还原 -->
|
||||
<view class="page">
|
||||
<!-- 顶部导航:左侧资料编辑 + 标题 -->
|
||||
<!-- 顶部导航 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-settings" bindtap="goToProfileEdit">
|
||||
<image class="nav-settings-icon" src="/assets/icons/edit-gray.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<text class="nav-title">我的</text>
|
||||
</view>
|
||||
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
@@ -23,36 +20,30 @@
|
||||
<view class="profile-card" wx:else>
|
||||
<view class="profile-card-inner">
|
||||
<view class="profile-top-row">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-wrap" bindtap="tapAvatar">
|
||||
<view class="avatar-inner {{isVip ? 'avatar-vip' : ''}}">
|
||||
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
|
||||
<image wx:if="{{profileAvatarDisplay}}" class="avatar-img" src="{{profileAvatarDisplay}}" 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>VIP</view>
|
||||
<button class="avatar-overlay-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
|
||||
</view>
|
||||
<view class="profile-meta">
|
||||
<view class="profile-name-row">
|
||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||
<view class="profile-name-actions">
|
||||
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" wx:if="{{!auditMode}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="vip-tags" wx:if="{{!auditMode}}">
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">会员</text>
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToMatch">匹配</text>
|
||||
<text class="vip-tag {{isVip ? 'vip-tag-active' : ''}}" bindtap="goToVip">排行</text>
|
||||
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
|
||||
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
|
||||
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
|
||||
</view>
|
||||
<text class="user-wechat" wx:if="{{userWechat}}" bindtap="copyUserId">微信号: {{userWechat}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="profile-stats-row">
|
||||
<view class="profile-stat" bindtap="goToChapters">
|
||||
<view class="profile-stat" bindtap="goToReadStat">
|
||||
<text class="profile-stat-val">{{readCountText || '0'}}</text>
|
||||
<text class="profile-stat-label">已读章节</text>
|
||||
<text class="profile-stat-label">{{mpUiReadStatLabel}}</text>
|
||||
</view>
|
||||
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
|
||||
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral" data-focus="bindings">
|
||||
<text class="profile-stat-val">{{referralCount}}</text>
|
||||
<text class="profile-stat-label">推荐好友</text>
|
||||
</view>
|
||||
@@ -60,7 +51,7 @@
|
||||
<text class="profile-stat-val">{{matchHistoryText}}</text>
|
||||
<text class="profile-stat-label">匹配伙伴</text>
|
||||
</view>
|
||||
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral">
|
||||
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral" data-focus="earnings">
|
||||
<text class="profile-stat-val">{{pendingEarnings || '0.00'}}</text>
|
||||
<text class="profile-stat-label">我的收益</text>
|
||||
</view>
|
||||
@@ -117,43 +108,13 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 已解锁:仅低调图标区(无标题文案);默认 5 条 + 倒三角展开;倒序由接口/JS 保证 -->
|
||||
<view class="card recent-card unlocked-card" wx:if="{{unlockedChaptersFull.length > 0}}">
|
||||
<view class="unlocked-section-head">
|
||||
<image class="unlocked-section-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="recent-list">
|
||||
<view
|
||||
class="recent-item"
|
||||
wx:for="{{displayUnlockedChapters}}"
|
||||
wx:key="id"
|
||||
bindtap="goToRead"
|
||||
data-id="{{item.id}}"
|
||||
data-mid="{{item.mid}}"
|
||||
>
|
||||
<view class="recent-left">
|
||||
<text class="recent-index">{{index + 1}}</text>
|
||||
<text class="recent-title">{{item.title}}</text>
|
||||
</view>
|
||||
<text class="recent-link">阅读</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="unlocked-expand-hint"
|
||||
wx:if="{{unlockedChaptersFull.length > 5 && !unlockedExpanded}}"
|
||||
bindtap="expandUnlockedChapters"
|
||||
hover-class="unlocked-expand-hint-hover"
|
||||
hover-stay-time="80"
|
||||
>
|
||||
<view class="unlocked-expand-triangle"></view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 已解锁/充值/代付等流水已迁至「我的订单」页 -->
|
||||
|
||||
<!-- 最近阅读 -->
|
||||
<view class="card recent-card">
|
||||
<view class="card-header">
|
||||
<view class="card-header" bindtap="goToRecentReadHub">
|
||||
<image class="card-icon-img" src="/assets/icons/book-arrow-teal.svg" mode="aspectFit"/>
|
||||
<text class="card-title">最近阅读</text>
|
||||
<text class="card-title">{{mpUiRecentTitle}}</text>
|
||||
</view>
|
||||
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
|
||||
<view
|
||||
|
||||
@@ -73,23 +73,51 @@
|
||||
}
|
||||
.vip-badge-gray { background: rgba(255,255,255,0.2); color: rgba(255,255,255,0.5); }
|
||||
.profile-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 12rpx; }
|
||||
.profile-name-row { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; flex-wrap: wrap; }
|
||||
.profile-name-row { display: flex; align-items: center; justify-content: flex-start; gap: 16rpx; flex-wrap: wrap; }
|
||||
.user-name {
|
||||
font-size: 44rpx; font-weight: bold; color: #fff;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
|
||||
}
|
||||
.become-member-btn {
|
||||
padding: 12rpx 28rpx; border: 2rpx solid #C8A146; color: #C8A146;
|
||||
.profile-actions-row { display: flex; flex-wrap: wrap; align-items: center; gap: 12rpx; }
|
||||
/* 名片/会员中心:紧挨昵称下方(在 profile-meta 内) */
|
||||
.profile-actions-under-name { margin-top: 4rpx; }
|
||||
/* 名片 / 会员中心:统一品牌青,与 tabBar 选中色一致 */
|
||||
.profile-action-btn {
|
||||
padding: 12rpx 28rpx; border: 2rpx solid #4FD1C5; color: #4FD1C5;
|
||||
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.become-member-vip { border-color: rgba(200,161,70,0.5); color: rgba(200,161,70,0.8); }
|
||||
.vip-tags { display: flex; gap: 12rpx; flex-shrink: 0; }
|
||||
.vip-tag {
|
||||
font-size: 20rpx; padding: 6rpx 12rpx; border-radius: 8rpx;
|
||||
border: 1rpx solid #374151; background: rgba(255,255,255,0.05); color: #9CA3AF;
|
||||
}
|
||||
.vip-tag-active { border-color: #C8A146; background: rgba(200,161,70,0.1); color: #C8A146; }
|
||||
.profile-action-btn:active { opacity: 0.75; }
|
||||
.user-wechat { font-size: 26rpx; color: #6B7280; }
|
||||
.super-card-entry {
|
||||
position: relative;
|
||||
margin-top: 24rpx;
|
||||
padding: 24rpx 56rpx 24rpx 28rpx;
|
||||
border-radius: 16rpx;
|
||||
background: rgba(79, 209, 197, 0.08);
|
||||
border: 1rpx solid rgba(79, 209, 197, 0.28);
|
||||
}
|
||||
.super-card-entry-txt {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #4fd1c5;
|
||||
display: block;
|
||||
}
|
||||
.super-card-entry-sub {
|
||||
font-size: 22rpx;
|
||||
color: #9ca3af;
|
||||
margin-top: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
.super-card-entry-arrow {
|
||||
position: absolute;
|
||||
right: 24rpx;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 40rpx;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.profile-stats-row {
|
||||
display: flex; justify-content: space-around; margin-top: 32rpx;
|
||||
padding-top: 24rpx; border-top: 1rpx solid #374151;
|
||||
@@ -98,6 +126,15 @@
|
||||
.profile-stat-val { display: block; font-size: 36rpx; font-weight: bold; color: #4FD1C5; }
|
||||
.profile-stat-label { display: block; font-size: 22rpx; color: #6B7280; margin-top: 8rpx; }
|
||||
|
||||
.profile-edit-bar {
|
||||
display: flex; align-items: center; gap: 16rpx;
|
||||
margin-top: 24rpx; padding: 20rpx 24rpx;
|
||||
background: rgba(79,209,197,0.06); border-radius: 12rpx;
|
||||
}
|
||||
.profile-edit-icon { width: 32rpx; height: 32rpx; opacity: 0.6; flex-shrink: 0; }
|
||||
.profile-edit-text { flex: 1; font-size: 26rpx; color: #9ca3af; }
|
||||
.profile-edit-arrow { font-size: 36rpx; color: rgba(255,255,255,0.3); font-weight: 300; }
|
||||
|
||||
/* ===== 主内容区 ===== */
|
||||
.main-content { padding: 0 0 0 0; }
|
||||
|
||||
@@ -163,19 +200,6 @@
|
||||
padding: 24rpx; background: #252525; border-radius: 20rpx;
|
||||
}
|
||||
.recent-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; }
|
||||
/* 已解锁区块:仅顶部弱对比图标,无标题字 */
|
||||
.unlocked-card { padding-top: 28rpx; }
|
||||
.unlocked-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0 8rpx 16rpx 8rpx;
|
||||
}
|
||||
.unlocked-section-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.recent-index { font-size: 28rpx; color: #6B7280; font-family: monospace; }
|
||||
.recent-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recent-link { font-size: 24rpx; color: #4FD1C5; font-weight: 500; flex-shrink: 0; }
|
||||
@@ -183,25 +207,6 @@
|
||||
.recent-empty-text { font-size: 28rpx; color: #6B7280; display: block; margin-bottom: 24rpx; }
|
||||
.recent-empty-btn { font-size: 28rpx; color: #4FD1C5; }
|
||||
|
||||
/* 已解锁章节列表底部倒三角展开(与首页「最新新增」一致) */
|
||||
.unlocked-expand-hint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 8rpx 0 8rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
.unlocked-expand-hint-hover {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.unlocked-expand-triangle {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 16rpx solid transparent;
|
||||
border-right: 16rpx solid transparent;
|
||||
border-top: 20rpx solid rgba(79, 209, 197, 0.85);
|
||||
}
|
||||
|
||||
/* 菜单 */
|
||||
.menu-card { padding: 0; margin-bottom: 48rpx; overflow: hidden; }
|
||||
.menu-item {
|
||||
|
||||
@@ -13,6 +13,9 @@ const { toAvatarPath } = require('../../utils/util.js')
|
||||
|
||||
const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
|
||||
|
||||
/** 首次分步完善完成后写入;与手机号+昵称齐全时自动写入,老用户免向导 */
|
||||
const PROFILE_WIZARD_DONE_KEY = 'profile_wizard_v1_done'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
@@ -41,9 +44,19 @@ Page({
|
||||
loading: true,
|
||||
showPrivacyModal: false,
|
||||
nicknameInputFocus: false,
|
||||
/** 首次完善:分 3 步;full=1 或未达标资料时为单页 */
|
||||
wizardMode: false,
|
||||
wizardStep: 1,
|
||||
totalWizardSteps: 3,
|
||||
/** 头像区展示:含 MBTI 默认图 */
|
||||
avatarPreviewUrl: '',
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
this._wizardRouteOpts = {
|
||||
full: options?.full === '1',
|
||||
wizardOff: options?.wizard === '0',
|
||||
}
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44,
|
||||
fromVip: options?.from === 'vip',
|
||||
@@ -72,6 +85,9 @@ Page({
|
||||
app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true }),
|
||||
app.request({ url: `/api/miniprogram/vip/status?userId=${userId}`, silent: true }),
|
||||
])
|
||||
try {
|
||||
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
|
||||
} catch (_) {}
|
||||
this.setData({ isVip: vipRes?.data?.isVip || false })
|
||||
const res = profileRes
|
||||
if (res?.success && res.data) {
|
||||
@@ -98,6 +114,8 @@ Page({
|
||||
projectIntro: v('projectIntro'),
|
||||
loading: false,
|
||||
})
|
||||
this._applyWizardModeFromProfile(d)
|
||||
this._syncAvatarPreview()
|
||||
setTimeout(() => this.generateShareCard(), 200)
|
||||
} else {
|
||||
this.setData({ loading: false })
|
||||
@@ -109,6 +127,49 @@ Page({
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
/**
|
||||
* 是否走三步向导:资料未「手机号+昵称」齐全且未标记完成,且非 full=1、非 VIP 开通页强制单页。
|
||||
* 老用户已齐全则自动写 DONE,避免重复向导。
|
||||
*/
|
||||
_applyWizardModeFromProfile(d) {
|
||||
const ro = this._wizardRouteOpts || {}
|
||||
const forceFull = ro.full === true
|
||||
const forceNoWizard = ro.wizardOff === true
|
||||
const phoneNum = String(d.phone || '').replace(/\D/g, '')
|
||||
const phoneOk = /^1[3-9]\d{9}$/.test(phoneNum)
|
||||
const nickOk = !!(d.nickname && String(d.nickname).trim())
|
||||
if (phoneOk && nickOk && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)) {
|
||||
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
|
||||
}
|
||||
const wizardMode = !forceFull && !forceNoWizard && !wx.getStorageSync(PROFILE_WIZARD_DONE_KEY)
|
||||
this.setData({ wizardMode, wizardStep: 1 })
|
||||
},
|
||||
|
||||
onWizardPrev() {
|
||||
if (this.data.wizardStep > 1) {
|
||||
this.setData({ wizardStep: this.data.wizardStep - 1 })
|
||||
}
|
||||
},
|
||||
|
||||
onWizardNext() {
|
||||
if (this.data.saving) return
|
||||
const { wizardMode, wizardStep } = this.data
|
||||
if (!wizardMode) return
|
||||
if (wizardStep === 1) {
|
||||
if (!(this.data.nickname || '').trim()) {
|
||||
wx.showToast({ title: '请填写昵称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.setData({ wizardStep: 2 })
|
||||
return
|
||||
}
|
||||
if (wizardStep === 2) {
|
||||
this.setData({ wizardStep: 3 })
|
||||
return
|
||||
}
|
||||
this._doSaveProfile({ wizardComplete: true })
|
||||
},
|
||||
|
||||
onNicknameAreaTouch() {
|
||||
if (typeof wx.requirePrivacyAuthorize !== 'function') return
|
||||
wx.requirePrivacyAuthorize({
|
||||
@@ -312,7 +373,22 @@ Page({
|
||||
|
||||
onMbtiPickerChange(e) {
|
||||
const i = parseInt(e.detail.value, 10)
|
||||
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] })
|
||||
this.setData({ mbtiIndex: i, mbti: MBTI_OPTIONS[i] }, () => this._syncAvatarPreview())
|
||||
},
|
||||
|
||||
_syncAvatarPreview() {
|
||||
try {
|
||||
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
|
||||
const url = resolveAvatarWithMbti(
|
||||
this.data.avatar,
|
||||
this.data.mbti,
|
||||
app.globalData.mbtiAvatarsMap || {},
|
||||
app.globalData.baseUrl || ''
|
||||
)
|
||||
this.setData({ avatarPreviewUrl: url || '' })
|
||||
} catch (_) {
|
||||
this.setData({ avatarPreviewUrl: (this.data.avatar || '').trim() })
|
||||
}
|
||||
},
|
||||
|
||||
// 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照)
|
||||
@@ -345,7 +421,7 @@ Page({
|
||||
if (avatarUrl && !avatarUrl.startsWith('http')) {
|
||||
avatarUrl = app.globalData.baseUrl + avatarUrl
|
||||
}
|
||||
this.setData({ avatar: avatarUrl })
|
||||
this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview())
|
||||
const avatarToSave = toAvatarPath(avatarUrl)
|
||||
await app.request({
|
||||
url: '/api/miniprogram/user/profile',
|
||||
@@ -365,7 +441,11 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
saveProfile() {
|
||||
this._doSaveProfile({ wizardComplete: false })
|
||||
},
|
||||
|
||||
async _doSaveProfile({ wizardComplete }) {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
@@ -373,7 +453,6 @@ Page({
|
||||
}
|
||||
const s = (v) => (v || '').toString().trim()
|
||||
const isVip = this.data.isVip
|
||||
// 手机号必填,格式校验(支持带空格/连字符输入)
|
||||
const phoneRaw = s(this.data.phone)
|
||||
if (!phoneRaw) {
|
||||
wx.showToast({ title: '请输入手机号', icon: 'none' })
|
||||
@@ -428,8 +507,17 @@ Page({
|
||||
if (app.globalData.userInfo) {
|
||||
if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname
|
||||
if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar
|
||||
if (payload.phone) app.globalData.userInfo.phone = payload.phone
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
}
|
||||
if (wizardComplete) {
|
||||
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
|
||||
this.setData({ wizardMode: false, saving: false })
|
||||
setTimeout(() => {
|
||||
wx.redirectTo({ url: `/pages/member-detail/member-detail?id=${userId}` })
|
||||
}, 400)
|
||||
return
|
||||
}
|
||||
setTimeout(() => getApp().goBackOrToHome(), 800)
|
||||
} catch (e) {
|
||||
wx.showToast({ title: e.message || '保存失败', icon: 'none' })
|
||||
|
||||
@@ -1,158 +1,311 @@
|
||||
<!-- 资料编辑 - comprehensive_profile_editor_v1_1 | input/textarea 用 view 包裹,配色 enhanced -->
|
||||
<!-- 资料编辑:单页 full=1;首次未完善走三步向导,保存后跳转超级个体名片 -->
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="#5EEAD4" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">编辑资料</text>
|
||||
<text class="nav-title">{{wizardMode ? '分步档案(' + wizardStep + '/3)' : '编辑资料'}}</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>
|
||||
<!-- 温馨提示:from=vip 时强化权益说明 -->
|
||||
<view class="tip-card {{fromVip ? 'tip-card-highlight' : ''}}">
|
||||
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">{{fromVip ? '恭喜成为VIP!完善资料后即可使用找伙伴、提现等功能,手机号必填' : '温馨提示:手机号必填,微信号建议填写,以便使用提现和找伙伴功能'}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像:点击直接弹出微信原生选择器(用微信头像/从相册选择/拍照) -->
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
|
||||
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
|
||||
</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<view class="section">
|
||||
<view class="form-row">
|
||||
<text class="form-label">昵称</text>
|
||||
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
|
||||
<input
|
||||
class="form-input-inner"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
value="{{nickname}}"
|
||||
focus="{{nicknameInputFocus}}"
|
||||
bindinput="onNicknameInput"
|
||||
bindchange="onNicknameChange"
|
||||
bindblur="onNicknameBlur"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
<text class="input-tip">微信用户可点击自动填充昵称,或手动输入</text>
|
||||
<!-- —— 三步向导(首次) —— -->
|
||||
<block wx:if="{{wizardMode}}">
|
||||
<view class="wizard-bar">
|
||||
<view class="wizard-step {{wizardStep >= 1 ? 'wizard-step-on' : ''}}">1</view>
|
||||
<view class="wizard-line {{wizardStep >= 2 ? 'wizard-line-on' : ''}}"></view>
|
||||
<view class="wizard-step {{wizardStep >= 2 ? 'wizard-step-on' : ''}}">2</view>
|
||||
<view class="wizard-line {{wizardStep >= 3 ? 'wizard-line-on' : ''}}"></view>
|
||||
<view class="wizard-step {{wizardStep >= 3 ? 'wizard-step-on' : ''}}">3</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>
|
||||
<text class="wizard-sub">分步完善,保存后将进入「我的超级个体名片」,可转发分享</text>
|
||||
|
||||
<block wx:if="{{wizardStep === 1}}">
|
||||
<view class="tip-card tip-card-wizard">
|
||||
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">第 1 步:先设置对外展示的头像与昵称</text>
|
||||
</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"/>
|
||||
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
|
||||
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
|
||||
</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
<view class="section section-wizard">
|
||||
<view class="form-row">
|
||||
<text class="form-label">昵称<text class="required-mark">*</text></text>
|
||||
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
|
||||
<input
|
||||
class="form-input-inner"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
value="{{nickname}}"
|
||||
focus="{{nicknameInputFocus}}"
|
||||
bindinput="onNicknameInput"
|
||||
bindchange="onNicknameChange"
|
||||
bindblur="onNicknameBlur"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
<text class="input-tip">可用微信昵称或手动输入</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" wx:if="{{isVip}}">
|
||||
<text class="form-label">我擅长</text>
|
||||
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:短视频制作、IP打造、私域运营" value="{{skills}}" bindinput="onSkillsInput"/></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 核心联系方式 -->
|
||||
<view class="section">
|
||||
<view class="section-title">
|
||||
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>核心联系方式</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">手机号<text class="required-mark">*</text></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>
|
||||
<block wx:if="{{wizardStep === 2}}">
|
||||
<view class="tip-card tip-card-wizard">
|
||||
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">第 2 步:补充职业画像(可后补,尽量填写便于匹配)</text>
|
||||
</view>
|
||||
<view class="section section-wizard">
|
||||
<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"/>
|
||||
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
|
||||
</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" wx:if="{{isVip}}">
|
||||
<text class="form-label">我擅长</text>
|
||||
<view class="form-input-wrap"><input class="form-input-inner" placeholder="例如:私域运营、投融资对接" value="{{skills}}" bindinput="onSkillsInput"/></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 个人故事(仅 VIP 展示) -->
|
||||
<view class="section" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<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>
|
||||
<block wx:if="{{wizardStep === 3}}">
|
||||
<view class="tip-card tip-card-wizard">
|
||||
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">第 3 步:手机号必填;微信号建议填写,便于链接与提现</text>
|
||||
</view>
|
||||
<view class="section section-wizard">
|
||||
<view class="section-title">
|
||||
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>核心联系方式</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">手机号<text class="required-mark">*</text></text>
|
||||
<view class="form-input-wrap"><input class="form-input-inner" type="tel" placeholder="11 位手机号" 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" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>个人故事</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">你最赚钱的一个月</text>
|
||||
<view class="form-textarea-wrap"><textarea class="form-textarea-inner" placeholder="选填" 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="选填" 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" wx:if="{{isVip || helpOffer || helpNeed}}">
|
||||
<view class="section-title">
|
||||
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<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" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<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>
|
||||
</block>
|
||||
|
||||
<!-- 互助需求(VIP 或 资源对接已填写时展示) -->
|
||||
<view class="section" wx:if="{{isVip || helpOffer || helpNeed}}">
|
||||
<view class="section-title">
|
||||
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>互助需求</text>
|
||||
<view class="wizard-actions">
|
||||
<view class="wizard-btn-secondary" wx:if="{{wizardStep > 1}}" bindtap="onWizardPrev">上一步</view>
|
||||
<view class="wizard-btn-primary {{wizardStep === 1 ? 'wizard-btn-full' : ''}}" bindtap="onWizardNext">
|
||||
{{wizardStep === 3 ? (saving ? '保存中...' : '保存并查看名片') : '下一步'}}
|
||||
</view>
|
||||
</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="bottom-space"></view>
|
||||
</block>
|
||||
|
||||
<!-- 项目介绍(仅 VIP 展示) -->
|
||||
<view class="section" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>项目介绍</text>
|
||||
<!-- —— 单页完整编辑(已完善过或 full=1) —— -->
|
||||
<block wx:else>
|
||||
<view class="tip-card {{fromVip ? 'tip-card-highlight' : ''}}">
|
||||
<icon name="info" size="36" color="#00CED1" customClass="tip-icon"></icon>
|
||||
<text class="tip-text">{{fromVip ? '恭喜成为 VIP。补全资料后找伙伴、提现与群对接更顺畅;手机号为必填' : '手机号为必填;建议填写微信号,便于提现核对与对方联系。'}}</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>
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
|
||||
<view class="avatar-wrap">
|
||||
<view class="avatar-inner">
|
||||
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
|
||||
<view wx:else class="avatar-placeholder">{{nickname ? nickname[0] : '?'}}</view>
|
||||
</view>
|
||||
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="form-row">
|
||||
<text class="form-label">昵称</text>
|
||||
<view class="form-input-wrap" catchtouchstart="onNicknameAreaTouch">
|
||||
<input
|
||||
class="form-input-inner"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
value="{{nickname}}"
|
||||
focus="{{nicknameInputFocus}}"
|
||||
bindinput="onNicknameInput"
|
||||
bindchange="onNicknameChange"
|
||||
bindblur="onNicknameBlur"
|
||||
maxlength="20"
|
||||
/>
|
||||
</view>
|
||||
<text class="input-tip">微信用户可点击自动填充昵称,或手动输入</text>
|
||||
</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"/>
|
||||
<icon name="map-pin" size="32" color="#8e8e93" customClass="form-suffix"></icon>
|
||||
</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" wx:if="{{isVip}}">
|
||||
<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">
|
||||
<icon name="phone" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<text>核心联系方式</text>
|
||||
</view>
|
||||
<view class="form-row">
|
||||
<text class="form-label">手机号<text class="required-mark">*</text></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" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<icon name="lightbulb" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<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" wx:if="{{isVip || helpOffer || helpNeed}}">
|
||||
<view class="section-title">
|
||||
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<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" wx:if="{{isVip}}">
|
||||
<view class="section-title">
|
||||
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
|
||||
<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>
|
||||
</block>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 分享名片 canvas(隐藏,用于生成分享图 5:4) -->
|
||||
<canvas canvas-id="shareCardCanvas" class="share-card-canvas" style="width: 500px; height: 400px;"></canvas>
|
||||
|
||||
<!-- 隐私授权弹窗(昵称需授权后方可唤起微信昵称选择器) -->
|
||||
<view class="privacy-mask" wx:if="{{showPrivacyModal}}" catchtouchmove="preventMove">
|
||||
<view class="privacy-modal">
|
||||
<text class="privacy-title">温馨提示</text>
|
||||
|
||||
@@ -333,3 +333,90 @@
|
||||
.privacy-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: #5EEAD4; color: #050B14; font-size: 30rpx; font-weight: 600; border-radius: 44rpx; border: none; }
|
||||
.privacy-btn::after { border: none; }
|
||||
.privacy-cancel { text-align: center; margin-top: 24rpx; font-size: 28rpx; color: #94A3B8; }
|
||||
|
||||
/* —— 三步向导 —— */
|
||||
.wizard-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
.wizard-step {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #64748b;
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border: 2rpx solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
.wizard-step-on {
|
||||
color: #042f2e;
|
||||
background: linear-gradient(135deg, #5eead4, #2dd4bf);
|
||||
border-color: transparent;
|
||||
}
|
||||
.wizard-line {
|
||||
width: 48rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 4rpx;
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.wizard-line-on {
|
||||
background: linear-gradient(90deg, #5eead4, #2dd4bf);
|
||||
}
|
||||
.wizard-sub {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #94a3b8;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
margin-bottom: 32rpx;
|
||||
padding: 0 12rpx;
|
||||
}
|
||||
.tip-card-wizard {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.section-wizard {
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 24rpx;
|
||||
margin-top: 24rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.wizard-btn-secondary {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 28rpx 24rpx;
|
||||
border-radius: 48rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
border: 2rpx solid rgba(148, 163, 184, 0.35);
|
||||
background: transparent;
|
||||
}
|
||||
.wizard-btn-primary {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 28rpx 24rpx;
|
||||
border-radius: 48rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #042f2e;
|
||||
background: linear-gradient(135deg, #5eead4, #2dd4bf);
|
||||
box-shadow: 0 8rpx 24rpx rgba(45, 212, 191, 0.25);
|
||||
}
|
||||
.wizard-btn-full {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 从「我的」页编辑图标进入;展示基本信息、个人故事、互助需求、项目介绍
|
||||
*/
|
||||
const app = getApp()
|
||||
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
|
||||
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -30,15 +32,27 @@ Page({
|
||||
}
|
||||
this.setData({ loading: true })
|
||||
try {
|
||||
try {
|
||||
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
|
||||
} catch (_) {}
|
||||
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 e = (v) => (v == null || v === undefined ? '' : (String(v).trim() === '' || String(v).trim() === '未填写' ? '' : String(v).trim()))
|
||||
const phone = d.phone || ''
|
||||
const wechat = d.wechatId || wx.getStorageSync('user_wechat') || ''
|
||||
const av = d.avatar
|
||||
const safeAv = isSafeImageSrc(av) ? String(av).trim() : ''
|
||||
const displayAv = resolveAvatarWithMbti(
|
||||
safeAv,
|
||||
d.mbti,
|
||||
app.globalData.mbtiAvatarsMap || {},
|
||||
app.globalData.baseUrl || ''
|
||||
)
|
||||
this.setData({
|
||||
profile: {
|
||||
...d,
|
||||
avatar: displayAv,
|
||||
industry: e(d.industry),
|
||||
position: e(d.position),
|
||||
businessScale: e(d.businessScale || d.business_scale),
|
||||
|
||||
@@ -1,13 +1,92 @@
|
||||
/**
|
||||
* Soul创业实验 - 订单页
|
||||
* Soul创业实验 - 订单页(已支付消费流水:章节/VIP/余额/代付等)
|
||||
*/
|
||||
const app = getApp()
|
||||
const { cleanSingleLineField } = require('../../utils/contentParser.js')
|
||||
|
||||
const PAID_STATUSES = new Set(['paid', 'completed', 'success'])
|
||||
|
||||
function parseOrderTimeMs(o) {
|
||||
const raw = o.created_at || o.createdAt || o.pay_time || 0
|
||||
const t = new Date(raw).getTime()
|
||||
return Number.isFinite(t) ? t : 0
|
||||
}
|
||||
|
||||
function formatShortDate(ms) {
|
||||
if (!ms) return '--'
|
||||
const d = new Date(ms)
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, '0')
|
||||
const day = d.getDate().toString().padStart(2, '0')
|
||||
return `${m}-${day}`
|
||||
}
|
||||
|
||||
function midForSection(sectionId, bookFlat) {
|
||||
const row = bookFlat.find((s) => s.id === sectionId)
|
||||
return row?.mid ?? row?.MID ?? 0
|
||||
}
|
||||
|
||||
function classifyNav(productType, productId, mid) {
|
||||
const pt = String(productType || '').toLowerCase()
|
||||
if (pt === 'section' && productId) {
|
||||
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
|
||||
}
|
||||
if (pt === 'fullbook') {
|
||||
return { kind: 'switchTab', path: '/pages/chapters/chapters', label: '去目录' }
|
||||
}
|
||||
if (pt === 'vip') {
|
||||
return { kind: 'page', path: '/pages/vip/vip', label: '会员中心' }
|
||||
}
|
||||
if (pt === 'match') {
|
||||
return { kind: 'switchTab', path: '/pages/match/match', label: '找伙伴' }
|
||||
}
|
||||
if (pt === 'balance_recharge') {
|
||||
return { kind: 'page', path: '/pages/wallet/wallet', label: '余额' }
|
||||
}
|
||||
if (pt === 'gift_pay' || pt === 'gift_pay_batch') {
|
||||
return { kind: 'page', path: '/pages/gift-pay/list', label: '代付记录' }
|
||||
}
|
||||
if (productId && (/^\d+\.\d+/.test(productId) || productId.length > 0)) {
|
||||
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
|
||||
}
|
||||
return { kind: 'none', label: '--' }
|
||||
}
|
||||
|
||||
function mapApiOrderToRow(item, bookFlat) {
|
||||
const status = String(item.status || '').toLowerCase()
|
||||
if (!PAID_STATUSES.has(status)) return null
|
||||
|
||||
const pt = String(item.product_type || '').toLowerCase()
|
||||
const productId = String(item.product_id || item.section_id || '').trim()
|
||||
let mid = Number(item.section_mid ?? item.mid ?? item.MID ?? 0) || 0
|
||||
if (pt === 'section' && productId && !mid) mid = midForSection(productId, bookFlat)
|
||||
|
||||
const titleRaw = cleanSingleLineField(item.product_name || '')
|
||||
const title =
|
||||
titleRaw ||
|
||||
(pt === 'balance_recharge' ? '余额充值' : productId ? `订单 ${productId}` : '消费记录')
|
||||
|
||||
const amt = Number(item.amount)
|
||||
const amountStr = Number.isFinite(amt) ? amt.toFixed(2) : '--'
|
||||
const t = parseOrderTimeMs(item)
|
||||
const nav = classifyNav(pt, productId, mid)
|
||||
|
||||
return {
|
||||
rowKey: String(item.order_sn || item.id || `o_${t}`),
|
||||
title,
|
||||
subLine: `¥${amountStr} · ${formatShortDate(t)}`,
|
||||
actionLabel: nav.label,
|
||||
nav,
|
||||
_sortMs: t
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
orders: [],
|
||||
loading: true
|
||||
loading: true,
|
||||
allRows: [],
|
||||
displayRows: [],
|
||||
historyExpanded: false
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -16,63 +95,95 @@ Page({
|
||||
this.loadOrders()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
if (!this._purchasesFirstOnShowSkipped) {
|
||||
this._purchasesFirstOnShowSkipped = true
|
||||
return
|
||||
}
|
||||
if (app.globalData.isLoggedIn) this.loadOrders()
|
||||
},
|
||||
|
||||
applyDisplay(expanded) {
|
||||
const all = this.data.allRows || []
|
||||
const display = expanded || all.length <= 5 ? all : all.slice(0, 5)
|
||||
this.setData({ displayRows: display, historyExpanded: !!expanded })
|
||||
},
|
||||
|
||||
expandHistory() {
|
||||
if (this.data.historyExpanded) return
|
||||
this.applyDisplay(true)
|
||||
},
|
||||
|
||||
async loadOrders() {
|
||||
this.setData({ loading: true })
|
||||
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
|
||||
const userId = app.globalData.userInfo?.id
|
||||
|
||||
try {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) {
|
||||
const res = await app.request(`/api/miniprogram/orders?userId=${userId}`)
|
||||
if (res && res.success && res.data) {
|
||||
const raw = (res.data || []).map(item => ({
|
||||
id: item.id || item.order_sn,
|
||||
sectionId: item.product_id || item.section_id,
|
||||
sectionMid: item.section_mid ?? item.mid ?? 0,
|
||||
title: item.product_name || `章节 ${item.product_id || ''}`,
|
||||
amount: item.amount || 0,
|
||||
status: item.status || 'completed',
|
||||
createTime: item.created_at ? new Date(item.created_at).toLocaleDateString() : '--',
|
||||
_sortMs: new Date(item.created_at || item.pay_time || 0).getTime() || 0
|
||||
}))
|
||||
raw.sort((a, b) => b._sortMs - a._sortMs)
|
||||
const orders = raw.map(({ _sortMs, ...rest }) => rest)
|
||||
this.setData({ orders })
|
||||
const res = await app.request({
|
||||
url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`,
|
||||
silent: true
|
||||
})
|
||||
if (res && res.success && Array.isArray(res.data)) {
|
||||
const rows = res.data
|
||||
.map((item) => mapApiOrderToRow(item, bookFlat))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b._sortMs - a._sortMs)
|
||||
.map(({ _sortMs, ...rest }) => rest)
|
||||
this.setData({ allRows: rows, loading: false })
|
||||
this.applyDisplay(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
|
||||
const orders = purchasedSections.map((id, index) => ({
|
||||
id: `order_${index}`,
|
||||
sectionId: id,
|
||||
sectionMid: 0,
|
||||
title: `章节 ${id}`,
|
||||
amount: 1,
|
||||
status: 'completed',
|
||||
createTime: new Date(Date.now() - index * 86400000).toLocaleDateString()
|
||||
}))
|
||||
this.setData({ orders })
|
||||
const ids = [...(app.globalData.purchasedSections || [])].reverse()
|
||||
const rows = ids.map((id, index) => {
|
||||
const mid = midForSection(id, bookFlat)
|
||||
const row = bookFlat.find((s) => s.id === id)
|
||||
const title =
|
||||
cleanSingleLineField(
|
||||
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
|
||||
) || `章节 ${id}`
|
||||
const t = Date.now() - index * 86400000
|
||||
return {
|
||||
rowKey: `p_${id}_${index}`,
|
||||
title,
|
||||
subLine: `已解锁 · ${formatShortDate(t)}`,
|
||||
actionLabel: '阅读',
|
||||
nav: { kind: 'read', id, mid, label: '阅读' }
|
||||
}
|
||||
})
|
||||
this.setData({ allRows: rows, loading: false })
|
||||
this.applyDisplay(false)
|
||||
} catch (e) {
|
||||
console.error('加载订单失败:', e)
|
||||
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
|
||||
this.setData({
|
||||
orders: purchasedSections.map((id, i) => ({
|
||||
id: `order_${i}`, sectionId: id, sectionMid: 0, title: `章节 ${id}`, amount: 1, status: 'completed',
|
||||
createTime: new Date(Date.now() - i * 86400000).toLocaleDateString()
|
||||
}))
|
||||
})
|
||||
} finally {
|
||||
this.setData({ loading: false })
|
||||
this.setData({ allRows: [], loading: false })
|
||||
this.applyDisplay(false)
|
||||
}
|
||||
},
|
||||
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
if (!id) return
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
onOrderRowTap(e) {
|
||||
const index = e.currentTarget.dataset.index
|
||||
const row = (this.data.displayRows || [])[index]
|
||||
if (!row || !row.nav) return
|
||||
const { nav } = row
|
||||
if (nav.kind === 'read' && nav.id) {
|
||||
const q = nav.mid ? `mid=${nav.mid}` : `id=${nav.id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
return
|
||||
}
|
||||
if (nav.kind === 'page' && nav.path) {
|
||||
wx.navigateTo({ url: nav.path })
|
||||
return
|
||||
}
|
||||
if (nav.kind === 'switchTab' && nav.path) {
|
||||
wx.switchTab({ url: nav.path })
|
||||
}
|
||||
},
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
goBack() {
|
||||
getApp().goBackOrToHome()
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const ref = app.getMyReferralCode()
|
||||
|
||||
@@ -14,20 +14,37 @@
|
||||
<view class="skeleton"></view>
|
||||
</view>
|
||||
|
||||
<view class="orders-list" wx:elif="{{orders.length > 0}}">
|
||||
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}" data-mid="{{item.sectionMid}}">
|
||||
<view class="order-info">
|
||||
<view class="order-title-row">
|
||||
<text class="order-unlock-icon">🔓</text>
|
||||
<text class="order-title">{{item.title}}</text>
|
||||
<view class="order-history-card" wx:elif="{{displayRows.length > 0}}">
|
||||
<view class="order-history-head">
|
||||
<image class="order-history-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
|
||||
</view>
|
||||
<view class="oh-list">
|
||||
<view
|
||||
class="oh-row"
|
||||
wx:for="{{displayRows}}"
|
||||
wx:key="rowKey"
|
||||
bindtap="onOrderRowTap"
|
||||
data-index="{{index}}"
|
||||
>
|
||||
<view class="oh-left">
|
||||
<text class="oh-index">{{index + 1}}</text>
|
||||
<view class="oh-text-wrap">
|
||||
<text class="oh-title">{{item.title}}</text>
|
||||
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="order-time">{{item.createTime}}</text>
|
||||
</view>
|
||||
<view class="order-right">
|
||||
<text class="order-amount">¥{{item.amount}}</text>
|
||||
<text class="order-status">已完成</text>
|
||||
<text class="oh-link">{{item.actionLabel}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
class="oh-expand"
|
||||
wx:if="{{allRows.length > 5 && !historyExpanded}}"
|
||||
bindtap="expandHistory"
|
||||
hover-class="oh-expand-hover"
|
||||
hover-stay-time="80"
|
||||
>
|
||||
<view class="oh-triangle"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="empty" wx:else>
|
||||
|
||||
@@ -7,17 +7,29 @@
|
||||
.loading { display: flex; flex-direction: column; gap: 24rpx; }
|
||||
.skeleton { height: 120rpx; background: linear-gradient(90deg, #1c1c1e 25%, #2c2c2e 50%, #1c1c1e 75%); background-size: 200% 100%; animation: skeleton 1.5s ease-in-out infinite; border-radius: 24rpx; }
|
||||
@keyframes skeleton { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||
.orders-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.order-item { display: flex; align-items: center; justify-content: space-between; padding: 24rpx; background: #1c1c1e; border-radius: 24rpx; }
|
||||
.order-item:active { opacity: 0.92; }
|
||||
.order-info { flex: 1; min-width: 0; }
|
||||
.order-title-row { display: flex; align-items: flex-start; gap: 12rpx; margin-bottom: 8rpx; }
|
||||
.order-unlock-icon { font-size: 26rpx; line-height: 1.35; opacity: 0.55; flex-shrink: 0; }
|
||||
.order-title { font-size: 28rpx; color: #fff; flex: 1; min-width: 0; }
|
||||
.order-time { font-size: 22rpx; color: rgba(255,255,255,0.4); }
|
||||
.order-right { text-align: right; }
|
||||
.order-amount { font-size: 28rpx; font-weight: 600; color: #00CED1; display: block; margin-bottom: 4rpx; }
|
||||
.order-status { font-size: 22rpx; color: rgba(255,255,255,0.4); }
|
||||
.order-history-card { background: #1c1c1e; border-radius: 24rpx; padding: 28rpx 24rpx 16rpx; }
|
||||
.order-history-head { padding: 0 8rpx 16rpx 8rpx; }
|
||||
.order-history-icon { width: 40rpx; height: 40rpx; opacity: 0.92; }
|
||||
.oh-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.oh-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 24rpx; background: #252525; border-radius: 20rpx;
|
||||
}
|
||||
.oh-row:active { opacity: 0.92; }
|
||||
.oh-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; flex: 1; }
|
||||
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; }
|
||||
.oh-index { font-size: 28rpx; color: #6B7280; font-family: monospace; flex-shrink: 0; }
|
||||
.oh-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.oh-sub { font-size: 22rpx; color: rgba(255,255,255,0.4); }
|
||||
.oh-link { font-size: 24rpx; color: #00CED1; font-weight: 500; flex-shrink: 0; margin-left: 16rpx; }
|
||||
.oh-expand { display: flex; justify-content: center; align-items: center; padding: 8rpx 0 8rpx; margin-top: 8rpx; }
|
||||
.oh-expand-hover { opacity: 0.65; }
|
||||
.oh-triangle {
|
||||
width: 0; height: 0;
|
||||
border-left: 16rpx solid transparent;
|
||||
border-right: 16rpx solid transparent;
|
||||
border-top: 20rpx solid rgba(0, 206, 209, 0.85);
|
||||
}
|
||||
.empty { display: flex; flex-direction: column; align-items: center; padding: 96rpx; }
|
||||
.empty-icon { font-size: 96rpx; margin-bottom: 24rpx; opacity: 0.5; }
|
||||
.empty-text { font-size: 28rpx; color: rgba(255,255,255,0.4); }
|
||||
|
||||
@@ -17,8 +17,8 @@ const accessManager = require('../../utils/chapterAccessManager')
|
||||
const readingTracker = require('../../utils/readingTracker')
|
||||
const { parseScene } = require('../../utils/scene.js')
|
||||
const contentParser = require('../../utils/contentParser.js')
|
||||
const soulBridge = require('../../utils/soulBridge.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
const app = getApp()
|
||||
|
||||
@@ -93,7 +93,7 @@ Page({
|
||||
// 价格
|
||||
sectionPrice: 1,
|
||||
fullBookPrice: 9.9,
|
||||
totalSections: 0, // 来自 app.getTotalSections() 或 book/parts
|
||||
totalSections: 62,
|
||||
|
||||
// 弹窗
|
||||
showShareModal: false,
|
||||
@@ -116,21 +116,69 @@ Page({
|
||||
// 余额(用于余额支付)
|
||||
walletBalance: 0,
|
||||
|
||||
// 未解锁时显示的预览比例(来自文章详情,用于付费墙「已阅读X%」)
|
||||
previewPercent: 20,
|
||||
|
||||
// 审核模式:隐藏购买按钮
|
||||
auditMode: false,
|
||||
|
||||
// 分润比例(来自 config.shareRate,用于分享提示文案)
|
||||
shareRate: 90,
|
||||
|
||||
// 好友从代付分享进入:待自动领取的 requestSn
|
||||
pendingGiftRequestSn: '',
|
||||
|
||||
// 朋友圈单页模式(scene 1154 / systemInfo.mode):无法登录与支付,仅引导「前往小程序」
|
||||
readSinglePageMode: false,
|
||||
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal)
|
||||
momentsPaywallExpanded: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* 是否处于朋友圈等「单页预览」环境。
|
||||
* 兼容:部分机型/基础库首帧 getSystemInfoSync().mode 未就绪,需结合 launch/enter scene 1154、getWindowInfo。
|
||||
* 命中时同步 app.globalData.isSinglePageMode,保证 ensureFullAppForAuth 与页内 wx:if 一致。
|
||||
*/
|
||||
_detectReadSinglePage() {
|
||||
try {
|
||||
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
|
||||
if (launch && Number(launch.scene) === 1154) {
|
||||
app.globalData.isSinglePageMode = true
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
const enter = typeof wx.getEnterOptionsSync === 'function' ? wx.getEnterOptionsSync() : null
|
||||
if (enter && Number(enter.scene) === 1154) {
|
||||
app.globalData.isSinglePageMode = true
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
const win = typeof wx.getWindowInfo === 'function' ? wx.getWindowInfo() : null
|
||||
if (win && win.mode === 'singlePage') {
|
||||
app.globalData.isSinglePageMode = true
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
const sys = wx.getSystemInfoSync()
|
||||
if (sys && sys.mode === 'singlePage') {
|
||||
app.globalData.isSinglePageMode = true
|
||||
}
|
||||
} catch (e) {}
|
||||
return !!app.globalData.isSinglePageMode
|
||||
},
|
||||
|
||||
/** 单页模式下点「购买本章」:触觉反馈 + 展开极简说明;引导靠页内文案 + 底栏箭头,不再弹长 Modal */
|
||||
onUnlockTapInSinglePage() {
|
||||
trackClick('read', 'btn_click', '单页_解锁引导')
|
||||
try {
|
||||
wx.vibrateShort({ type: 'light' })
|
||||
} catch (e) {}
|
||||
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
|
||||
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
|
||||
}
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
const sp = this._detectReadSinglePage()
|
||||
this.setData({
|
||||
auditMode: app.globalData.auditMode || false,
|
||||
readSinglePageMode: sp,
|
||||
...(sp ? {} : { momentsPaywallExpanded: false }),
|
||||
})
|
||||
},
|
||||
|
||||
async onLoad(options) {
|
||||
@@ -194,7 +242,8 @@ Page({
|
||||
loading: true,
|
||||
accessState: 'unknown',
|
||||
pendingGiftRequestSn: giftRequestSn || '',
|
||||
totalSections: app.getTotalSections()
|
||||
readSinglePageMode: this._detectReadSinglePage(),
|
||||
momentsPaywallExpanded: false,
|
||||
})
|
||||
|
||||
if (ref) {
|
||||
@@ -205,12 +254,9 @@ Page({
|
||||
|
||||
try {
|
||||
const config = await accessManager.fetchLatestConfig()
|
||||
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
|
||||
this.setData({
|
||||
sectionPrice: config.prices?.section ?? 1,
|
||||
fullBookPrice: config.prices?.fullbook ?? 9.9,
|
||||
shareRate,
|
||||
totalSections: app.getTotalSections()
|
||||
fullBookPrice: config.prices?.fullbook ?? 9.9
|
||||
})
|
||||
|
||||
// 统一:先拉章节数据,用 isFree/price===0 判断免费
|
||||
@@ -245,9 +291,10 @@ Page({
|
||||
}
|
||||
}
|
||||
|
||||
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
||||
if (canAccess) {
|
||||
readingTracker.init(id)
|
||||
} else {
|
||||
app.touchRecentSection(id)
|
||||
}
|
||||
|
||||
// 5. 导航:文章详情已带 prev/next
|
||||
@@ -380,15 +427,13 @@ Page({
|
||||
chapterTitle: res.chapterTitle || ''
|
||||
}
|
||||
if (res.mid) updates.sectionMid = res.mid
|
||||
if (res.previewPercent != null && res.previewPercent >= 1 && res.previewPercent <= 100) {
|
||||
updates.previewPercent = res.previewPercent
|
||||
}
|
||||
this.setData(updates)
|
||||
// 写入本地缓存(存 displayContent,供离线/重试降级使用)
|
||||
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
|
||||
if (accessManager.canAccessFullContent(accessState)) {
|
||||
app.markSectionAsRead(id)
|
||||
}
|
||||
app.touchRecentSection(id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
|
||||
@@ -407,6 +452,7 @@ Page({
|
||||
partTitle: cached.partTitle || '',
|
||||
chapterTitle: cached.chapterTitle || ''
|
||||
})
|
||||
app.touchRecentSection(id)
|
||||
console.log('[Read] 从本地缓存加载成功')
|
||||
return
|
||||
}
|
||||
@@ -683,13 +729,71 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 存客宝留资:统一 soulBridge.submitCkbLead(与会员详情点头像同链路)
|
||||
// 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重
|
||||
async _doMentionAddFriend(targetUserId, targetNickname) {
|
||||
await soulBridge.submitCkbLead(app, {
|
||||
targetUserId,
|
||||
targetNickname,
|
||||
source: 'article_mention'
|
||||
})
|
||||
const app = getApp()
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录后再添加好友',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
const myUserId = app.globalData.userInfo.id
|
||||
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
||||
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
try {
|
||||
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
|
||||
if (profileRes?.success && profileRes.data) {
|
||||
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
|
||||
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
wx.showModal({
|
||||
title: '补全手机号',
|
||||
content: '请填写手机号(必填),便于对方联系您。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/ckb/lead',
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId: myUserId,
|
||||
phone: phone || undefined,
|
||||
wechatId: wechatId || undefined,
|
||||
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
|
||||
targetUserId,
|
||||
targetNickname: targetNickname || undefined,
|
||||
source: 'article_mention'
|
||||
}
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
// 分享弹窗
|
||||
@@ -740,11 +844,7 @@ Page({
|
||||
const sys = wx.getSystemInfoSync()
|
||||
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
|
||||
if (isSinglePage) {
|
||||
wx.showModal({
|
||||
title: '朋友圈单页',
|
||||
content: '当前为朋友圈单页,无法发起代付支付。请点击底部「前往小程序」进入完整版后再操作。',
|
||||
showCancel: false
|
||||
})
|
||||
this.onUnlockTapInSinglePage()
|
||||
return
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -798,8 +898,24 @@ Page({
|
||||
}
|
||||
const payParams = payRes.data.payParams
|
||||
const orderSn = payRes.data.orderSn
|
||||
await soulBridge.requestWxJsapiPayment(payParams)
|
||||
await soulBridge.syncOrderStatusQuery(app, orderSn)
|
||||
await new Promise((resolve, reject) => {
|
||||
wx.requestPayment({
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonceStr,
|
||||
package: payParams.package,
|
||||
signType: payParams.signType || 'RSA',
|
||||
paySign: payParams.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
// 3) 主动同步(与其他支付流程一致)
|
||||
if (orderSn) {
|
||||
try {
|
||||
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
wx.showToast({ title: '支付成功', icon: 'success' })
|
||||
this.setData({ giftPaid: true, giftRequestSn: requestSn, giftPaying: false })
|
||||
@@ -817,7 +933,8 @@ Page({
|
||||
|
||||
// 复制链接
|
||||
copyLink() {
|
||||
const referralCode = app.getMyReferralCode() || ''
|
||||
const userInfo = app.globalData.userInfo
|
||||
const referralCode = userInfo?.referralCode || ''
|
||||
const shareUrl = `https://soul.quwanzhi.com/read/${this.data.sectionId}${referralCode ? '?ref=' + referralCode : ''}`
|
||||
|
||||
wx.setClipboardData({
|
||||
@@ -833,10 +950,9 @@ Page({
|
||||
copyShareText() {
|
||||
const { section } = this.data
|
||||
|
||||
const total = app.getTotalSections()
|
||||
const shareText = `🔥 刚看完这篇《${section?.title || '卡若创业派对'}》,太上头了!
|
||||
|
||||
${total}个真实商业案例,每个都是从0到1的实战经验。私域运营、资源整合、商业变现,干货满满。
|
||||
62个真实商业案例,每个都是从0到1的实战经验。私域运营、资源整合、商业变现,干货满满。
|
||||
|
||||
推荐给正在创业或想创业的朋友,搜"卡若创业派对"小程序就能看!
|
||||
|
||||
@@ -872,17 +988,9 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
return { title, path }
|
||||
},
|
||||
|
||||
// 底部「分享到朋友圈」按钮点击:微信不支持 button open-type=shareTimeline,只能通过右上角菜单分享,点击时引导用户
|
||||
onShareTimelineTap() {
|
||||
wx.showToast({
|
||||
title: '请点击右上角「...」→ 分享到朋友圈',
|
||||
icon: 'none',
|
||||
duration: 2500
|
||||
})
|
||||
},
|
||||
|
||||
// 右下角悬浮按钮:分享到朋友圈(复制文案 + 引导点右上角)
|
||||
// 分享到朋友圈:复制摘要 + 引导用户用右上角「···」发圈(无 open-type=shareTimeline)
|
||||
shareToMoments() {
|
||||
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
|
||||
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
|
||||
const raw = (this.data.content || '')
|
||||
.replace(/<[^>]+>/g, '\n')
|
||||
@@ -901,7 +1009,7 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
wx.hideToast()
|
||||
wx.showModal({
|
||||
title: '分享到朋友圈',
|
||||
content: '文案已复制。\n\n请点击右上角「···」菜单,选择「分享到朋友圈」即可发布。',
|
||||
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
@@ -928,21 +1036,10 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
|
||||
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
|
||||
showLoginModal() {
|
||||
// 朋友圈等单页模式下,不直接弹登录,用官方推荐的方式引导用户「前往小程序」
|
||||
try {
|
||||
const sys = wx.getSystemInfoSync()
|
||||
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
|
||||
if (isSinglePage) {
|
||||
wx.showModal({
|
||||
title: '请前往完整小程序',
|
||||
content: '当前为朋友圈单页,仅支持部分浏览。想登录继续阅读,请点击底部「前往小程序」后再操作。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Read] 检测单页模式失败,回退为正常登录流程:', e)
|
||||
// 单页模式无法弹登录组件:页内已说明「前往小程序」,不再弹 Modal
|
||||
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
||||
this.onUnlockTapInSinglePage()
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.setData({ showLoginModal: true })
|
||||
@@ -1026,6 +1123,10 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
|
||||
// 购买章节 - 直接调起支付
|
||||
async handlePurchaseSection() {
|
||||
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
||||
this.onUnlockTapInSinglePage()
|
||||
return
|
||||
}
|
||||
trackClick('read', 'btn_click', '购买章节_' + this.data.sectionId)
|
||||
console.log('[Pay] 点击购买章节按钮')
|
||||
wx.showLoading({ title: '处理中...', mask: true })
|
||||
@@ -1045,6 +1146,10 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
|
||||
// 购买全书 - 直接调起支付
|
||||
async handlePurchaseFullBook() {
|
||||
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
||||
this.onUnlockTapInSinglePage()
|
||||
return
|
||||
}
|
||||
console.log('[Pay] 点击购买全书按钮')
|
||||
wx.showLoading({ title: '处理中...', mask: true })
|
||||
|
||||
@@ -1063,6 +1168,14 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
// 处理支付 - 调用真实微信支付接口
|
||||
async processPayment(type, sectionId, amount) {
|
||||
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
|
||||
|
||||
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
||||
try {
|
||||
wx.hideLoading()
|
||||
} catch (e) {}
|
||||
this.onUnlockTapInSinglePage()
|
||||
return
|
||||
}
|
||||
|
||||
const userInfo = app.globalData.userInfo
|
||||
if (userInfo?.id) {
|
||||
@@ -1072,10 +1185,10 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
if (needProfile) {
|
||||
const res = await new Promise(resolve => {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: '购买前请先完善头像和昵称',
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后',
|
||||
title: '设置头像与昵称',
|
||||
content: '支付订单会关联你的对外展示信息,请先设置头像与昵称,避免账单与对方看到默认占位。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
success: resolve
|
||||
})
|
||||
})
|
||||
@@ -1132,7 +1245,7 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
try {
|
||||
// 0. 尝试余额支付(若余额足够)
|
||||
const userId = app.globalData.userInfo?.id
|
||||
const referralCode = soulBridge.getReferralCodeForPay(app)
|
||||
const referralCode = wx.getStorageSync('referral_code') || ''
|
||||
if (userId) {
|
||||
try {
|
||||
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
|
||||
@@ -1196,9 +1309,14 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
let paymentData = null
|
||||
|
||||
try {
|
||||
// 获取章节完整名称用于支付描述
|
||||
const sectionTitle = this.data.section?.title || sectionId
|
||||
const description = soulBridge.buildSectionPayDescription(type, sectionId, sectionTitle)
|
||||
const referralCode = soulBridge.getReferralCodeForPay(app)
|
||||
const description = type === 'fullbook'
|
||||
? '《一场Soul的创业实验》全书'
|
||||
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
|
||||
|
||||
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
|
||||
const referralCode = wx.getStorageSync('referral_code') || ''
|
||||
const res = await app.request('/api/miniprogram/pay', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
@@ -1229,7 +1347,7 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
title: '支付通道维护中',
|
||||
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
|
||||
confirmText: '复制微信号',
|
||||
cancelText: '稍后再说',
|
||||
cancelText: '关闭',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
wx.setClipboardData({
|
||||
@@ -1250,11 +1368,18 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
console.log('[Pay] 调起微信支付, paymentData:', paymentData)
|
||||
|
||||
try {
|
||||
await soulBridge.requestWxJsapiPayment(paymentData)
|
||||
|
||||
await this.callWechatPay(paymentData)
|
||||
|
||||
// 4. 【关键】主动向微信查询订单状态并同步到本地(不依赖回调,解决订单一直 created 的问题)
|
||||
const orderSn = paymentData._orderSn || paymentData.orderSn
|
||||
await soulBridge.syncOrderStatusQuery(app, orderSn)
|
||||
if (orderSn) console.log('[Pay] 已主动同步订单状态:', orderSn)
|
||||
if (orderSn) {
|
||||
try {
|
||||
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
|
||||
console.log('[Pay] 已主动同步订单状态:', orderSn)
|
||||
} catch (e) {
|
||||
console.warn('[Pay] 主动同步订单失败,继续刷新购买状态:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 【标准流程】刷新权限并解锁内容
|
||||
console.log('[Pay] 微信支付成功!')
|
||||
@@ -1342,6 +1467,7 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '购买成功', icon: 'success' })
|
||||
checkAndExecute('after_pay', this)
|
||||
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
@@ -1389,6 +1515,21 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
}
|
||||
},
|
||||
|
||||
// 调用微信支付
|
||||
callWechatPay(paymentData) {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.requestPayment({
|
||||
timeStamp: paymentData.timeStamp,
|
||||
nonceStr: paymentData.nonceStr,
|
||||
package: paymentData.package,
|
||||
signType: paymentData.signType || 'MD5',
|
||||
paySign: paymentData.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
// 跳转到上一篇
|
||||
goToPrev() {
|
||||
if (this.data.prevSection) {
|
||||
@@ -1412,6 +1553,25 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
wx.navigateTo({ url: '/pages/referral/referral' })
|
||||
},
|
||||
|
||||
/** 海报 canvas 在弹层渲染后偶现取不到 node,多次重试 */
|
||||
async _queryPosterCanvasNode(maxTry = 10, delayMs = 100) {
|
||||
for (let i = 0; i < maxTry; i++) {
|
||||
const node = await new Promise((resolve) => {
|
||||
wx.createSelectorQuery()
|
||||
.in(this)
|
||||
.select('#posterCanvas')
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
if (res && res[0] && res[0].node) resolve(res[0])
|
||||
else resolve(null)
|
||||
})
|
||||
})
|
||||
if (node) return node
|
||||
await new Promise((r) => setTimeout(r, delayMs))
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
// 生成海报(Canvas 2D API)
|
||||
async generatePoster() {
|
||||
wx.showLoading({ title: '生成中...' })
|
||||
@@ -1422,6 +1582,7 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
if (typeof wx.nextTick === 'function') wx.nextTick(resolve)
|
||||
else setTimeout(resolve, 50)
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 120))
|
||||
|
||||
try {
|
||||
const { section, contentParagraphs, sectionId, sectionMid } = this.data
|
||||
@@ -1439,18 +1600,12 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
if (qrRes.success && qrRes.image) qrcodeImage = qrRes.image
|
||||
} catch (_) {}
|
||||
|
||||
const canvasNode = await new Promise((resolve, reject) => {
|
||||
wx.createSelectorQuery().in(this)
|
||||
.select('#posterCanvas')
|
||||
.fields({ node: true, size: true })
|
||||
.exec(res => {
|
||||
if (res && res[0] && res[0].node) resolve(res[0])
|
||||
else reject(new Error('canvas node not found'))
|
||||
})
|
||||
})
|
||||
const canvasNode = await this._queryPosterCanvasNode()
|
||||
if (!canvasNode) {
|
||||
throw new Error('canvas node not found')
|
||||
}
|
||||
|
||||
const canvas = canvasNode.node
|
||||
const ctx = canvas.getContext('2d')
|
||||
let dpr = 2
|
||||
try {
|
||||
if (typeof wx.getWindowInfo === 'function') {
|
||||
@@ -1461,73 +1616,100 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
} catch (_) {
|
||||
dpr = 2
|
||||
}
|
||||
const width = 300
|
||||
const height = 450
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
// 布局尺寸:优先用节点测量;为 0 时回退 300×450(避免真机 query 过早得到 0 导致空白)
|
||||
const layoutW = (canvasNode.width && canvasNode.width > 1) ? Math.round(canvasNode.width) : 300
|
||||
const layoutH = (canvasNode.height && canvasNode.height > 1) ? Math.round(canvasNode.height) : 450
|
||||
canvas.width = Math.max(1, Math.floor(layoutW * dpr))
|
||||
canvas.height = Math.max(1, Math.floor(layoutH * dpr))
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('canvas 2d not supported')
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
const grd = ctx.createLinearGradient(0, 0, 0, height)
|
||||
grd.addColorStop(0, '#1a1a2e')
|
||||
grd.addColorStop(1, '#16213e')
|
||||
ctx.fillStyle = grd
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
const paintPoster = async () => {
|
||||
const w = layoutW
|
||||
const h = layoutH
|
||||
const grd = ctx.createLinearGradient(0, 0, 0, h)
|
||||
grd.addColorStop(0, '#1a1a2e')
|
||||
grd.addColorStop(1, '#16213e')
|
||||
ctx.fillStyle = grd
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
|
||||
ctx.fillStyle = '#00CED1'
|
||||
ctx.fillRect(0, 0, width, 4)
|
||||
ctx.fillStyle = '#00CED1'
|
||||
ctx.fillRect(0, 0, w, 4)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.fillText('📚 卡若创业派对', 20, 35)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '14px sans-serif'
|
||||
ctx.fillText('卡若创业派对', 20, 35)
|
||||
|
||||
ctx.font = '18px sans-serif'
|
||||
ctx.fillStyle = '#ffffff'
|
||||
const title = section?.title || '精彩内容'
|
||||
const titleLines = this.wrapText2d(ctx, title, width - 40)
|
||||
let y = 70
|
||||
titleLines.forEach(line => { ctx.fillText(line, 20, y); y += 26 })
|
||||
ctx.font = '18px sans-serif'
|
||||
ctx.fillStyle = '#ffffff'
|
||||
const title = section?.title || '精彩内容'
|
||||
const titleLines = this.wrapText2d(ctx, title, w - 40)
|
||||
let y = 70
|
||||
titleLines.forEach((line) => { ctx.fillText(line, 20, y); y += 26 })
|
||||
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(20, y + 10)
|
||||
ctx.lineTo(width - 20, y + 10)
|
||||
ctx.stroke()
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(20, y + 10)
|
||||
ctx.lineTo(w - 20, y + 10)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)'
|
||||
y += 30
|
||||
const summary = contentParagraphs.slice(0, 3).join(' ').slice(0, 150) + '...'
|
||||
const summaryLines = this.wrapText2d(ctx, summary, width - 40)
|
||||
summaryLines.slice(0, 6).forEach(line => { ctx.fillText(line, 20, y); y += 20 })
|
||||
|
||||
ctx.fillStyle = 'rgba(0,206,209,0.1)'
|
||||
ctx.fillRect(0, height - 100, width, 100)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.fillText('长按识别小程序码', 20, height - 60)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
|
||||
|
||||
if (qrcodeImage) {
|
||||
try {
|
||||
const fs = wx.getFileSystemManager()
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
|
||||
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
|
||||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||
const img = canvas.createImage()
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
img.onerror = reject
|
||||
img.src = filePath
|
||||
})
|
||||
ctx.drawImage(img, width - 85, height - 85, 70, 70)
|
||||
} catch (_) {
|
||||
this.drawQRPlaceholder2d(ctx, width, height)
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.8)'
|
||||
y += 30
|
||||
let paras = Array.isArray(contentParagraphs) ? contentParagraphs.filter(Boolean) : []
|
||||
if (!paras.length && this.data.content) {
|
||||
const plain = String(this.data.content)
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (plain) paras = [plain.slice(0, 400)]
|
||||
}
|
||||
const rawSummary = paras.slice(0, 3).join(' ').trim() || '卡若创业派对 · 真实商业故事'
|
||||
const summary = rawSummary.length > 160 ? rawSummary.slice(0, 157) + '...' : rawSummary
|
||||
const summaryLines = this.wrapText2d(ctx, summary, w - 40)
|
||||
summaryLines.slice(0, 6).forEach((line) => { ctx.fillText(line, 20, y); y += 20 })
|
||||
|
||||
ctx.fillStyle = 'rgba(0,206,209,0.1)'
|
||||
ctx.fillRect(0, h - 100, w, 100)
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.fillText('长按识别小程序码', 20, h - 60)
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.6)'
|
||||
ctx.font = '11px sans-serif'
|
||||
ctx.fillText('长按小程序码阅读全文', 20, h - 38)
|
||||
|
||||
if (qrcodeImage) {
|
||||
try {
|
||||
const fs = wx.getFileSystemManager()
|
||||
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
|
||||
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
|
||||
fs.writeFileSync(filePath, base64Data, 'base64')
|
||||
const img = canvas.createImage()
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
img.onerror = reject
|
||||
img.src = filePath
|
||||
})
|
||||
ctx.drawImage(img, w - 85, h - 85, 70, 70)
|
||||
} catch (_) {
|
||||
this.drawQRPlaceholder2d(ctx, w, h)
|
||||
}
|
||||
} else {
|
||||
this.drawQRPlaceholder2d(ctx, w, h)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof canvas.requestAnimationFrame === 'function') {
|
||||
await new Promise((resolve, reject) => {
|
||||
canvas.requestAnimationFrame(() => {
|
||||
paintPoster().then(resolve).catch(reject)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.drawQRPlaceholder2d(ctx, width, height)
|
||||
await paintPoster()
|
||||
}
|
||||
|
||||
wx.hideLoading()
|
||||
@@ -1635,13 +1817,11 @@ ${total}个真实商业案例,每个都是从0到1的实战经验。私域运
|
||||
|
||||
try {
|
||||
const config = await accessManager.fetchLatestConfig()
|
||||
const shareRate = (config && config.shareRate != null) ? config.shareRate : 90
|
||||
this.setData({
|
||||
sectionPrice: config.prices?.section ?? 1,
|
||||
fullBookPrice: config.prices?.fullbook ?? 9.9,
|
||||
shareRate
|
||||
fullBookPrice: config.prices?.fullbook ?? 9.9
|
||||
})
|
||||
|
||||
|
||||
// 重新拉取章节,用 isFree/price 判断免费
|
||||
const chapterRes = await app.request({
|
||||
url: this._getChapterUrl({}),
|
||||
|
||||
@@ -89,21 +89,21 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 分享操作区 -->
|
||||
<!-- 分享区:仅好友/海报/代付;完整小程序发圈见右下角悬浮钮 -->
|
||||
<view class="action-section">
|
||||
<view class="action-row-inline">
|
||||
<view class="action-btn-inline btn-share-inline" bindtap="onShareTimelineTap">
|
||||
<icon name="megaphone" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">分享到朋友圈</text>
|
||||
</view>
|
||||
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
|
||||
<button plain class="action-share-native action-tile-unified" open-type="share" hover-class="action-share-native-hover" hover-stop-propagation>
|
||||
<icon name="share" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">分享给好友</text>
|
||||
</button>
|
||||
<button plain class="action-share-native action-tile-unified" bindtap="generatePoster" hover-class="action-share-native-hover" hover-stop-propagation>
|
||||
<icon name="image" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">生成海报</text>
|
||||
</view>
|
||||
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
|
||||
</button>
|
||||
<button plain class="action-share-native action-tile-unified" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}" hover-class="action-share-native-hover" hover-stop-propagation>
|
||||
<icon name="gift" size="32" color="#00CED1" customClass="action-icon-small"></icon>
|
||||
<text class="action-text-small">代付分享</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
<view class="share-tip-inline" wx:if="{{!auditMode}}">
|
||||
<text class="share-tip-text">分享后好友购买,你可获得 {{shareRate || 90}}% 收益</text>
|
||||
@@ -122,23 +122,36 @@
|
||||
<!-- 渐变遮罩 -->
|
||||
<view class="fade-mask"></view>
|
||||
|
||||
<!-- 付费墙 - 未登录:显示购买按钮(朋友圈/分享场景) -->
|
||||
<view class="paywall">
|
||||
<!-- 付费墙 - 未登录:完整小程序登录+价;朋友圈单页与正文同款「购买本章 ¥1」,点后再展开极简说明 -->
|
||||
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
|
||||
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="paywall-title">解锁完整内容</text>
|
||||
<text class="paywall-desc">已预览部分内容,登录并购买后阅读全文</text>
|
||||
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
|
||||
<block wx:if="{{readSinglePageMode}}">
|
||||
<text class="paywall-title">解锁全文</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥1</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
|
||||
</block>
|
||||
|
||||
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
|
||||
<text class="login-btn-text">手机号登录后购买</text>
|
||||
</view>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
<block wx:else>
|
||||
<text class="paywall-title">解锁完整内容</text>
|
||||
<text class="paywall-desc">已预览部分内容,登录并支付 ¥1 后阅读全文</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
|
||||
<text class="login-btn-text">手机号登录后购买</text>
|
||||
</view>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 章节导航 -->
|
||||
@@ -182,39 +195,47 @@
|
||||
<view class="fade-mask"></view>
|
||||
|
||||
<!-- 付费墙 - 已登录未购买 -->
|
||||
<view class="paywall">
|
||||
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
|
||||
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
|
||||
<text class="paywall-title">解锁完整内容</text>
|
||||
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
|
||||
|
||||
<!-- 购买选项(审核模式隐藏) -->
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<!-- 购买本章 - 直接调起支付 -->
|
||||
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
|
||||
</view>
|
||||
|
||||
<!-- 解锁全书 - 只有购买超过3章才显示 -->
|
||||
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
|
||||
<view class="btn-left">
|
||||
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
|
||||
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
|
||||
</view>
|
||||
<view class="btn-right">
|
||||
<text class="btn-price">¥{{fullBookPrice || 9.9}}</text>
|
||||
<text class="btn-discount">省82%</text>
|
||||
<block wx:if="{{readSinglePageMode}}">
|
||||
<text class="paywall-title">解锁全文</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥1</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">预览不可付款,请点底部「前往小程序」。</text>
|
||||
</block>
|
||||
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
<!-- 代付分享:帮好友购买(审核模式隐藏) -->
|
||||
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
|
||||
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
|
||||
<text class="gift-share-text">代付分享</text>
|
||||
</view>
|
||||
<block wx:else>
|
||||
<text class="paywall-title">解锁完整内容</text>
|
||||
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
|
||||
<view class="purchase-options" wx:if="{{!auditMode}}">
|
||||
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
|
||||
</view>
|
||||
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
|
||||
<view class="btn-left">
|
||||
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
|
||||
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
|
||||
</view>
|
||||
<view class="btn-right">
|
||||
<text class="btn-price">¥{{fullBookPrice}}</text>
|
||||
<text class="btn-discount">省82%</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
|
||||
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
|
||||
<text class="gift-share-text">代付分享</text>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 章节导航 -->
|
||||
@@ -270,9 +291,9 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 海报生成弹窗 -->
|
||||
<view class="modal-overlay" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
|
||||
<view class="modal-content poster-modal" catchtap="stopPropagation">
|
||||
<!-- 海报生成弹窗:居中 + z-index 高于右下角悬浮,避免「空白」错觉 -->
|
||||
<view class="modal-overlay modal-overlay-center" wx:if="{{showPosterModal}}" bindtap="closePosterModal">
|
||||
<view class="modal-content modal-content-center poster-modal" catchtap="stopPropagation">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">生成海报</text>
|
||||
<view class="modal-close" bindtap="closePosterModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
@@ -357,8 +378,12 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
|
||||
<view class="fab-share" bindtap="shareToMoments">
|
||||
<icon name="share" size="44" color="#0f172a" customClass="fab-share-icon"></icon>
|
||||
<!-- 单页预览(朋友圈):指向底栏「前往小程序」,字少;完整小程序仍保留发圈悬浮钮 -->
|
||||
<view class="singlepage-launch-pointer" wx:if="{{readSinglePageMode && momentsPaywallExpanded}}" aria-hidden="true">
|
||||
<view class="singlepage-launch-pointer__arrow">↘</view>
|
||||
</view>
|
||||
|
||||
<view class="fab-share-moments" wx:if="{{!readSinglePageMode}}" bindtap="shareToMoments" hover-class="fab-share-moments-hover" aria-label="分享到朋友圈">
|
||||
<icon name="share" size="44" color="#ffffff" customClass="fab-share-moments-icon"></icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -280,6 +280,36 @@
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
/* 朋友圈单页:与完整小程序同款购买行,留白略紧 */
|
||||
.paywall--single-preview {
|
||||
padding-top: 40rpx;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
.paywall--single-preview .paywall-icon {
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.paywall--single-preview .paywall-title {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.paywall-desc--moments-expanded {
|
||||
margin-top: 28rpx !important;
|
||||
margin-bottom: 0 !important;
|
||||
font-size: 26rpx !important;
|
||||
line-height: 1.45;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
/* 朋友圈单页:未点解锁前的一行轻提示 */
|
||||
.paywall-hint-compact {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.48);
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin-bottom: 36rpx;
|
||||
line-height: 1.55;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
|
||||
.paywall-desc {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
@@ -360,6 +390,33 @@
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
.paywall-singlepage-note {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 朋友圈单页付费墙底部:与正文文末「分享赚收益」文案一致 */
|
||||
.paywall-share-earn-wrap {
|
||||
margin-top: 28rpx;
|
||||
padding-top: 24rpx;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
.paywall-share-earn-wrap .share-tip-text {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.paywall-share-earn-sub {
|
||||
margin-top: 12rpx !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.paywall-tip {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
@@ -470,7 +527,14 @@
|
||||
.action-row-inline {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 16rpx;
|
||||
align-items: stretch;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
/* 底部三按钮:同一底纹与描边(好友 / 海报 / 代付) */
|
||||
.action-tile-unified {
|
||||
background: rgba(255, 255, 255, 0.06) !important;
|
||||
border: 2rpx solid rgba(0, 206, 209, 0.28) !important;
|
||||
}
|
||||
|
||||
.action-btn-inline {
|
||||
@@ -489,21 +553,38 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.action-btn-inline::after {
|
||||
/* 分享给好友:原生 button + open-type=share,样式与 action-btn-inline 对齐 */
|
||||
.action-share-native {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 96rpx;
|
||||
margin: 0;
|
||||
padding: 24rpx 12rpx;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
line-height: normal;
|
||||
font-size: inherit;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
.action-share-native::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-share-inline {
|
||||
background: rgba(7, 193, 96, 0.15);
|
||||
border: 2rpx solid rgba(7, 193, 96, 0.3);
|
||||
button.action-share-native {
|
||||
color: inherit;
|
||||
}
|
||||
.action-share-native-hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.btn-poster-inline {
|
||||
background: rgba(255, 215, 0, 0.15);
|
||||
border: 2rpx solid rgba(255, 215, 0, 0.3);
|
||||
.action-btn-inline::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
.action-icon-small {
|
||||
font-size: 28rpx;
|
||||
flex-shrink: 0;
|
||||
@@ -597,7 +678,8 @@
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
/* 高于右下角悬浮钮,避免弹层被盖住或 canvas 不可见 */
|
||||
z-index: 10050;
|
||||
}
|
||||
|
||||
.modal-overlay-center {
|
||||
@@ -1201,6 +1283,9 @@
|
||||
/* ===== 海报弹窗 ===== */
|
||||
.poster-modal {
|
||||
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.poster-preview {
|
||||
@@ -1251,44 +1336,54 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== 右下角悬浮分享按钮 ===== */
|
||||
.fab-share {
|
||||
/* ===== 右下角:分享到朋友圈(固定悬浮小圆钮,不在文末分享行) ===== */
|
||||
.fab-share-moments {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
width:70rpx!important;
|
||||
bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
height: 70rpx;
|
||||
border-radius: 60rpx;
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%);
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.4);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
z-index: 9999;
|
||||
display:flex;
|
||||
box-shadow: 0 8rpx 32rpx rgba(0, 206, 209, 0.42);
|
||||
z-index: 9980;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.fab-share::after {
|
||||
border: none;
|
||||
.fab-share-moments-hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.fab-share:active {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 206, 209, 0.5);
|
||||
.fab-share-moments:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
padding:16rpx;
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.fab-share-icon {
|
||||
.fab-share-moments-icon {
|
||||
font-size: 44rpx;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 2rpx 4rpx rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
/* 朋友圈单页:点「购买本章」后,箭头指向底栏「前往小程序」(字少,仅符号) */
|
||||
.singlepage-launch-pointer {
|
||||
position: fixed;
|
||||
right: 48rpx;
|
||||
bottom: calc(168rpx + env(safe-area-inset-bottom));
|
||||
z-index: 99985;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.singlepage-launch-pointer__arrow {
|
||||
font-size: 56rpx;
|
||||
line-height: 1;
|
||||
color: #00CED1;
|
||||
text-shadow: 0 0 20rpx rgba(0, 206, 209, 0.55);
|
||||
transform: rotate(0deg);
|
||||
animation: singlepage-launch-pulse 1.25s ease-in-out infinite;
|
||||
}
|
||||
@keyframes singlepage-launch-pulse {
|
||||
0%, 100% { opacity: 0.75; transform: translate(0, 0) scale(1); }
|
||||
50% { opacity: 1; transform: translate(8rpx, 10rpx) scale(1.06); }
|
||||
}
|
||||
|
||||
178
miniprogram/pages/reading-records/reading-records.js
Normal file
178
miniprogram/pages/reading-records/reading-records.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 阅读记录:最近阅读 + 已读章节(与「我的」数据源一致)
|
||||
*/
|
||||
const app = getApp()
|
||||
const { cleanSingleLineField } = require('../../utils/contentParser.js')
|
||||
|
||||
function titleFromBookData(sectionId, bookFlat) {
|
||||
const row = bookFlat.find((s) => s.id === sectionId)
|
||||
return cleanSingleLineField(
|
||||
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
|
||||
) || `章节 ${sectionId}`
|
||||
}
|
||||
|
||||
function midFromBookData(sectionId, bookFlat) {
|
||||
const row = bookFlat.find((s) => s.id === sectionId)
|
||||
return row?.mid ?? row?.MID ?? 0
|
||||
}
|
||||
|
||||
function mergeRecentFromLocal(apiList) {
|
||||
const normalized = Array.isArray(apiList)
|
||||
? apiList.map((item) => ({
|
||||
id: item.id,
|
||||
mid: item.mid,
|
||||
title: cleanSingleLineField(item.title || '') || `章节 ${item.id}`
|
||||
}))
|
||||
: []
|
||||
if (normalized.length > 0) return normalized
|
||||
try {
|
||||
const progressData = wx.getStorageSync('reading_progress') || {}
|
||||
const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
|
||||
return Object.keys(progressData)
|
||||
.map((id) => ({
|
||||
id,
|
||||
ts: progressData[id]?.lastOpenAt || progressData[id]?.last_open_at || 0
|
||||
}))
|
||||
.filter((e) => e.id)
|
||||
.sort((a, b) => b.ts - a.ts)
|
||||
.slice(0, 20)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
mid: midFromBookData(e.id, bookFlat),
|
||||
title: titleFromBookData(e.id, bookFlat)
|
||||
}))
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
statusBarHeight: 44,
|
||||
isLoggedIn: false,
|
||||
focus: 'all',
|
||||
recentList: [],
|
||||
readAllList: [],
|
||||
recentSectionTitle: '最近阅读',
|
||||
readSectionTitle: '已读章节'
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
const focus = (options.focus === 'recent' || options.focus === 'read') ? options.focus : 'all'
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44,
|
||||
focus
|
||||
})
|
||||
this._applyMpUiTitles()
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.setData({ isLoggedIn: !!(app.globalData.isLoggedIn && app.globalData.userInfo?.id) })
|
||||
this._applyMpUiTitles()
|
||||
if (this.data.isLoggedIn) this.loadData()
|
||||
},
|
||||
|
||||
_applyMpUiTitles() {
|
||||
const my = app.globalData.configCache?.mpConfig?.mpUi?.myPage || {}
|
||||
this.setData({
|
||||
recentSectionTitle: my.recentReadTitle || '最近阅读',
|
||||
readSectionTitle: my.readStatLabel || '已读章节'
|
||||
})
|
||||
},
|
||||
|
||||
async _ensureBookFlat() {
|
||||
let flat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : []
|
||||
if (flat.length) return flat
|
||||
try {
|
||||
const r = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
const list = r?.data
|
||||
if (Array.isArray(list) && list.length) {
|
||||
app.globalData.bookData = list
|
||||
return list
|
||||
}
|
||||
} catch (_) {}
|
||||
return []
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) return
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: `/api/miniprogram/user/dashboard-stats?userId=${encodeURIComponent(userId)}`,
|
||||
silent: true
|
||||
})
|
||||
const bookFlat = await this._ensureBookFlat()
|
||||
let recent = []
|
||||
let readIds = []
|
||||
if (res?.success && res.data) {
|
||||
const apiRecent = Array.isArray(res.data.recentChapters) ? res.data.recentChapters : []
|
||||
recent = mergeRecentFromLocal(apiRecent)
|
||||
readIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : []
|
||||
} else {
|
||||
recent = mergeRecentFromLocal([])
|
||||
}
|
||||
try {
|
||||
const progressData = wx.getStorageSync('reading_progress') || {}
|
||||
const fromKeys = Object.keys(progressData).filter(Boolean)
|
||||
const stored = wx.getStorageSync('readSectionIds')
|
||||
const fromStored = Array.isArray(stored) ? stored.filter(Boolean) : []
|
||||
const fromGlobal = Array.isArray(app.globalData.readSectionIds)
|
||||
? app.globalData.readSectionIds.filter(Boolean)
|
||||
: []
|
||||
readIds = [...new Set([...readIds, ...fromGlobal, ...fromStored, ...fromKeys])]
|
||||
} catch (_) {}
|
||||
if (readIds.length === 0 && recent.length > 0) {
|
||||
readIds = recent.map((r) => r.id)
|
||||
}
|
||||
const readAllList = readIds.map((id) => ({
|
||||
id,
|
||||
mid: midFromBookData(id, bookFlat),
|
||||
title: titleFromBookData(id, bookFlat)
|
||||
}))
|
||||
this.setData({ recentList: recent, readAllList })
|
||||
} catch (e) {
|
||||
console.warn('[reading-records]', e)
|
||||
try {
|
||||
const bookFlat = await this._ensureBookFlat()
|
||||
let readIds = Array.isArray(app.globalData.readSectionIds) ? [...app.globalData.readSectionIds] : []
|
||||
if (!readIds.length) {
|
||||
try {
|
||||
const stored = wx.getStorageSync('readSectionIds')
|
||||
if (Array.isArray(stored)) readIds = [...stored]
|
||||
} catch (_) {}
|
||||
}
|
||||
const recent = mergeRecentFromLocal([])
|
||||
if (!readIds.length && recent.length) readIds = recent.map((r) => r.id)
|
||||
const readAllList = readIds.map((id) => ({
|
||||
id,
|
||||
mid: midFromBookData(id, bookFlat),
|
||||
title: titleFromBookData(id, bookFlat)
|
||||
}))
|
||||
this.setData({ recentList: recent, readAllList })
|
||||
} catch (_) {
|
||||
this.setData({ recentList: [], readAllList: [] })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
goRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
const mid = e.currentTarget.dataset.mid
|
||||
if (!id) return
|
||||
const q = mid ? `mid=${mid}` : `id=${id}`
|
||||
wx.navigateTo({ url: `/pages/read/read?${q}` })
|
||||
},
|
||||
|
||||
goChapters() {
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
goLogin() {
|
||||
wx.switchTab({ url: '/pages/my/my' })
|
||||
},
|
||||
|
||||
goBack() {
|
||||
getApp().goBackOrToHome()
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/reading-records/reading-records.json
Normal file
4
miniprogram/pages/reading-records/reading-records.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
51
miniprogram/pages/reading-records/reading-records.wxml
Normal file
51
miniprogram/pages/reading-records/reading-records.wxml
Normal file
@@ -0,0 +1,51 @@
|
||||
<view class="page">
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
|
||||
<text class="nav-title">阅读记录</text>
|
||||
<view class="nav-placeholder"></view>
|
||||
</view>
|
||||
<view style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
|
||||
<view class="content" wx:if="{{isLoggedIn}}">
|
||||
<view class="section" wx:if="{{focus === 'all' || focus === 'recent'}}">
|
||||
<view class="section-head">
|
||||
<text class="section-title">{{recentSectionTitle}}</text>
|
||||
<text class="section-count" wx:if="{{recentList.length > 0}}">{{recentList.length}} 条</text>
|
||||
</view>
|
||||
<view class="list" wx:if="{{recentList.length > 0}}">
|
||||
<view class="row" wx:for="{{recentList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
|
||||
<text class="row-idx">{{index + 1}}</text>
|
||||
<text class="row-title">{{item.title}}</text>
|
||||
<text class="row-link">阅读</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty" wx:else>
|
||||
<text class="empty-t">暂无最近阅读</text>
|
||||
<text class="empty-a" bindtap="goChapters">去目录逛逛</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section" wx:if="{{focus === 'all' || focus === 'read'}}">
|
||||
<view class="section-head">
|
||||
<text class="section-title">{{readSectionTitle}}</text>
|
||||
<text class="section-count" wx:if="{{readAllList.length > 0}}">共 {{readAllList.length}} 章</text>
|
||||
</view>
|
||||
<view class="list" wx:if="{{readAllList.length > 0}}">
|
||||
<view class="row" wx:for="{{readAllList}}" wx:key="id" bindtap="goRead" data-id="{{item.id}}" data-mid="{{item.mid}}">
|
||||
<text class="row-idx">{{index + 1}}</text>
|
||||
<text class="row-title">{{item.title}}</text>
|
||||
<text class="row-link">阅读</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="empty" wx:else>
|
||||
<text class="empty-t">暂无已读记录</text>
|
||||
<text class="empty-a" bindtap="goChapters">去目录选章阅读</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="guest" wx:else>
|
||||
<text class="guest-t">登录后查看阅读记录</text>
|
||||
<view class="guest-btn" bindtap="goLogin">去登录</view>
|
||||
</view>
|
||||
</view>
|
||||
25
miniprogram/pages/reading-records/reading-records.wxss
Normal file
25
miniprogram/pages/reading-records/reading-records.wxss
Normal file
@@ -0,0 +1,25 @@
|
||||
.page { min-height: 100vh; background: #000; padding-bottom: 48rpx; }
|
||||
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.92); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
|
||||
.nav-back { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: center; }
|
||||
.nav-title { font-size: 36rpx; font-weight: 600; color: #00CED1; flex: 1; text-align: center; }
|
||||
.nav-placeholder { width: 72rpx; }
|
||||
.content { padding: 32rpx; }
|
||||
.section { margin-bottom: 48rpx; }
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 24rpx; }
|
||||
.section-title { font-size: 32rpx; font-weight: 600; color: #e5e7eb; }
|
||||
.section-count { font-size: 24rpx; color: #6b7280; }
|
||||
.list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.row {
|
||||
display: flex; align-items: center; gap: 20rpx;
|
||||
padding: 24rpx; background: #1c1c1e; border-radius: 20rpx;
|
||||
}
|
||||
.row:active { opacity: 0.9; }
|
||||
.row-idx { font-size: 26rpx; color: #6b7280; font-family: monospace; flex-shrink: 0; }
|
||||
.row-title { flex: 1; min-width: 0; font-size: 28rpx; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.row-link { font-size: 24rpx; color: #00CED1; flex-shrink: 0; }
|
||||
.empty { padding: 48rpx 24rpx; text-align: center; }
|
||||
.empty-t { display: block; font-size: 28rpx; color: #6b7280; margin-bottom: 24rpx; }
|
||||
.empty-a { font-size: 28rpx; color: #00CED1; }
|
||||
.guest { padding: 120rpx 48rpx; text-align: center; }
|
||||
.guest-t { display: block; font-size: 30rpx; color: #9ca3af; margin-bottom: 32rpx; }
|
||||
.guest-btn { display: inline-block; padding: 20rpx 48rpx; background: #00CED1; color: #000; font-size: 28rpx; font-weight: 600; border-radius: 24rpx; }
|
||||
@@ -35,7 +35,7 @@ Page({
|
||||
minWithdrawAmount: 10, // 最低提现金额,从 referral/data 获取
|
||||
bindingDays: 30, // 绑定期天数,从 referral/data 获取
|
||||
userDiscount: 5, // 好友购买优惠%,从 referral/data 获取
|
||||
hasWechatId: false, // 是否已绑定微信号(未绑定时需引导去设置)
|
||||
hasWechatId: false, // 是否已绑定微信号(未绑定时提示去设置页)
|
||||
|
||||
// === 统计数据 ===
|
||||
referralCount: 0, // 总推荐人数
|
||||
@@ -46,6 +46,8 @@ Page({
|
||||
|
||||
// 绑定用户列表
|
||||
showBindingList: true,
|
||||
showShareSection: false,
|
||||
showEarningsDetails: false,
|
||||
activeTab: 'active',
|
||||
activeBindings: [],
|
||||
convertedBindings: [],
|
||||
@@ -63,10 +65,13 @@ Page({
|
||||
posterReferralLink: '',
|
||||
posterNickname: '',
|
||||
posterNicknameInitial: '',
|
||||
posterCaseCount: 0 // 来自 app.getTotalSections(),initData 后更新
|
||||
/** 推广海报推荐人头像:优先微信头像,否则按 MBTI 用后台映射 */
|
||||
posterDisplayAvatar: '',
|
||||
posterCaseCount: 62
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
this._focusTarget = (options && options.focus) || ""
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
this.initData()
|
||||
// 启用分享到朋友圈(需同时有 onShareAppMessage 和 onShareTimeline;menus 在 Android 支持,iOS 为 Beta)
|
||||
@@ -174,7 +179,6 @@ Page({
|
||||
minWithdrawAmount: minWithdrawAmount,
|
||||
bindingDays: realData?.bindingDays ?? 30,
|
||||
userDiscount: realData?.userDiscount ?? 5,
|
||||
posterCaseCount: app.getTotalSections(),
|
||||
|
||||
// 统计
|
||||
referralCount: realData?.referralCount || realData?.stats?.totalBindings || activeBindings.length + convertedBindings.length,
|
||||
@@ -208,12 +212,37 @@ Page({
|
||||
|
||||
// 隐藏加载提示
|
||||
wx.hideLoading()
|
||||
this._applyFocusTarget()
|
||||
} else {
|
||||
// 未登录时也隐藏loading
|
||||
this.setData({ isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
toggleShareSection() {
|
||||
this.setData({ showShareSection: !this.data.showShareSection })
|
||||
},
|
||||
|
||||
toggleEarningsDetails() {
|
||||
this.setData({ showEarningsDetails: !this.data.showEarningsDetails })
|
||||
},
|
||||
|
||||
_applyFocusTarget() {
|
||||
const t = this._focusTarget || ''
|
||||
if (!t) return
|
||||
if (t === 'bindings') {
|
||||
this.setData({ showBindingList: true, activeTab: 'active', currentBindings: this.data.activeBindings })
|
||||
setTimeout(() => wx.pageScrollTo({ selector: '#binding-card', duration: 220 }), 80)
|
||||
this._focusTarget = ''
|
||||
return
|
||||
}
|
||||
if (t === 'earnings') {
|
||||
setTimeout(() => wx.pageScrollTo({ selector: '#earnings-card', duration: 220 }), 80)
|
||||
this._focusTarget = ''
|
||||
return
|
||||
}
|
||||
},
|
||||
// 切换Tab
|
||||
switchTab(e) {
|
||||
const tab = e.currentTarget.dataset.tab
|
||||
@@ -343,11 +372,20 @@ Page({
|
||||
|
||||
console.log('[Poster] 小程序码已保存到本地:', filePath)
|
||||
|
||||
try {
|
||||
if (app.loadMbtiAvatarsMap) await app.loadMbtiAvatarsMap()
|
||||
} catch (_) {}
|
||||
const u = this.data.userInfo || app.globalData.userInfo || {}
|
||||
const posterDisplayAvatar = app.resolveAvatarWithMbti
|
||||
? app.resolveAvatarWithMbti(u.avatar, u.mbti)
|
||||
: ''
|
||||
|
||||
this.setData({
|
||||
posterQrSrc: filePath,
|
||||
posterReferralLink: '', // 小程序版本不再使用 H5 链接
|
||||
posterNickname: nickname,
|
||||
posterNicknameInitial: (nickname || '用').charAt(0),
|
||||
posterDisplayAvatar,
|
||||
isGeneratingPoster: false
|
||||
})
|
||||
wx.hideLoading()
|
||||
@@ -355,7 +393,7 @@ Page({
|
||||
console.error('[Poster] 生成二维码失败:', e)
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '生成失败', icon: 'none' })
|
||||
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '' })
|
||||
this.setData({ showPosterModal: false, isGeneratingPoster: false, posterQrSrc: '', posterReferralLink: '', posterDisplayAvatar: '' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -546,10 +584,9 @@ Page({
|
||||
|
||||
// 分享到朋友圈 - 随机文案
|
||||
shareToMoments() {
|
||||
const total = app.getTotalSections()
|
||||
// 10条随机文案,基于书的内容
|
||||
const shareTexts = [
|
||||
`🔥 在派对房里听到的真实故事,比虚构的小说精彩100倍!\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n${total}个真实案例,搜"卡若创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
|
||||
`🔥 在派对房里听到的真实故事,比虚构的小说精彩100倍!\n\n电动车出租月入5万、私域一年赚1000万、一个人的公司月入10万...\n\n62个真实案例,搜"卡若创业派对"小程序看全部!\n\n#创业 #私域 #商业`,
|
||||
|
||||
`💡 今天终于明白:会赚钱的人,都在用"流量杠杆"\n\n抖音、Soul、飞书...同一套内容,撬动不同平台的流量。\n\n《卡若创业派对》里的实战方法,受用终身!\n\n#流量 #副业 #创业派对`,
|
||||
|
||||
@@ -600,7 +637,7 @@ Page({
|
||||
}
|
||||
// 任意金额可提现,不再设最低限额
|
||||
|
||||
// 未绑定微信号时引导去设置
|
||||
// 未绑定微信号:说明提现到账核对所需
|
||||
if (!hasWechatId) {
|
||||
wx.showModal({
|
||||
title: '请先绑定微信号',
|
||||
@@ -656,31 +693,37 @@ Page({
|
||||
method: 'POST',
|
||||
data: { userId, amount }
|
||||
})
|
||||
|
||||
|
||||
wx.hideLoading()
|
||||
wx.showModal({
|
||||
title: '提现申请已提交 ✅',
|
||||
content: res.message || '正在审核中,通过后会自动到账您的微信零钱',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
this.initData()
|
||||
|
||||
if (res.success) {
|
||||
wx.showModal({
|
||||
title: '提现申请已提交 ✅',
|
||||
content: res.message || '正在审核中,通过后会自动到账您的微信零钱',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
|
||||
// 刷新数据(此时待审核金额会增加,可提现金额会减少)
|
||||
this.initData()
|
||||
} else {
|
||||
if (res.needBind || res.needBindWechat) {
|
||||
wx.showModal({
|
||||
title: res.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
|
||||
content: res.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
|
||||
confirmText: '去绑定',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: res.message || res.error || '提现失败', icon: 'none', duration: 3000 })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
console.error('[Referral] 提现失败:', e)
|
||||
const r = e.response || {}
|
||||
if (r.needBind || r.needBindWechat) {
|
||||
wx.showModal({
|
||||
title: r.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
|
||||
content: r.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
|
||||
confirmText: '去绑定',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
wx.showToast({ title: e.message || '提现失败,请重试', icon: 'none', duration: 3000 })
|
||||
wx.showToast({ title: '提现失败,请重试', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -856,9 +899,8 @@ Page({
|
||||
onShareTimeline() {
|
||||
const ref = this.data.referralCode || app.getMyReferralCode()
|
||||
console.log('[Referral] 分享到朋友圈,推荐码:', ref)
|
||||
const total = app.getTotalSections()
|
||||
return {
|
||||
title: `卡若创业派对 - ${total}个真实商业案例`,
|
||||
title: `卡若创业派对 - 62个真实商业案例`,
|
||||
query: ref ? `ref=${ref}` : ''
|
||||
// 不设置 imageUrl,使用小程序默认截图
|
||||
}
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 收益卡片 - 对齐 Next.js -->
|
||||
<view class="earnings-card">
|
||||
<view class="earnings-bg"></view>
|
||||
<view id="earnings-card" class="earnings-card">
|
||||
<view class="earnings-main">
|
||||
<view class="earnings-header">
|
||||
<view class="earnings-left">
|
||||
@@ -87,7 +86,7 @@
|
||||
</view>
|
||||
|
||||
<!-- 绑定用户列表 -->
|
||||
<view class="binding-card">
|
||||
<view id="binding-card" class="binding-card">
|
||||
<view class="binding-header" bindtap="toggleBindingList">
|
||||
<view class="binding-title">
|
||||
<image class="binding-icon-img" src="/assets/icons/users.svg" mode="aspectFit"></image>
|
||||
@@ -161,9 +160,14 @@
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 分享按钮 - 1:1 对齐 Next.js -->
|
||||
<view class="share-section">
|
||||
<view class="share-item" bindtap="generatePoster">
|
||||
<!-- 分享按钮(可折叠,默认收起) -->
|
||||
<view class="section-collapse-card">
|
||||
<view class="section-collapse-header" bindtap="toggleShareSection">
|
||||
<text class="section-collapse-title">分享推广</text>
|
||||
<icon name="{{showShareSection ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
|
||||
</view>
|
||||
<view class="share-section" wx:if="{{showShareSection}}">
|
||||
<view class="share-item" bindtap="generatePoster">
|
||||
<view class="share-icon poster">
|
||||
<image class="icon-share-btn" src="/assets/icons/image.svg" mode="aspectFit"></image>
|
||||
</view>
|
||||
@@ -196,13 +200,15 @@
|
||||
<image class="share-arrow-icon" src="/assets/icons/arrow-right.svg" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 收益明细 - 增强版 -->
|
||||
<view class="earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
|
||||
<view class="detail-header">
|
||||
<text class="detail-title">收益明细</text>
|
||||
<!-- 收益明细(可折叠,默认收起) -->
|
||||
<view class="section-collapse-card earnings-detail-card" wx:if="{{earningsDetails.length > 0}}">
|
||||
<view class="section-collapse-header" bindtap="toggleEarningsDetails">
|
||||
<text class="section-collapse-title">收益明细</text>
|
||||
<icon name="{{showEarningsDetails ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)"></icon>
|
||||
</view>
|
||||
<view class="detail-list">
|
||||
<view class="detail-list" wx:if="{{showEarningsDetails}}">
|
||||
<view class="detail-item" wx:for="{{earningsDetails}}" wx:key="id">
|
||||
<!-- 买家头像 -->
|
||||
<view class="detail-avatar-wrap">
|
||||
@@ -297,7 +303,8 @@
|
||||
<!-- 推荐人 -->
|
||||
<view class="poster-recommender">
|
||||
<view class="poster-avatar">
|
||||
<text class="poster-avatar-text">{{posterNicknameInitial}}</text>
|
||||
<image wx:if="{{posterDisplayAvatar}}" class="poster-avatar-img" src="{{posterDisplayAvatar}}" mode="aspectFill" />
|
||||
<text wx:else class="poster-avatar-text">{{posterNicknameInitial}}</text>
|
||||
</view>
|
||||
<text class="poster-recommender-text">{{posterNickname}} 推荐你来读</text>
|
||||
</view>
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
/* ???????? - 1:1??Web?? */
|
||||
@import './earnings-detail-styles.wxss';
|
||||
|
||||
/* 分销中心 - 主结构样式 */
|
||||
.page { min-height: 100vh; background: #000; padding-bottom: 64rpx; }
|
||||
|
||||
/* ??? */
|
||||
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.9); backdrop-filter: blur(40rpx); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
|
||||
.nav-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
|
||||
background: rgba(0,0,0,0.92);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 32rpx; height: 88rpx;
|
||||
border-bottom: 1rpx solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.nav-left { display: flex; gap: 16rpx; align-items: center; }
|
||||
.nav-back { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
|
||||
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(70%); }
|
||||
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; flex: 1; text-align: center; }
|
||||
.nav-right-placeholder { width: 144rpx; }
|
||||
.nav-btn { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.content { padding: 24rpx; width: 100%; box-sizing: border-box; }
|
||||
|
||||
/* ?????? */
|
||||
.expiring-banner { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3); border-radius: 24rpx; margin-bottom: 24rpx; }
|
||||
.expiring-banner {
|
||||
display: flex; align-items: center; gap: 24rpx; padding: 24rpx;
|
||||
background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3);
|
||||
border-radius: 24rpx; margin-bottom: 24rpx;
|
||||
}
|
||||
.banner-icon { width: 80rpx; height: 80rpx; background: rgba(255,165,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.icon-bell-warning { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(64%) sepia(89%) saturate(1363%) hue-rotate(4deg) brightness(101%) contrast(102%); }
|
||||
.banner-content { flex: 1; }
|
||||
.banner-title { font-size: 28rpx; font-weight: 500; color: #fff; display: block; }
|
||||
.banner-desc { font-size: 24rpx; color: rgba(255,165,0,0.8); margin-top: 4rpx; display: block; }
|
||||
|
||||
/* ???? - ?? Next.js */
|
||||
.earnings-card { position: relative; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden; width: 100%; box-sizing: border-box; }
|
||||
.earnings-bg { position: absolute; top: 0; right: 0; width: 256rpx; height: 256rpx; background: rgba(0,206,209,0.15); border-radius: 50%; filter: blur(100rpx); }
|
||||
.earnings-card {
|
||||
position: relative; background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1);
|
||||
border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden;
|
||||
width: 100%; box-sizing: border-box;
|
||||
}
|
||||
.earnings-main { position: relative; }
|
||||
.earnings-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32rpx; }
|
||||
.earnings-left { display: flex; align-items: center; gap: 16rpx; }
|
||||
@@ -34,26 +45,17 @@
|
||||
.earnings-right { text-align: right; }
|
||||
.earnings-value { font-size: 60rpx; font-weight: 700; color: #fff; display: block; line-height: 1; }
|
||||
.pending-text { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; display: block; }
|
||||
|
||||
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; box-shadow: 0 8rpx 24rpx rgba(0,206,209,0.3); }
|
||||
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); box-shadow: none; }
|
||||
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; }
|
||||
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); }
|
||||
.wechat-tip { display: block; font-size: 24rpx; color: rgba(255,165,0,0.9); margin-top: 16rpx; text-align: center; }
|
||||
.withdraw-records-link { display: block; margin-top: 16rpx; text-align: center; font-size: 26rpx; color: #00CED1; }
|
||||
|
||||
/* ???? - ?? Next.js 4??? */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
|
||||
.stat-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
|
||||
.stat-card { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
|
||||
.stat-value { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
|
||||
.stat-value.orange { color: #FFA500; }
|
||||
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.6); margin-top: 8rpx; display: block; }
|
||||
|
||||
/* ????? */
|
||||
.visit-stat { display: flex; align-items: center; justify-content: center; gap: 12rpx; padding: 20rpx 32rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 24rpx; }
|
||||
.visit-label { font-size: 24rpx; color: rgba(255,255,255,0.5); }
|
||||
.visit-value { font-size: 32rpx; font-weight: 700; color: #00CED1; }
|
||||
.visit-tip { font-size: 24rpx; color: rgba(255,255,255,0.5); }
|
||||
|
||||
/* ???? - ?? Next.js */
|
||||
.rules-card { background: rgba(0,206,209,0.05); border: 2rpx solid rgba(0,206,209,0.2); border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
|
||||
.rules-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
|
||||
.rules-icon { width: 64rpx; height: 64rpx; background: rgba(0,206,209,0.2); border-radius: 16rpx; display: flex; align-items: center; justify-content: center; }
|
||||
@@ -63,240 +65,89 @@
|
||||
.rule-item { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 2; display: block; margin-bottom: 4rpx; }
|
||||
.rule-item .gold { color: #FFD700; font-weight: 500; }
|
||||
.rule-item .brand { color: #00CED1; font-weight: 500; }
|
||||
.rule-item .orange { color: #FFA500; font-weight: 500; }
|
||||
|
||||
/* ?????? - ?? Next.js */
|
||||
.binding-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
|
||||
.binding-card { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
|
||||
.binding-header { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
|
||||
.binding-title { display: flex; align-items: center; gap: 12rpx; }
|
||||
.binding-icon-img { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
|
||||
.title-text { font-size: 30rpx; font-weight: 600; color: #fff; }
|
||||
.binding-count { font-size: 26rpx; color: rgba(255,255,255,0.5); }
|
||||
.toggle-icon { font-size: 24rpx; color: rgba(255,255,255,0.5); }
|
||||
|
||||
/* Tab?? */
|
||||
.binding-tabs { display: flex; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
|
||||
.tab-item { flex: 1; padding: 24rpx 0; text-align: center; font-size: 26rpx; color: rgba(255,255,255,0.5); position: relative; }
|
||||
.tab-item.tab-active { color: #00CED1; }
|
||||
.tab-item.tab-active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 80rpx; height: 4rpx; background: #00CED1; border-radius: 4rpx; }
|
||||
|
||||
/* ???? */
|
||||
.binding-list { max-height: 640rpx; overflow-y: auto; }
|
||||
.empty-state { padding: 80rpx 0; text-align: center; }
|
||||
.empty-icon { font-size: 64rpx; display: block; margin-bottom: 16rpx; }
|
||||
.empty-text { font-size: 26rpx; color: rgba(255,255,255,0.5); }
|
||||
|
||||
/* ?????? - ??? */
|
||||
.binding-item { display: flex; align-items: center; padding: 24rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); gap: 24rpx; }
|
||||
.binding-item:last-child { border-bottom: none; }
|
||||
|
||||
/* ?? */
|
||||
.user-avatar { width: 88rpx; height: 88rpx; border-radius: 50%; background: rgba(0,206,209,0.2); display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: 600; color: #00CED1; flex-shrink: 0; }
|
||||
.user-avatar.avatar-converted { background: rgba(76,175,80,0.2); color: #4CAF50; }
|
||||
.user-avatar.avatar-expired { background: rgba(158,158,158,0.2); color: #9E9E9E; }
|
||||
|
||||
/* ???? */
|
||||
.user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4rpx; }
|
||||
.user-name { font-size: 28rpx; color: #fff; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-time { font-size: 22rpx; color: rgba(255,255,255,0.5); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ???? */
|
||||
.user-status { flex-shrink: 0; text-align: right; display: flex; flex-direction: column; align-items: flex-end; gap: 4rpx; min-width: 100rpx; }
|
||||
.status-amount { font-size: 28rpx; color: #4CAF50; font-weight: 600; white-space: nowrap; }
|
||||
.status-order { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
|
||||
.status-time { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
|
||||
|
||||
/* ???? */
|
||||
.status-order, .status-time { font-size: 20rpx; color: rgba(255,255,255,0.5); white-space: nowrap; }
|
||||
.status-tag { font-size: 24rpx; font-weight: 600; padding: 6rpx 16rpx; border-radius: 16rpx; white-space: nowrap; }
|
||||
.status-tag.tag-green { background: rgba(76,175,80,0.2); color: #4CAF50; }
|
||||
.status-tag.tag-orange { background: rgba(255,165,0,0.2); color: #FFA500; }
|
||||
.status-tag.tag-red { background: rgba(244,67,54,0.2); color: #F44336; }
|
||||
.status-tag.tag-gray { background: rgba(158,158,158,0.2); color: #9E9E9E; }
|
||||
|
||||
/* ????? - ?? Next.js */
|
||||
.invite-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 40rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
|
||||
.invite-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
|
||||
.invite-title { font-size: 30rpx; font-weight: 600; color: #fff; }
|
||||
.invite-code-box { background: rgba(0,206,209,0.2); padding: 12rpx 24rpx; border-radius: 16rpx; }
|
||||
.invite-code { font-size: 28rpx; font-weight: 600; color: #00CED1; font-family: 'Courier New', monospace; letter-spacing: 2rpx; }
|
||||
.invite-tip { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 1.5; display: block; }
|
||||
.invite-tip .gold { color: #FFD700; }
|
||||
.invite-tip .brand { color: #00CED1; }
|
||||
|
||||
/* ?????? - ??? */
|
||||
.earnings-detail-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
|
||||
.detail-header { padding: 40rpx 40rpx 24rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
|
||||
.detail-title { font-size: 30rpx; font-weight: 600; color: #fff; }
|
||||
.detail-list { max-height: 480rpx; overflow-y: auto; padding: 16rpx 0; }
|
||||
|
||||
/* ??????? */
|
||||
.earnings-detail-card .detail-item { display: flex; align-items: center; gap: 24rpx; padding: 24rpx 40rpx; background: transparent; border-bottom: 2rpx solid rgba(255,255,255,0.03); }
|
||||
.earnings-detail-card .detail-item:last-child { border-bottom: none; }
|
||||
.earnings-detail-card .detail-item:active { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
/* ???? */
|
||||
.earnings-detail-card .detail-avatar-wrap { width: 88rpx; height: 88rpx; flex-shrink: 0; }
|
||||
.earnings-detail-card .detail-avatar { width: 100%; height: 100%; border-radius: 50%; border: 2rpx solid rgba(56, 189, 172, 0.2); }
|
||||
.earnings-detail-card .detail-avatar-text { width: 100%; height: 100%; border-radius: 50%; background: linear-gradient(135deg, #38bdac 0%, #2da396 100%); display: flex; align-items: center; justify-content: center; font-size: 36rpx; font-weight: 700; color: #ffffff; }
|
||||
|
||||
/* ???? */
|
||||
.earnings-detail-card .detail-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8rpx; }
|
||||
.earnings-detail-card .detail-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
|
||||
.earnings-detail-card .detail-buyer { font-size: 28rpx; font-weight: 500; color: #ffffff; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.earnings-detail-card .detail-amount { font-size: 32rpx; font-weight: 700; color: #38bdac; flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
/* ???? */
|
||||
.earnings-detail-card .detail-product { display: flex; align-items: baseline; gap: 4rpx; font-size: 24rpx; color: rgba(255, 255, 255, 0.6); min-width: 0; overflow: hidden; }
|
||||
.earnings-detail-card .detail-book { color: rgba(255, 255, 255, 0.7); font-weight: 500; max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.earnings-detail-card .detail-chapter { color: rgba(255, 255, 255, 0.5); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.earnings-detail-card .detail-time { font-size: 22rpx; color: rgba(255, 255, 255, 0.4); }
|
||||
|
||||
/* ???? - ?? Next.js */
|
||||
.share-section { display: flex; flex-direction: column; gap: 12rpx; width: 100%; margin-bottom: 24rpx; }
|
||||
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; border: none; margin: 0; text-align: left; width: 100%; box-sizing: border-box; }
|
||||
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; width: 100%; box-sizing: border-box; }
|
||||
.share-item::after { border: none; }
|
||||
.share-icon { width: 96rpx; height: 96rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
|
||||
.share-icon.poster { background: rgba(103,58,183,0.2); }
|
||||
.share-icon.wechat { background: rgba(7,193,96,0.2); }
|
||||
.share-icon.link { background: rgba(158,158,158,0.2); }
|
||||
.icon-share-btn { width: 48rpx; height: 48rpx; display: block; }
|
||||
.share-icon.poster .icon-share-btn { filter: brightness(0) saturate(100%) invert(37%) sepia(73%) saturate(2296%) hue-rotate(252deg) brightness(96%) contrast(92%); }
|
||||
.share-icon.wechat .icon-share-btn { filter: brightness(0) saturate(100%) invert(58%) sepia(91%) saturate(1255%) hue-rotate(105deg) brightness(96%) contrast(97%); }
|
||||
.share-icon.link .icon-share-btn { filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
|
||||
.share-info { flex: 1; text-align: left; }
|
||||
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; text-align: left; }
|
||||
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; text-align: left; }
|
||||
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
|
||||
.share-btn-wechat { line-height: normal; font-size: inherit; padding: 24rpx 32rpx !important; margin: 0 !important; width: 100% !important; }
|
||||
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; }
|
||||
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
|
||||
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%); }
|
||||
|
||||
.empty-earnings { background: rgba(28, 28, 30, 0.8); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 64rpx 40rpx; text-align: center; margin-bottom: 24rpx; }
|
||||
.empty-icon-wrapper { width: 128rpx; height: 128rpx; border-radius: 50%; background: rgba(28, 28, 30, 0.8); display: flex; align-items: center; justify-content: center; margin: 0 auto 32rpx; }
|
||||
.empty-gift-icon { width: 64rpx; height: 64rpx; display: block; filter: brightness(0) saturate(100%) invert(60%); }
|
||||
.empty-title { font-size: 30rpx; font-weight: 500; color: #fff; display: block; margin-bottom: 16rpx; }
|
||||
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.6); display: block; line-height: 1.5; }
|
||||
|
||||
/* ?????????????? + ???? + ???????? */
|
||||
/* ???????? backdrop-filter??????????????? */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 32rpx; box-sizing: border-box; }
|
||||
|
||||
.poster-dialog { width: 686rpx; border-radius: 24rpx; overflow: hidden; position: relative; background: transparent; }
|
||||
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); display: flex; align-items: center; justify-content: center; z-index: 5; font-size: 28rpx; }
|
||||
|
||||
/* ???? */
|
||||
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); display: flex; align-items: center; justify-content: center; z-index: 5; }
|
||||
.poster-card { position: relative; background: linear-gradient(135deg, #0a1628 0%, #0f2137 50%, #1a3a5c 100%); color: #fff; padding: 44rpx 40rpx 36rpx; }
|
||||
.poster-inner { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||
|
||||
/* ???? */
|
||||
/* ???????? filter: blur ??????????????? + ???? */
|
||||
.poster-glow { position: absolute; width: 320rpx; height: 320rpx; border-radius: 50%; opacity: 0.6; z-index: 1; }
|
||||
.poster-glow-left { top: -120rpx; left: -160rpx; background: rgba(0,206,209,0.12); box-shadow: 0 0 140rpx 40rpx rgba(0,206,209,0.18); }
|
||||
.poster-glow-right { bottom: -140rpx; right: -160rpx; background: rgba(255,215,0,0.10); box-shadow: 0 0 160rpx 50rpx rgba(255,215,0,0.14); }
|
||||
.poster-ring { position: absolute; width: 520rpx; height: 520rpx; border-radius: 50%; border: 2rpx solid rgba(0,206,209,0.06); left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 1; }
|
||||
|
||||
/* ???? */
|
||||
.poster-badges { display: flex; gap: 16rpx; margin-bottom: 24rpx; }
|
||||
.poster-badge { padding: 10rpx 22rpx; font-size: 20rpx; font-weight: 700; border-radius: 999rpx; border: 2rpx solid transparent; }
|
||||
.poster-badge-gold { background: rgba(255,215,0,0.18); color: #FFD700; border-color: rgba(255,215,0,0.28); }
|
||||
.poster-badge-brand { background: rgba(0,206,209,0.18); color: #00CED1; border-color: rgba(0,206,209,0.28); }
|
||||
|
||||
/* ?? */
|
||||
.poster-title { margin-bottom: 8rpx; }
|
||||
.poster-title-line1 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #00CED1; }
|
||||
.poster-title-line2 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #fff; margin-top: 6rpx; }
|
||||
.poster-subtitle { display: block; font-size: 22rpx; color: rgba(255,255,255,0.6); margin-bottom: 28rpx; }
|
||||
|
||||
/* ???? */
|
||||
.poster-stats { width: 100%; display: flex; gap: 16rpx; justify-content: center; margin-bottom: 24rpx; }
|
||||
.poster-stat { flex: 1; max-width: 190rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 16rpx; padding: 18rpx 10rpx; }
|
||||
.poster-stat-value { display: block; font-size: 44rpx; font-weight: 900; line-height: 1; }
|
||||
.poster-stat-label { display: block; font-size: 20rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; }
|
||||
.poster-stat-gold { color: #FFD700; }
|
||||
.poster-stat-brand { color: #00CED1; }
|
||||
.poster-stat-pink { color: #E91E63; }
|
||||
|
||||
/* ?? */
|
||||
.poster-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 10rpx; margin: 0 24rpx 26rpx; }
|
||||
.poster-tag { font-size: 20rpx; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 12rpx; padding: 6rpx 14rpx; }
|
||||
|
||||
/* ??? */
|
||||
.poster-recommender { display: flex; align-items: center; gap: 12rpx; background: rgba(0,206,209,0.10); border: 2rpx solid rgba(0,206,209,0.20); border-radius: 999rpx; padding: 12rpx 22rpx; margin-bottom: 22rpx; }
|
||||
.poster-avatar { width: 44rpx; height: 44rpx; border-radius: 22rpx; background: rgba(0,206,209,0.30); display: flex; align-items: center; justify-content: center; }
|
||||
.poster-avatar-text { font-size: 20rpx; font-weight: 800; color: #00CED1; }
|
||||
.poster-recommender-text { font-size: 22rpx; color: #00CED1; }
|
||||
|
||||
/* ??? */
|
||||
.poster-discount { width: 100%; background: linear-gradient(90deg, rgba(255,215,0,0.10) 0%, rgba(233,30,99,0.10) 100%); border: 2rpx solid rgba(255,215,0,0.20); border-radius: 18rpx; padding: 18rpx 18rpx; margin-bottom: 26rpx; }
|
||||
.poster-discount-text { font-size: 22rpx; color: rgba(255,255,255,0.80); }
|
||||
.poster-discount-highlight { color: #00CED1; font-weight: 800; }
|
||||
|
||||
/* ??? */
|
||||
.poster-qr-wrap { background: #fff; padding: 14rpx; border-radius: 16rpx; margin-bottom: 12rpx; box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.35); }
|
||||
.poster-qr-img { width: 240rpx; height: 240rpx; display: block; }
|
||||
.poster-qr-tip { font-size: 20rpx; color: rgba(255,255,255,0.40); margin-bottom: 8rpx; }
|
||||
.poster-code { font-size: 22rpx; font-family: monospace; letter-spacing: 2rpx; color: rgba(0,206,209,0.80); }
|
||||
|
||||
/* ??????? */
|
||||
.poster-footer { background: #fff; padding: 28rpx 28rpx 32rpx; display: flex; flex-direction: column; gap: 18rpx; }
|
||||
.poster-footer-tip { font-size: 22rpx; color: rgba(0,0,0,0.55); text-align: center; }
|
||||
.poster-footer-btn { height: 72rpx; border-radius: 18rpx; border: 2rpx solid rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: rgba(0,0,0,0.75); background: #fff; }
|
||||
|
||||
| ||||