feat: 小程序阅读记录与资料链路、管理端用户规则、API/VIP/推荐与运营脚本
- miniprogram: reading-records、imageUrl/mpNavigate、多页资料与 VIP 展示调整 - soul-admin: Users/Settings/UserDetailModal、dist 构建产物更新 - soul-api: user/vip/referral/ckb/db、MBTI 头像管理、user_rule_completion、迁移 SQL - .cursor: karuo-party 与飞书文档;.gitignore 忽略 .tmp_skill_bundle Made-with: Cursor
This commit is contained in:
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** 推送配置统一指向本开发群,避免复盘散落多个群。
|
||||
@@ -112,9 +112,11 @@ 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
|
||||
```
|
||||
@@ -140,7 +142,7 @@ python3 "$DIST_SCRIPT/distribute_all.py" --now
|
||||
### Phase 3:视频生产
|
||||
|
||||
1. **视频切片**:转录 → 高光识别 → 批量切片 → 增强
|
||||
2. **多平台分发**:成片 → 5平台发布(定时排期)
|
||||
2. **多平台分发**:成片 → 5 平台发布(默认智能错峰定时)
|
||||
|
||||
### Phase 4:文章内容
|
||||
|
||||
@@ -167,7 +169,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 自动化登录后提取
|
||||
|
||||
|
||||
@@ -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.3"
|
||||
updated: "2026-03-23"
|
||||
---
|
||||
|
||||
# 多平台分发 Skill(v4.0)
|
||||
# 多平台分发 Skill(v4.3)
|
||||
|
||||
> **核心原则**:API 发布为主,Playwright 为辅。确保确定性地分发到各平台。
|
||||
> **v4.0 变更**:视频号已切换为纯 API、统一元数据生成器、定时排期优化、简介/标签/分区自动填充。
|
||||
> **v4.3**:默认**静默**(不自动 `channels_login`);需弹窗时 `--auto-channels-login` 或 `CHANNELS_AUTO_LOGIN=1`(独立脚本)。**v4.2**:智能排期与去重下标对齐。
|
||||
|
||||
## 〇、执行原则(第一性原理)
|
||||
|
||||
- **视频号两步**:先扫码落盘 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,9 +48,12 @@ updated: "2026-03-11"
|
||||
```bash
|
||||
cd /Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本
|
||||
|
||||
# 定时排期:第1条立即,后续 30-120min 随机间隔
|
||||
# 默认智能错峰排期
|
||||
python3 distribute_all.py
|
||||
|
||||
# 旧版随机间隔
|
||||
python3 distribute_all.py --legacy-schedule
|
||||
|
||||
# 立即全部发布
|
||||
python3 distribute_all.py --now
|
||||
|
||||
@@ -54,24 +66,30 @@ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、定时排期(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` 为旧逻辑
|
||||
- 去重时排期与目录列表下标对齐
|
||||
|
||||
### 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 +109,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 +140,11 @@ 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 校验有效性
|
||||
|
||||
---
|
||||
|
||||
|
||||
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,6 +9,8 @@ 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'
|
||||
|
||||
@@ -101,25 +103,26 @@ App({
|
||||
|
||||
/** 正式版强制生产 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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -393,8 +396,8 @@ App({
|
||||
if (!this.globalData.isSinglePageMode) return true
|
||||
|
||||
wx.showModal({
|
||||
title: '请前往完整小程序',
|
||||
content: '当前为朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。',
|
||||
title: '请打开完整小程序',
|
||||
content: '当前是朋友圈预览,无法在这里登录或付款。请先点击屏幕底部「前往小程序」,进入完整版后再解锁本章。',
|
||||
showCancel: false,
|
||||
confirmText: '我知道了',
|
||||
})
|
||||
@@ -410,7 +413,7 @@ App({
|
||||
},
|
||||
|
||||
/**
|
||||
* 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname;新用户由登录处强制 redirectTo
|
||||
* 头像/昵称未改:老用户弹窗后跳 avatar-nickname;新用户由登录处强制 redirectTo
|
||||
* VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit,避免乱跳
|
||||
*/
|
||||
checkAvatarNicknameAndGuide() {
|
||||
@@ -429,10 +432,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' })
|
||||
}
|
||||
@@ -590,7 +593,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
|
||||
@@ -619,12 +622,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' })
|
||||
@@ -637,9 +640,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' })
|
||||
@@ -647,12 +650,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' })
|
||||
}
|
||||
@@ -691,6 +694,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 }),
|
||||
@@ -709,11 +723,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
|
||||
},
|
||||
|
||||
@@ -851,6 +899,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 去重,避免并发重复请求。
|
||||
@@ -870,68 +991,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()
|
||||
}
|
||||
showError(msg)
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
resolve(data)
|
||||
return
|
||||
}
|
||||
if (res.statusCode === 401) {
|
||||
this.logout()
|
||||
showError('未授权,请重新登录')
|
||||
reject(new Error('未授权'))
|
||||
return
|
||||
}
|
||||
// 4xx/5xx:优先用返回体的 message/error
|
||||
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
|
||||
showError(msg)
|
||||
reject(new Error(msg))
|
||||
},
|
||||
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') {
|
||||
@@ -1169,6 +1243,22 @@ 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "完善资料",
|
||||
"navigationBarTitleText": "头像与昵称",
|
||||
"usingComponents": {}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<!--卡若创业派对 - 头像昵称引导页,仅头像+昵称-->
|
||||
<!--卡若创业派对 - 头像与昵称设置页-->
|
||||
<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">头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
|
||||
</view>
|
||||
|
||||
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
|
||||
@@ -62,7 +61,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;
|
||||
|
||||
@@ -26,6 +26,7 @@ Page({
|
||||
|
||||
// 展开状态
|
||||
expandedPart: null,
|
||||
bookCollapsed: false,
|
||||
|
||||
// 已加载的篇章章节缓存 { partId: chapters }
|
||||
_loadedChapters: {},
|
||||
@@ -47,7 +48,11 @@ Page({
|
||||
partsLoading: true,
|
||||
|
||||
// 功能配置(搜索开关)
|
||||
searchEnabled: true
|
||||
searchEnabled: true,
|
||||
|
||||
// mp_config.mpUi.chaptersPage
|
||||
chaptersBookTitle: '一场SOUL的创业实验场',
|
||||
chaptersBookSubtitle: '来自Soul派对房的真实商业故事'
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -62,10 +67,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 +89,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 +109,6 @@ Page({
|
||||
totalSections = res.totalSections ?? 0
|
||||
fixedSections = res.fixedSections || []
|
||||
}
|
||||
const numbers = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十', '十一', '十二']
|
||||
const fixedMap = {}
|
||||
fixedSections.forEach(f => { fixedMap[f.id] = f.mid })
|
||||
const appendixList = [
|
||||
@@ -100,13 +116,14 @@ Page({
|
||||
{ id: 'appendix-2', title: '附录2|创业者自检清单', mid: fixedMap['appendix-2'] },
|
||||
{ id: 'appendix-3', title: '附录3|本书提到的工具和资源', mid: fixedMap['appendix-3'] }
|
||||
]
|
||||
const bookData = parts.map((p, idx) => ({
|
||||
const bookData = parts.map((p) => ({
|
||||
id: p.id,
|
||||
number: numbers[idx] || String(idx + 1),
|
||||
icon: p.icon || '',
|
||||
title: p.title,
|
||||
subtitle: p.subtitle || '',
|
||||
chapterCount: p.chapterCount || 0,
|
||||
chapters: [] // 展开时懒加载
|
||||
chapters: [],
|
||||
alwaysShow: (p.title || '').indexOf('每日派对干货') > -1
|
||||
}))
|
||||
app.globalData.totalSections = totalSections
|
||||
this.setData({
|
||||
@@ -186,6 +203,7 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this._applyChaptersMpUi()
|
||||
// 设置TabBar选中状态
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
const tabBar = this.getTabBar()
|
||||
@@ -225,7 +243,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,12 @@
|
||||
|
||||
<!-- 篇章列表 -->
|
||||
<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="aspectFit"/>
|
||||
<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,9 @@
|
||||
color: #ffffff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.part-icon-img {
|
||||
width: 64rpx; height: 64rpx; border-radius: 16rpx; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.part-info {
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
const app = getApp()
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { cleanSingleLineField } = require('../../utils/contentParser')
|
||||
const { navigateMpPath } = require('../../utils/mpNavigate.js')
|
||||
|
||||
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
|
||||
function isKaruoHostDuplicateName(displayName) {
|
||||
@@ -85,7 +86,19 @@ Page({
|
||||
searchEnabled: true,
|
||||
|
||||
// 审核模式:隐藏支付相关入口
|
||||
auditMode: false
|
||||
auditMode: false,
|
||||
|
||||
// mp_config.mpUi.homePage(后台系统设置 mpUi)
|
||||
mpUiLogoTitle: '卡若创业派对',
|
||||
mpUiLogoSubtitle: '来自派对房的真实故事',
|
||||
mpUiLinkKaruoText: '点击链接卡若',
|
||||
mpUiSearchPlaceholder: '搜索章节标题或内容...',
|
||||
mpUiBannerTag: '推荐',
|
||||
mpUiBannerReadMore: '点击阅读',
|
||||
mpUiSuperTitle: '超级个体',
|
||||
mpUiSuperLinkText: '获客入口',
|
||||
mpUiPickTitle: '精选推荐',
|
||||
mpUiLatestTitle: '最新新增'
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
@@ -111,6 +124,7 @@ Page({
|
||||
onShow() {
|
||||
console.log('[Index] onShow 触发')
|
||||
this.setData({ auditMode: app.globalData.auditMode || false })
|
||||
this._applyHomeMpUi()
|
||||
|
||||
// 设置TabBar选中状态
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
@@ -305,6 +319,30 @@ Page({
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
_applyHomeMpUi() {
|
||||
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
|
||||
this.setData({
|
||||
mpUiLogoTitle: String(h.logoTitle || '卡若创业派对').trim() || '卡若创业派对',
|
||||
mpUiLogoSubtitle: String(h.logoSubtitle || '来自派对房的真实故事').trim() || '来自派对房的真实故事',
|
||||
mpUiLinkKaruoText: String(h.linkKaruoText || '点击链接卡若').trim() || '点击链接卡若',
|
||||
mpUiSearchPlaceholder: String(h.searchPlaceholder || '搜索章节标题或内容...').trim() || '搜索章节标题或内容...',
|
||||
mpUiBannerTag: String(h.bannerTag || '推荐').trim() || '推荐',
|
||||
mpUiBannerReadMore: String(h.bannerReadMoreText || '点击阅读').trim() || '点击阅读',
|
||||
mpUiSuperTitle: String(h.superSectionTitle || '超级个体').trim() || '超级个体',
|
||||
mpUiSuperLinkText: String(h.superSectionLinkText || '获客入口').trim() || '获客入口',
|
||||
mpUiPickTitle: String(h.pickSectionTitle || '精选推荐').trim() || '精选推荐',
|
||||
mpUiLatestTitle: String(h.latestSectionTitle || '最新新增').trim() || '最新新增'
|
||||
})
|
||||
},
|
||||
|
||||
/** 超级个体右侧文案:默认跳转找伙伴 Tab(路径可由 homePage.superSectionLinkPath 配置) */
|
||||
goSuperSectionLink() {
|
||||
const p = String(
|
||||
app.globalData.configCache?.mpConfig?.mpUi?.homePage?.superSectionLinkPath || '/pages/match/match'
|
||||
).trim()
|
||||
if (p) navigateMpPath(p)
|
||||
},
|
||||
|
||||
async loadFeatureConfig() {
|
||||
try {
|
||||
const hasCachedFeatures = app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean'
|
||||
@@ -313,6 +351,7 @@ Page({
|
||||
searchEnabled: app.globalData.features.searchEnabled,
|
||||
auditMode: app.globalData.auditMode || false
|
||||
})
|
||||
this._applyHomeMpUi()
|
||||
return
|
||||
}
|
||||
const res = await app.getConfig()
|
||||
@@ -324,8 +363,10 @@ Page({
|
||||
app.globalData.features.searchEnabled = searchEnabled
|
||||
app.globalData.auditMode = auditMode
|
||||
this.setData({ searchEnabled, auditMode })
|
||||
this._applyHomeMpUi()
|
||||
} catch (e) {
|
||||
this.setData({ searchEnabled: true, auditMode: app.globalData.auditMode || false })
|
||||
this._applyHomeMpUi()
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -12,16 +12,11 @@
|
||||
<text class="logo-text">派</text>
|
||||
</view>
|
||||
<view class="logo-info">
|
||||
<text class="logo-title-text">卡若创业派对</text>
|
||||
<text class="logo-subtitle">来自派对房的真实故事</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>
|
||||
<text class="logo-title-text">{{mpUiLogoTitle}}</text>
|
||||
<text class="logo-subtitle">{{mpUiLogoSubtitle}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏(根据配置显示) -->
|
||||
@@ -29,7 +24,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 +33,26 @@
|
||||
<!-- 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>
|
||||
<text class="section-subtitle" bindtap="goSuperSectionLink">{{mpUiSuperLinkText}}</text>
|
||||
</view>
|
||||
<!-- 加载中:骨架动画 -->
|
||||
<view wx:if="{{superMembersLoading}}" class="super-loading">
|
||||
@@ -100,7 +95,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 +129,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,25 +160,4 @@
|
||||
<!-- 底部留白 -->
|
||||
<view class="bottom-space"></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>
|
||||
<text class="lead-divider">或手动输入</text>
|
||||
<view class="lead-input-wrap">
|
||||
<input class="lead-input" placeholder="请输入手机号" type="number" maxlength="11" value="{{leadPhone}}" bindinput="onLeadPhoneInput"/>
|
||||
</view>
|
||||
<view class="lead-actions">
|
||||
<button class="lead-btn lead-btn-cancel" bindtap="closeLeadModal">取消</button>
|
||||
<button class="lead-btn lead-btn-submit" bindtap="submitLead">提交</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -8,6 +8,18 @@ const app = getApp()
|
||||
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项信息(我能帮到你什么、我需要什么帮助)
|
||||
@@ -226,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
|
||||
@@ -413,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,
|
||||
@@ -424,23 +448,39 @@ Page({
|
||||
this.setData({ matchAttempts: this.data.matchAttempts + 1 })
|
||||
}, 1000)
|
||||
|
||||
// 从数据库获取真实用户匹配
|
||||
// 从数据库获取真实用户匹配(带上手机/微信写入 match_records,与流量池运营对齐)
|
||||
let matchedUser = null
|
||||
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)
|
||||
matchFailHint = (e && e.message) ? String(e.message) : '网络异常,请稍后重试'
|
||||
}
|
||||
|
||||
// 延迟显示结果(模拟匹配过程)
|
||||
@@ -453,7 +493,7 @@ Page({
|
||||
this.setData({ isMatching: false })
|
||||
wx.showModal({
|
||||
title: '暂无匹配',
|
||||
content: '当前暂无合适的匹配用户,请稍后再试',
|
||||
content: matchFailHint || '当前暂无合适的匹配用户,请稍后再试',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
@@ -498,7 +538,7 @@ Page({
|
||||
}
|
||||
}
|
||||
})
|
||||
// 匹配后规则:引导填写 MBTI/行业信息
|
||||
// 匹配后规则:资料未齐时提示补全(服务端 profile 合并,见 ruleEngine)
|
||||
checkAndExecute('after_match', this)
|
||||
} catch (e) {
|
||||
console.log('上报匹配失败:', e)
|
||||
|
||||
@@ -5,21 +5,70 @@
|
||||
* 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 { trackClick } = require('../../utils/trackClick')
|
||||
const { isSafeImageSrc } = require('../../utils/imageUrl.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) {
|
||||
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) {
|
||||
@@ -64,10 +113,11 @@ Page({
|
||||
|
||||
enrichAndFormat(raw) {
|
||||
const e = (v) => this._emptyIfPlaceholder(v)
|
||||
const rawAv = raw.avatar || raw.vipAvatar || raw.vip_avatar || ''
|
||||
const merged = {
|
||||
id: raw.id,
|
||||
name: raw.nickname || raw.name || raw.vipName || raw.vip_name || '创业者',
|
||||
avatar: raw.avatar || raw.vipAvatar || raw.vip_avatar || '',
|
||||
avatar: isSafeImageSrc(rawAv) ? String(rawAv).trim() : '',
|
||||
isVip: !!(raw.isVip || raw.is_vip),
|
||||
mbti: e(raw.mbti),
|
||||
region: e(raw.region),
|
||||
@@ -152,55 +202,167 @@ Page({
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* 点头像:有存客宝人物 token 时优先 POST /api/miniprogram/ckb/lead(与阅读页 @ 同链路,匹配 persons.ckb_api_key 计划)
|
||||
* 否则:解锁后复制微信/手机号并引导
|
||||
*/
|
||||
startLinkFlow() {
|
||||
const member = this.data.member
|
||||
if (!member) return
|
||||
const leadTok = (member.ckbLeadToken || '').trim()
|
||||
if (leadTok) {
|
||||
const nickname = ((member.name || 'TA').trim() || 'TA')
|
||||
wx.showModal({
|
||||
title: '添加好友',
|
||||
content: `是否通过获客计划联系 ${nickname}?提交后将按对方在存客宝后台配置的计划执行。`,
|
||||
confirmText: '确定',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) this._doCkbLeadSubmit(leadTok, nickname)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (member.wechatRaw || member.wechatDisplay) {
|
||||
if (!this._ensureUnlockedForLink('wechat')) return
|
||||
const m = this.data.member
|
||||
if (m.wechatFull) this._copyAndGuideWechat(m.wechatFull)
|
||||
return
|
||||
}
|
||||
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' })
|
||||
/** 链接前:头像需非空且非默认图;微信号需已填写(与 app._needsAvatarNickname 中头像规则一致) */
|
||||
_hasCustomAvatarForLink(u) {
|
||||
const avatar = (u && (u.avatar || u.avatarUrl) || '').trim()
|
||||
return !!avatar && !avatar.includes('default')
|
||||
},
|
||||
|
||||
/** 与 read 页 _doMentionAddFriend 一致:targetUserId = Person.token */
|
||||
async _doCkbLeadSubmit(targetUserId, targetNickname) {
|
||||
_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
|
||||
},
|
||||
|
||||
/**
|
||||
* 点头像:登录 → 头像+微信号 → 手机号 均校验通过后弹窗说明;确认后 POST ckb/lead。
|
||||
* 有 ckbLeadToken 走人物计划;无 token 走全局留资。对方已公开联系方式时可取消后在下方自行添加。
|
||||
*/
|
||||
async startLinkFlow() {
|
||||
if (this.data.isOwnProfile) return
|
||||
const member = this.data.member
|
||||
if (!member) return
|
||||
const nickname = (member.name || 'TA').trim() || 'TA'
|
||||
trackClick('member_detail', 'btn_click', '链接头像_' + (member.id || ''))
|
||||
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
|
||||
wx.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录后再添加好友',
|
||||
title: `链接「${nickname}」`,
|
||||
content: '请先登录后再发起链接。',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
|
||||
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
|
||||
})
|
||||
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 (!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)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** 无人物 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)) {
|
||||
@@ -212,10 +374,27 @@ Page({
|
||||
}
|
||||
} 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: '请先填写手机号(必填),以便对方通过获客计划联系您',
|
||||
title: '补全手机号',
|
||||
content: '请填写手机号(必填),便于对方通过获客计划联系您。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
|
||||
@@ -234,6 +413,8 @@ Page({
|
||||
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
|
||||
targetUserId,
|
||||
targetNickname: targetNickname || undefined,
|
||||
targetMemberId: targetMemberId || undefined,
|
||||
targetMemberName: targetMemberName || undefined,
|
||||
source: 'member_detail_avatar'
|
||||
}
|
||||
})
|
||||
@@ -372,11 +553,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'
|
||||
}
|
||||
},
|
||||
@@ -384,7 +610,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,21 +8,8 @@ const app = getApp()
|
||||
const { formatStatNum } = require('../../utils/util.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { cleanSingleLineField } = require('../../utils/contentParser.js')
|
||||
|
||||
/** 是否视为「单章解锁」类订单(排除全书/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: {
|
||||
@@ -97,10 +84,12 @@ Page({
|
||||
// 我的余额
|
||||
walletBalanceText: '--',
|
||||
|
||||
// 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开)
|
||||
unlockedChaptersFull: [],
|
||||
displayUnlockedChapters: [],
|
||||
unlockedExpanded: false,
|
||||
// mp_config.mpUi.myPage(后台可改文案/跳转)
|
||||
mpUiCardLabel: '名片',
|
||||
mpUiVipLabelVip: '会员中心',
|
||||
mpUiVipLabelGuest: '成为会员',
|
||||
mpUiReadStatLabel: '已读章节',
|
||||
mpUiRecentTitle: '最近阅读',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -130,6 +119,27 @@ Page({
|
||||
}
|
||||
}
|
||||
this.initUserStatus()
|
||||
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 loadFeatureConfig() {
|
||||
@@ -144,9 +154,11 @@ Page({
|
||||
app.globalData.auditMode = auditMode
|
||||
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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -158,11 +170,17 @@ 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,
|
||||
userIdShort,
|
||||
userWechat,
|
||||
readCount: 0,
|
||||
@@ -182,9 +200,9 @@ Page({
|
||||
this.loadPendingConfirm()
|
||||
this.loadVipStatus()
|
||||
this.loadWalletBalance()
|
||||
this.loadUnlockedChapters()
|
||||
} else {
|
||||
const guestReadCount = app.getReadCount()
|
||||
const guestRecent = this._mergeRecentChaptersFromLocal([])
|
||||
this.setData({
|
||||
isLoggedIn: false,
|
||||
userInfo: null,
|
||||
@@ -195,10 +213,7 @@ Page({
|
||||
earnings: '-',
|
||||
pendingEarnings: '-',
|
||||
earningsLoading: false,
|
||||
recentChapters: [],
|
||||
unlockedChaptersFull: [],
|
||||
displayUnlockedChapters: [],
|
||||
unlockedExpanded: false,
|
||||
recentChapters: guestRecent,
|
||||
totalReadTime: 0,
|
||||
matchHistory: 0,
|
||||
totalReadTimeText: '0',
|
||||
@@ -207,88 +222,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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -302,21 +312,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)
|
||||
@@ -334,6 +352,7 @@ Page({
|
||||
})
|
||||
} catch (e) {
|
||||
console.log('[My] 拉取阅读统计失败:', e && e.message)
|
||||
this._hydrateReadStatsFromLocal()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -574,7 +593,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
|
||||
@@ -856,9 +879,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' })
|
||||
},
|
||||
|
||||
@@ -932,10 +979,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 }
|
||||
|
||||
@@ -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,34 +20,28 @@
|
||||
<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="{{userInfo.avatar && userInfo.avatar.length > 5}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill"/>
|
||||
<text wx:else class="avatar-text">{{userInfo.nickname ? userInfo.nickname[0] : '?'}}</text>
|
||||
</view>
|
||||
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
|
||||
<view class="vip-badge vip-badge-gray" wx:else>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" 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">
|
||||
<text class="profile-stat-val">{{referralCount}}</text>
|
||||
@@ -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,49 @@
|
||||
}
|
||||
.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; }
|
||||
/* 名片 / 会员中心:统一品牌青,与 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 +124,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 +198,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 +205,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,17 @@ Page({
|
||||
loading: true,
|
||||
showPrivacyModal: false,
|
||||
nicknameInputFocus: false,
|
||||
/** 首次完善:分 3 步;full=1 或未达标资料时为单页 */
|
||||
wizardMode: false,
|
||||
wizardStep: 1,
|
||||
totalWizardSteps: 3,
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
this._wizardRouteOpts = {
|
||||
full: options?.full === '1',
|
||||
wizardOff: options?.wizard === '0',
|
||||
}
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight || 44,
|
||||
fromVip: options?.from === 'vip',
|
||||
@@ -98,6 +109,7 @@ Page({
|
||||
projectIntro: v('projectIntro'),
|
||||
loading: false,
|
||||
})
|
||||
this._applyWizardModeFromProfile(d)
|
||||
setTimeout(() => this.generateShareCard(), 200)
|
||||
} else {
|
||||
this.setData({ loading: false })
|
||||
@@ -109,6 +121,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({
|
||||
@@ -365,7 +420,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 +432,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 +486,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="{{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 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="{{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>
|
||||
</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,7 @@
|
||||
* 从「我的」页编辑图标进入;展示基本信息、个人故事、互助需求、项目介绍
|
||||
*/
|
||||
const app = getApp()
|
||||
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
|
||||
|
||||
Page({
|
||||
data: {
|
||||
@@ -36,9 +37,11 @@ Page({
|
||||
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
|
||||
this.setData({
|
||||
profile: {
|
||||
...d,
|
||||
avatar: isSafeImageSrc(av) ? String(av).trim() : '',
|
||||
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); }
|
||||
|
||||
@@ -18,6 +18,7 @@ const readingTracker = require('../../utils/readingTracker')
|
||||
const { parseScene } = require('../../utils/scene.js')
|
||||
const contentParser = require('../../utils/contentParser.js')
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
const app = getApp()
|
||||
|
||||
@@ -120,10 +121,64 @@ Page({
|
||||
|
||||
// 好友从代付分享进入:待自动领取的 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) {
|
||||
@@ -186,7 +241,9 @@ Page({
|
||||
sectionMid: mid || null,
|
||||
loading: true,
|
||||
accessState: 'unknown',
|
||||
pendingGiftRequestSn: giftRequestSn || ''
|
||||
pendingGiftRequestSn: giftRequestSn || '',
|
||||
readSinglePageMode: this._detectReadSinglePage(),
|
||||
momentsPaywallExpanded: false,
|
||||
})
|
||||
|
||||
if (ref) {
|
||||
@@ -234,9 +291,10 @@ Page({
|
||||
}
|
||||
}
|
||||
|
||||
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
||||
if (canAccess) {
|
||||
readingTracker.init(id)
|
||||
} else {
|
||||
app.touchRecentSection(id)
|
||||
}
|
||||
|
||||
// 5. 导航:文章详情已带 prev/next
|
||||
@@ -375,6 +433,7 @@ Page({
|
||||
if (accessManager.canAccessFullContent(accessState)) {
|
||||
app.markSectionAsRead(id)
|
||||
}
|
||||
app.touchRecentSection(id)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
|
||||
@@ -393,6 +452,7 @@ Page({
|
||||
partTitle: cached.partTitle || '',
|
||||
chapterTitle: cached.chapterTitle || ''
|
||||
})
|
||||
app.touchRecentSection(id)
|
||||
console.log('[Read] 从本地缓存加载成功')
|
||||
return
|
||||
}
|
||||
@@ -698,8 +758,8 @@ Page({
|
||||
}
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: '请先填写手机号(必填),以便对方联系您',
|
||||
title: '补全手机号',
|
||||
content: '请填写手机号(必填),便于对方联系您。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
@@ -784,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) {}
|
||||
@@ -932,17 +988,9 @@ Page({
|
||||
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')
|
||||
@@ -961,7 +1009,7 @@ Page({
|
||||
wx.hideToast()
|
||||
wx.showModal({
|
||||
title: '分享到朋友圈',
|
||||
content: '文案已复制。\n\n请点击右上角「···」菜单,选择「分享到朋友圈」即可发布。',
|
||||
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
@@ -988,21 +1036,10 @@ Page({
|
||||
|
||||
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
|
||||
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 })
|
||||
@@ -1086,6 +1123,10 @@ Page({
|
||||
|
||||
// 购买章节 - 直接调起支付
|
||||
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 })
|
||||
@@ -1105,6 +1146,10 @@ Page({
|
||||
|
||||
// 购买全书 - 直接调起支付
|
||||
async handlePurchaseFullBook() {
|
||||
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
|
||||
this.onUnlockTapInSinglePage()
|
||||
return
|
||||
}
|
||||
console.log('[Pay] 点击购买全书按钮')
|
||||
wx.showLoading({ title: '处理中...', mask: true })
|
||||
|
||||
@@ -1123,6 +1168,14 @@ Page({
|
||||
// 处理支付 - 调用真实微信支付接口
|
||||
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) {
|
||||
@@ -1132,10 +1185,10 @@ Page({
|
||||
if (needProfile) {
|
||||
const res = await new Promise(resolve => {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: '购买前请先完善头像和昵称',
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后',
|
||||
title: '设置头像与昵称',
|
||||
content: '支付订单会关联你的对外展示信息,请先设置头像与昵称,避免账单与对方看到默认占位。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
success: resolve
|
||||
})
|
||||
})
|
||||
@@ -1294,7 +1347,7 @@ Page({
|
||||
title: '支付通道维护中',
|
||||
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
|
||||
confirmText: '复制微信号',
|
||||
cancelText: '稍后再说',
|
||||
cancelText: '关闭',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
wx.setClipboardData({
|
||||
@@ -1414,6 +1467,7 @@ Page({
|
||||
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '购买成功', icon: 'success' })
|
||||
checkAndExecute('after_pay', this)
|
||||
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
@@ -1499,6 +1553,25 @@ Page({
|
||||
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: '生成中...' })
|
||||
@@ -1509,6 +1582,7 @@ Page({
|
||||
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
|
||||
@@ -1526,18 +1600,12 @@ Page({
|
||||
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') {
|
||||
@@ -1548,73 +1616,100 @@ Page({
|
||||
} 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()
|
||||
|
||||
@@ -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">分享后好友购买,你可获得 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, // 总推荐人数
|
||||
@@ -598,7 +598,7 @@ Page({
|
||||
}
|
||||
// 任意金额可提现,不再设最低限额
|
||||
|
||||
// 未绑定微信号时引导去设置
|
||||
// 未绑定微信号:说明提现到账核对所需
|
||||
if (!hasWechatId) {
|
||||
wx.showModal({
|
||||
title: '请先绑定微信号',
|
||||
|
||||
@@ -12,16 +12,16 @@ Page({
|
||||
originalPrice: 6980,
|
||||
/* 按 premium_membership_landing_v1 设计稿 */
|
||||
contentRights: [
|
||||
{ title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
|
||||
{ title: '派对专属', desc: '创业派对房专享', icon: 'star' },
|
||||
{ title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
|
||||
{ title: '轮流置顶', desc: '首页获客曝光位', icon: 'arrow-up' }
|
||||
{ key: 'match', title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
|
||||
{ key: 'party', title: '派对专属', desc: '创业派对房专享', icon: 'star' },
|
||||
{ key: 'rank', title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
|
||||
{ key: 'top', title: '轮流置顶', desc: '首页获客曝光位', icon: 'chevron-up' }
|
||||
],
|
||||
socialRights: [
|
||||
{ title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
|
||||
{ title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
|
||||
{ title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
|
||||
{ title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
|
||||
{ key: 'cases', title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
|
||||
{ key: 'fullbook', title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
|
||||
{ key: 'daily', title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
|
||||
{ key: 'leads', title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
|
||||
],
|
||||
purchasing: false
|
||||
},
|
||||
@@ -85,7 +85,7 @@ Page({
|
||||
return
|
||||
}
|
||||
}
|
||||
// VIP 购买后才引导完善资料:购买前不拦截,购买成功后跳转 profile-edit
|
||||
// VIP 购买成功后再跳转资料页:购买前不拦截
|
||||
this.setData({ purchasing: true })
|
||||
const amount = this.data.price
|
||||
try {
|
||||
@@ -163,9 +163,9 @@ Page({
|
||||
// 超级个体购买后:弹窗提示,强制跳转资料编辑页
|
||||
wx.hideLoading()
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: '为了更好为您服务,请填写好资料',
|
||||
confirmText: '去完善',
|
||||
title: '补全 VIP 资料',
|
||||
content: '补全资料后,找伙伴、提现与 VIP 群对接会更顺畅;手机号等为必填项。',
|
||||
confirmText: '去填写',
|
||||
showCancel: false,
|
||||
success: () => {
|
||||
wx.redirectTo({ url: '/pages/profile-edit/profile-edit?from=vip' })
|
||||
@@ -179,6 +179,33 @@ Page({
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
/**
|
||||
* 权益卡片跳转:会员权利 / 派对权利 统一点击进对应能力页
|
||||
*/
|
||||
onBenefitTap(e) {
|
||||
const key = e.currentTarget.dataset.key
|
||||
if (!key) return
|
||||
trackClick('vip', 'benefit_tap', key)
|
||||
const tab = (path) => {
|
||||
wx.switchTab({ url: path })
|
||||
}
|
||||
const nav = (path) => {
|
||||
wx.navigateTo({ url: path })
|
||||
}
|
||||
const routes = {
|
||||
match: () => tab('/pages/match/match'),
|
||||
party: () => nav('/pages/mentors/mentors'),
|
||||
rank: () => tab('/pages/index/index'),
|
||||
top: () => tab('/pages/index/index'),
|
||||
cases: () => tab('/pages/chapters/chapters'),
|
||||
fullbook: () => tab('/pages/chapters/chapters'),
|
||||
daily: () => tab('/pages/chapters/chapters'),
|
||||
leads: () => nav('/pages/profile-edit/profile-edit')
|
||||
}
|
||||
const fn = routes[key]
|
||||
if (typeof fn === 'function') fn()
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
const ref = app.getMyReferralCode()
|
||||
return {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<text class="rights-dot rights-dot-teal"></text>
|
||||
<text class="rights-col-title">会员权利</text>
|
||||
</view>
|
||||
<view class="benefit-card" wx:for="{{contentRights}}" wx:key="title">
|
||||
<view class="benefit-card benefit-card-tap" wx:for="{{contentRights}}" wx:key="key" bindtap="onBenefitTap" data-key="{{item.key}}" hover-class="benefit-card-hover">
|
||||
<icon name="{{item.icon || 'check'}}" size="40" color="#00CED1" customClass="benefit-icon"></icon>
|
||||
<view class="benefit-info">
|
||||
<text class="benefit-title">{{item.title}}</text>
|
||||
@@ -34,7 +34,7 @@
|
||||
<text class="rights-dot rights-dot-gold"></text>
|
||||
<text class="rights-col-title rights-col-title-gold">派对权利</text>
|
||||
</view>
|
||||
<view class="benefit-card" wx:for="{{socialRights}}" wx:key="title">
|
||||
<view class="benefit-card benefit-card-tap" wx:for="{{socialRights}}" wx:key="key" bindtap="onBenefitTap" data-key="{{item.key}}" hover-class="benefit-card-hover">
|
||||
<icon name="{{item.icon || 'check'}}" size="40" color="#FFD700" customClass="benefit-icon benefit-icon-gold"></icon>
|
||||
<view class="benefit-info">
|
||||
<text class="benefit-title">{{item.title}}</text>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
.rights-col-title { font-size: 24rpx; font-weight: bold; color: #4FD1C5; letter-spacing: 2rpx; }
|
||||
.rights-col-title-gold { color: #FFBD2E; }
|
||||
.benefit-card { display: flex; flex-direction: column; gap: 16rpx; padding: 24rpx; margin-bottom: 16rpx; background: #141414; border: 1rpx solid rgba(255,255,255,0.05); border-radius: 24rpx; }
|
||||
.benefit-card-tap { transition: opacity 0.15s; }
|
||||
.benefit-card-hover { opacity: 0.88; background: #1a1a1a; }
|
||||
.benefit-icon { font-size: 36rpx; color: #4FD1C5; }
|
||||
.benefit-icon-gold { color: #FFBD2E; }
|
||||
.benefit-info { display: flex; flex-direction: column; }
|
||||
|
||||
14
miniprogram/utils/imageUrl.js
Normal file
14
miniprogram/utils/imageUrl.js
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 小程序 <image src> 合法判断:避免 undefined 字符串、相对脏值触发「illegal src」
|
||||
*/
|
||||
function isSafeImageSrc(u) {
|
||||
if (u == null) return false
|
||||
const s = String(u).trim()
|
||||
if (!s || s === 'undefined' || s === 'null') return false
|
||||
if (/^https?:\/\//i.test(s)) return true
|
||||
if (s.startsWith('wxfile://') || s.startsWith('cloud://')) return true
|
||||
if (s.startsWith('/')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = { isSafeImageSrc }
|
||||
26
miniprogram/utils/mpNavigate.js
Normal file
26
miniprogram/utils/mpNavigate.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 按 mp_config.mpUi 配置的路径跳转:Tab 页用 switchTab,其余 navigateTo
|
||||
*/
|
||||
const TAB_PATHS = [
|
||||
'/pages/index/index',
|
||||
'/pages/chapters/chapters',
|
||||
'/pages/match/match',
|
||||
'/pages/my/my'
|
||||
]
|
||||
|
||||
function navigateMpPath(path) {
|
||||
if (!path || typeof path !== 'string') return false
|
||||
const full = path.trim()
|
||||
if (!full.startsWith('/')) return false
|
||||
const q = full.indexOf('?')
|
||||
const route = q >= 0 ? full.slice(0, q) : full
|
||||
const suffix = q >= 0 ? full.slice(q) : ''
|
||||
if (TAB_PATHS.includes(route)) {
|
||||
wx.switchTab({ url: route })
|
||||
return true
|
||||
}
|
||||
wx.navigateTo({ url: route + suffix })
|
||||
return true
|
||||
}
|
||||
|
||||
module.exports = { navigateMpPath, TAB_PATHS }
|
||||
@@ -32,13 +32,13 @@ class ReadingTracker {
|
||||
|
||||
console.log('[ReadingTracker] 初始化追踪:', sectionId)
|
||||
|
||||
// 恢复上次阅读位置
|
||||
this.saveProgressLocal()
|
||||
app.touchRecentSection(sectionId)
|
||||
|
||||
this.restoreLastPosition(sectionId)
|
||||
|
||||
// 开始定期上报(每30秒)
|
||||
this.startProgressReport()
|
||||
|
||||
// 立即上报一次「打开/点击」,确保内容管理后台的「点击」数据有记录(与 reading_progress 表直接捆绑)
|
||||
setTimeout(() => this.reportProgressToServer(false), 0)
|
||||
}
|
||||
|
||||
@@ -177,16 +177,20 @@ class ReadingTracker {
|
||||
this.activeTracker.lastScrollTime = now
|
||||
|
||||
try {
|
||||
const data = {
|
||||
userId,
|
||||
sectionId: this.activeTracker.sectionId,
|
||||
progress: this.activeTracker.maxProgress,
|
||||
duration: this.activeTracker.totalDuration,
|
||||
status: this.activeTracker.isCompleted ? 'completed' : 'reading'
|
||||
}
|
||||
if (this.activeTracker.isCompleted && this.activeTracker.completedAt != null) {
|
||||
const t = this.activeTracker.completedAt
|
||||
data.completedAt = typeof t === 'number' ? new Date(t).toISOString() : String(t)
|
||||
}
|
||||
await app.request('/api/miniprogram/user/reading-progress', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
userId,
|
||||
sectionId: this.activeTracker.sectionId,
|
||||
progress: this.activeTracker.maxProgress,
|
||||
duration: this.activeTracker.totalDuration,
|
||||
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
|
||||
completedAt: this.activeTracker.completedAt
|
||||
}
|
||||
data
|
||||
})
|
||||
|
||||
if (isCompletion) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 卡若创业派对 - 用户旅程规则引擎
|
||||
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发引导
|
||||
* 从后端 /api/miniprogram/user-rules 读取启用的规则,按场景触发提示(文案偏利他、少用命令式)
|
||||
* 稳定版兼容:readCount 用 getReadCount(),hasPurchasedFull 用 hasFullBook,完善头像跳 avatar-nickname
|
||||
*
|
||||
* trigger → scene 映射:
|
||||
@@ -37,6 +37,7 @@ const TRIGGER_SCENE_MAP = {
|
||||
'点击收费章节': 'before_read',
|
||||
'完成匹配': 'after_match',
|
||||
'完成付款': 'after_pay',
|
||||
'发起支付': 'before_pay',
|
||||
'累计浏览5章节': 'page_show',
|
||||
'加入派对房': 'before_join_party',
|
||||
'绑定微信': 'after_bindwechat',
|
||||
@@ -74,12 +75,44 @@ function getUserInfo() {
|
||||
return app ? (app.globalData.userInfo || {}) : {}
|
||||
}
|
||||
|
||||
function trimStr(v) {
|
||||
if (v == null || v === undefined) return ''
|
||||
const s = String(v).trim()
|
||||
return s
|
||||
}
|
||||
|
||||
/** 合并服务端 profile,避免本地 userInfo 未同步导致「已填写仍弹窗」 */
|
||||
async function fetchProfileMergeUser() {
|
||||
const base = { ...getUserInfo() }
|
||||
const userId = base.id
|
||||
if (!userId) return base
|
||||
try {
|
||||
const app = getAppInstance()
|
||||
const res = await app.request({ url: `/api/miniprogram/user/profile?userId=${userId}`, silent: true })
|
||||
if (res?.success && res.data) {
|
||||
const d = res.data
|
||||
return {
|
||||
...base,
|
||||
mbti: d.mbti != null ? d.mbti : base.mbti,
|
||||
industry: d.industry != null ? d.industry : base.industry,
|
||||
position: d.position != null ? d.position : base.position,
|
||||
projectIntro: d.projectIntro || d.project_intro || base.projectIntro,
|
||||
phone: d.phone != null ? d.phone : base.phone,
|
||||
wechatId: d.wechatId || d.wechat_id || base.wechatId,
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return base
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
if (_cachedRules && Date.now() - _cacheTs < CACHE_TTL) return _cachedRules
|
||||
const app = getAppInstance()
|
||||
if (!app) return _cachedRules || []
|
||||
const userId = (app.globalData.userInfo || {}).id || ''
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/user-rules', method: 'GET', silent: true })
|
||||
const url = userId ? `/api/miniprogram/user-rules?userId=${userId}` : '/api/miniprogram/user-rules'
|
||||
const res = await app.request({ url, method: 'GET', silent: true })
|
||||
if (res && res.success && res.rules) {
|
||||
_cachedRules = res.rules
|
||||
_cacheTs = Date.now()
|
||||
@@ -92,15 +125,30 @@ async function loadRules() {
|
||||
}
|
||||
|
||||
function isRuleEnabled(rules, triggerName) {
|
||||
return rules.some(r => r.trigger === triggerName)
|
||||
return rules.some(r => r.trigger === triggerName && !r.completed)
|
||||
}
|
||||
|
||||
function getRuleInfo(rules, triggerName) {
|
||||
return rules.find(r => r.trigger === triggerName)
|
||||
return rules.find(r => r.trigger === triggerName && !r.completed)
|
||||
}
|
||||
|
||||
function markRuleCompleted(ruleId) {
|
||||
const userId = getUserInfo().id
|
||||
if (!userId || !ruleId) return
|
||||
const app = getAppInstance()
|
||||
if (!app) return
|
||||
const numericId = typeof ruleId === 'number' ? ruleId : null
|
||||
if (!numericId) return
|
||||
app.request({
|
||||
url: '/api/miniprogram/user-rules/complete',
|
||||
method: 'POST',
|
||||
data: { userId, ruleId: numericId },
|
||||
silent: true
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 稳定版:跳转 avatar-nickname(专注头像+昵称,首次登录由 app.login 强制 redirect)
|
||||
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 引导到 profile-edit,避免与主流程冲突
|
||||
// VIP 用户不触发:统一由 checkVipContactRequiredAndGuide 跳转 profile-edit,避免与主流程冲突
|
||||
function checkRule_FillAvatar(rules) {
|
||||
if (!isRuleEnabled(rules, '注册')) return null
|
||||
const app = getAppInstance()
|
||||
@@ -115,8 +163,11 @@ function checkRule_FillAvatar(rules) {
|
||||
const info = getRuleInfo(rules, '注册')
|
||||
return {
|
||||
ruleId: 'fill_avatar',
|
||||
title: info?.title || '完善个人信息',
|
||||
message: info?.description || '设置头像和昵称,让其他创业者更容易认识你',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '设置头像与昵称',
|
||||
message: info?.description || '头像与昵称会展示在名片与匹配卡片上,方便伙伴认出你。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/avatar-nickname/avatar-nickname'
|
||||
}
|
||||
@@ -132,25 +183,34 @@ function checkRule_BindPhone(rules) {
|
||||
const info = getRuleInfo(rules, '点击收费章节')
|
||||
return {
|
||||
ruleId: 'bind_phone',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '绑定手机号',
|
||||
message: info?.description || '绑定手机号解锁更多功能,保障账户安全',
|
||||
message: info?.description || '绑定后可用于登录验证、收益与重要通知,账户更安全。',
|
||||
confirmText: '去绑定',
|
||||
cancelText: '关闭',
|
||||
action: 'bind_phone',
|
||||
target: null
|
||||
}
|
||||
}
|
||||
|
||||
function checkRule_FillProfile(rules) {
|
||||
function checkRule_FillProfile(rules, user) {
|
||||
if (!isRuleEnabled(rules, '完成匹配')) return null
|
||||
const user = getUserInfo()
|
||||
user = user || getUserInfo()
|
||||
if (!user.id) return null
|
||||
if (user.mbti && user.industry) return null
|
||||
const mbti = trimStr(user.mbti)
|
||||
const industry = trimStr(user.industry)
|
||||
const position = trimStr(user.position)
|
||||
if (mbti && industry && position) return null
|
||||
if (isInCooldown('fill_profile')) return null
|
||||
setCooldown('fill_profile')
|
||||
const info = getRuleInfo(rules, '完成匹配')
|
||||
return {
|
||||
ruleId: 'fill_profile',
|
||||
title: info?.title || '完善创业档案',
|
||||
message: info?.description || '填写 MBTI 和行业信息,帮你精准匹配创业伙伴',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '补充档案信息',
|
||||
message: info?.description || '补全 MBTI、行业和职位后,匹配页能更准确地向对方展示你,减少无效沟通。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/profile-edit/profile-edit'
|
||||
}
|
||||
@@ -169,45 +229,56 @@ function checkRule_ShareAfter5Chapters(rules) {
|
||||
const info = getRuleInfo(rules, '累计浏览5章节')
|
||||
return {
|
||||
ruleId: 'share_after_5',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '邀请好友一起看',
|
||||
message: info?.description || '你已阅读 ' + readCount + ' 个章节,分享给好友可获得分销收益',
|
||||
message: info?.description || '你已阅读 ' + readCount + ' 个章节,好友通过你的分享购买时,你可获得对应分销收益。',
|
||||
confirmText: '查看分享',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/referral/referral'
|
||||
}
|
||||
}
|
||||
|
||||
// 稳定版兼容:hasPurchasedFull 用 hasFullBook
|
||||
function checkRule_FillVipInfo(rules) {
|
||||
function checkRule_FillVipInfo(rules, user) {
|
||||
if (!isRuleEnabled(rules, '完成付款')) return null
|
||||
const user = getUserInfo()
|
||||
user = user || getUserInfo()
|
||||
if (!user.id) return null
|
||||
const app = getAppInstance()
|
||||
if (!app || !(app.globalData.hasFullBook || app.globalData.hasPurchasedFull)) return null
|
||||
if (user.wechatId && user.address) return null
|
||||
const wxId = trimStr(user.wechatId || user.wechat_id)
|
||||
const addr = trimStr(user.address)
|
||||
if (wxId && addr) return null
|
||||
if (isInCooldown('fill_vip_info')) return null
|
||||
setCooldown('fill_vip_info')
|
||||
const info = getRuleInfo(rules, '完成付款')
|
||||
return {
|
||||
ruleId: 'fill_vip_info',
|
||||
title: info?.title || '填写完整信息',
|
||||
message: info?.description || '购买全书后,需填写完整信息以进入 VIP 群',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '补全 VIP 资料',
|
||||
message: info?.description || '补全微信号与收货地址等信息,便于进入 VIP 群、寄送物料与售后联系。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/profile-edit/profile-edit'
|
||||
}
|
||||
}
|
||||
|
||||
function checkRule_JoinParty(rules) {
|
||||
function checkRule_JoinParty(rules, user) {
|
||||
if (!isRuleEnabled(rules, '加入派对房')) return null
|
||||
const user = getUserInfo()
|
||||
user = user || getUserInfo()
|
||||
if (!user.id) return null
|
||||
if (user.projectIntro) return null
|
||||
if (trimStr(user.projectIntro)) return null
|
||||
if (isInCooldown('join_party')) return null
|
||||
setCooldown('join_party')
|
||||
const info = getRuleInfo(rules, '加入派对房')
|
||||
return {
|
||||
ruleId: 'join_party',
|
||||
title: info?.title || '填写项目介绍',
|
||||
message: info?.description || '进入派对房前,引导填写项目介绍和核心需求',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '补充项目介绍',
|
||||
message: info?.description || '用简短文字说明项目与需求,派对房里的伙伴能更快判断是否与你有合作空间。',
|
||||
confirmText: '去填写',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/profile-edit/profile-edit'
|
||||
}
|
||||
@@ -217,14 +288,17 @@ function checkRule_BindWechat(rules) {
|
||||
if (!isRuleEnabled(rules, '绑定微信')) return null
|
||||
const user = getUserInfo()
|
||||
if (!user.id) return null
|
||||
if (user.wechatId) return null
|
||||
if (trimStr(user.wechatId || user.wechat_id)) return null
|
||||
if (isInCooldown('bind_wechat')) return null
|
||||
setCooldown('bind_wechat')
|
||||
const info = getRuleInfo(rules, '绑定微信')
|
||||
return {
|
||||
ruleId: 'bind_wechat',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '绑定微信号',
|
||||
message: info?.description || '绑定微信后,引导开启分销功能',
|
||||
message: info?.description || '绑定后可用于分销结算、提现核对与重要通知。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/settings/settings'
|
||||
}
|
||||
@@ -242,8 +316,11 @@ function checkRule_Withdraw(rules) {
|
||||
const info = getRuleInfo(rules, '收益满50元')
|
||||
return {
|
||||
ruleId: 'withdraw_50',
|
||||
serverRuleId: info?.id,
|
||||
title: info?.title || '可以提现了',
|
||||
message: info?.description || '累计分销收益超过 50 元,快去申请提现吧',
|
||||
message: info?.description || '累计分销收益已达到提现条件,可在推荐收益页发起提现到微信零钱。',
|
||||
confirmText: '去查看',
|
||||
cancelText: '关闭',
|
||||
action: 'navigate',
|
||||
target: '/pages/referral/referral'
|
||||
}
|
||||
@@ -258,8 +335,10 @@ function checkRulesSync(scene, rules) {
|
||||
return checkRule_FillAvatar(rules)
|
||||
case 'before_read':
|
||||
return checkRule_BindPhone(rules) || checkRule_FillAvatar(rules)
|
||||
case 'before_pay':
|
||||
return checkRule_FillAvatar(rules) || checkRule_BindPhone(rules) || checkRule_FillProfile(rules)
|
||||
case 'after_match':
|
||||
return checkRule_FillProfile(rules) || checkRule_JoinParty(rules)
|
||||
return null
|
||||
case 'after_pay':
|
||||
return checkRule_FillVipInfo(rules) || checkRule_FillProfile(rules)
|
||||
case 'page_show':
|
||||
@@ -277,8 +356,8 @@ function executeRule(rule, pageInstance) {
|
||||
wx.showModal({
|
||||
title: rule.title,
|
||||
content: rule.message,
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后再说',
|
||||
confirmText: rule.confirmText || '去填写',
|
||||
cancelText: rule.cancelText !== undefined ? rule.cancelText : '关闭',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
if (rule.action === 'navigate' && rule.target) {
|
||||
@@ -288,6 +367,9 @@ function executeRule(rule, pageInstance) {
|
||||
pageInstance.showPhoneBinding()
|
||||
}
|
||||
}
|
||||
if (rule.serverRuleId) {
|
||||
markRuleCompleted(rule.serverRuleId)
|
||||
}
|
||||
}
|
||||
_trackRuleAction(rule.ruleId, res.confirm ? 'confirm' : 'cancel')
|
||||
}
|
||||
@@ -309,10 +391,16 @@ function _trackRuleAction(ruleId, action) {
|
||||
|
||||
async function checkAndExecute(scene, pageInstance) {
|
||||
const rules = await loadRules()
|
||||
const rule = checkRulesSync(scene, rules)
|
||||
let rule = null
|
||||
if (scene === 'after_match') {
|
||||
const u = await fetchProfileMergeUser()
|
||||
rule = checkRule_FillProfile(rules, u) || checkRule_JoinParty(rules, u)
|
||||
} else {
|
||||
rule = checkRulesSync(scene, rules)
|
||||
}
|
||||
if (rule) {
|
||||
setTimeout(() => executeRule(rule, pageInstance), 800)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules }
|
||||
module.exports = { checkRules: checkRulesSync, executeRule, checkAndExecute, loadRules, markRuleCompleted }
|
||||
|
||||
@@ -32,6 +32,19 @@ const formatMoney = (amount, decimals = 2) => {
|
||||
return Number(amount).toFixed(decimals)
|
||||
}
|
||||
|
||||
/** 「我的」等页统计数字展示:非法值→0;≥1 万可缩写为「x万」 */
|
||||
const formatStatNum = (n) => {
|
||||
const x = Number(n)
|
||||
if (Number.isNaN(x) || !Number.isFinite(x)) return '0'
|
||||
const v = Math.floor(x)
|
||||
if (v >= 10000) {
|
||||
const w = v / 10000
|
||||
const s = w >= 10 ? String(Math.floor(w)) : String(Math.round(w * 10) / 10).replace(/\.0$/, '')
|
||||
return s + '万'
|
||||
}
|
||||
return String(v)
|
||||
}
|
||||
|
||||
// 防抖函数
|
||||
const debounce = (fn, delay = 300) => {
|
||||
let timer = null
|
||||
@@ -189,6 +202,7 @@ module.exports = {
|
||||
formatTime,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatStatNum,
|
||||
formatNumber,
|
||||
debounce,
|
||||
throttle,
|
||||
|
||||
@@ -1,48 +1,47 @@
|
||||
# Soul 运营全链路技能包(本机一键打包)
|
||||
# Soul 运营全链路技能包(精简打包)
|
||||
|
||||
## 你要做的事(复制到另一台电脑前)
|
||||
## 一键打包(推荐:体积小、可重装)
|
||||
|
||||
在 **本机终端** 执行(路径按你实际安装调整):
|
||||
在 **本机终端** 执行:
|
||||
|
||||
```bash
|
||||
python3 "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/scripts/pack_soul_operation_skills.py"
|
||||
```
|
||||
|
||||
- 会在 **`~/Downloads/Soul运营全链路技能包_20260320.zip`** 生成压缩包(日期戳见脚本内 `STAMP`,可自行改)。
|
||||
- 临时文件在永平项目下 **`.tmp_skill_bundle/`**,打包完成后可整目录删除。
|
||||
- 输出:**`~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip`**(日期为**打包当天**)。
|
||||
- 临时目录:`一场soul的创业实验-永平/.tmp_skill_bundle/`,打完可删。
|
||||
|
||||
若卡若AI不在默认路径,请先编辑 `pack_soul_operation_skills.py` 里的:
|
||||
卡若AI不在默认路径时,编辑脚本内:
|
||||
|
||||
```python
|
||||
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
|
||||
```
|
||||
|
||||
## 压缩包里有什么(保证链路齐全)
|
||||
## 精简包策略(大文件不进包)
|
||||
|
||||
| 内容 | 说明 |
|
||||
| 类型 | 处理 |
|
||||
|:---|:---|
|
||||
| `.cursor/skills/soul-operation-report` | Cursor 入口:运营报表 |
|
||||
| `.cursor/skills/soul-party-project` | Cursor 入口:水岸项目管理 |
|
||||
| `卡若AI/02_卡人(水)/水岸_项目管理/` | 水岸总纲 + 卡若创业派对 README |
|
||||
| `卡若AI/.../水桥_平台对接/飞书管理/` | 运营报表、妙记相关脚本与 SKILL |
|
||||
| `卡若AI/.../水桥_平台对接/智能纪要/` | 妙记下载、纪要 SKILL + 脚本 |
|
||||
| `卡若AI/.../水桥_平台对接/Soul创业实验/` | 写作/上传/环境与 TOKEN 说明 |
|
||||
| `卡若AI/03_卡木(木)/木叶_视频内容/` 下 | `视频切片`、`多平台分发`、`抖音/B站/视频号/小红书/快手发布` |
|
||||
| `卡若AI/运营中枢/工作台/00_账号与API索引.md` | 若本机存在则一并打入(凭证速查) |
|
||||
| `解压后必读.md` | 在另一台电脑上的合并步骤与环境说明 |
|
||||
| 单文件 **> 512KB** | 跳过 |
|
||||
| 视频/音频/压缩包/模型权重等扩展名 | 跳过 |
|
||||
| `cookies/`、`node_modules`、`.browser_state`、`venv` 等 | 整目录跳过 |
|
||||
| `publish_log.json`、`.feishu_tokens.json` | 跳过(到新机按脚本重新授权;若要迁移凭证请**单独**安全拷贝) |
|
||||
|
||||
## 「可直接运作」在另一台机上的含义
|
||||
包内另有 **`重装依赖说明.md`**、**`_pack_stats.json`**(本次打入/跳过统计)。
|
||||
|
||||
- **Skill 与脚本文件**会齐;但要真正跑通,仍需在新电脑上:
|
||||
- 安装 **Python、依赖、FFmpeg、conda/mlx-whisper**(见各 SKILL);
|
||||
- 配置 **飞书/妙记/各平台 Cookie、小程序与永平项目 `.env`**(见 `Soul创业实验/上传/环境与TOKEN配置.md` 与 `00_账号与API索引.md`);
|
||||
- 把文档里原机的 **`/Users/karuo/...`** 改成新机器路径。
|
||||
## 压缩包里有什么(链路齐全 = SKILL + 脚本 + 小配置)
|
||||
|
||||
- `.cursor/skills/`:`soul-operation-report`、`soul-party-project`
|
||||
- `卡若AI/02_卡人(水)/水岸_项目管理/`
|
||||
- `卡若AI/.../水桥_平台对接/飞书管理/`、`智能纪要/`、`Soul创业实验/`
|
||||
- `卡若AI/03_卡木(木)/木叶_视频内容/`:`视频切片`、`多平台分发`、各平台发布(仅小文件)
|
||||
- `卡若AI/运营中枢/工作台/00_账号与API索引.md`(若存在且小于体积限制)
|
||||
|
||||
## 另一台电脑
|
||||
|
||||
1. 解压 → 合并 `卡若AI/` → 安装 Cursor skills。
|
||||
2. 按 **`重装依赖说明.md`**:`pip`/`conda`/`ffmpeg`/`playwright` 等。
|
||||
3. 重新配置飞书 Token、妙记 Cookie、各平台 Cookie、永平 `.env`。
|
||||
|
||||
## 安全
|
||||
|
||||
压缩包可能含 **密钥与 Cookie 说明**,请勿上传公开网盘;用 U 盘或加密渠道传输。
|
||||
|
||||
## 未打入的内容(属正常)
|
||||
|
||||
- **`飞书管理/脚本/.browser_state/`**:Playwright/Chrome 本地状态,常含断链或套接字文件,打包会失败且不宜迁移;**到新电脑需按各脚本说明重新登录/生成状态**。
|
||||
- 体积约 **260MB+**(含脚本与分发相关文件);若需更小体积,可自行从包内删掉用不到的平台目录后再压缩。
|
||||
勿将含密钥的压缩包上传公开网盘;用 U 盘或加密渠道传输。
|
||||
|
||||
@@ -1,48 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Soul 运营全链路技能包:将 SKILL + 脚本 + Cursor 入口打成 zip,默认输出到用户「下载」文件夹。
|
||||
Soul 运营全链路技能包(精简版):只打包 SKILL / 脚本 / 小配置,便于另一台机 pip/conda 重装。
|
||||
大文件、媒体、Cookie、日志等一律不入包。
|
||||
|
||||
用法:
|
||||
python3 scripts/pack_soul_operation_skills.py
|
||||
输出:
|
||||
~/Downloads/Soul运营全链路技能包_精简_YYYYMMDD.zip
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
STAMP = "20260320"
|
||||
BUNDLE_TOP = f"Soul运营全链路技能包_{STAMP}"
|
||||
# 单文件超过此大小则跳过(字节)——非「代码/文档类」扩展名
|
||||
MAX_FILE_BYTES = 512 * 1024 # 512KB
|
||||
|
||||
# 脚本与文档类可放宽(避免误跳过大 .py/.md;仍远小于整包 200MB+)
|
||||
CODE_DOC_EXT = frozenset(
|
||||
{
|
||||
".py",
|
||||
".md",
|
||||
".mdc",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".txt",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".cfg",
|
||||
".ini",
|
||||
".sql",
|
||||
".html",
|
||||
".css",
|
||||
".js",
|
||||
".ts",
|
||||
".tsx",
|
||||
".jsx",
|
||||
".svg",
|
||||
".xml",
|
||||
}
|
||||
)
|
||||
MAX_CODE_DOC_BYTES = 8 * 1024 * 1024 # 8MB
|
||||
|
||||
# 整段目录名匹配则不进包(walk 时不进入)
|
||||
SKIP_DIR_NAMES = frozenset(
|
||||
{
|
||||
"__pycache__",
|
||||
".git",
|
||||
".svn",
|
||||
".browser_state",
|
||||
"chromium_data",
|
||||
"node_modules",
|
||||
"venv",
|
||||
".venv",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".tox",
|
||||
"dist",
|
||||
"build",
|
||||
"eggs",
|
||||
".eggs",
|
||||
"htmlcov",
|
||||
".ruff_cache",
|
||||
# Cookie 到新机需重新登录导出,不入包
|
||||
"cookies",
|
||||
}
|
||||
)
|
||||
|
||||
# 扩展名一律跳过(媒体/模型/压缩包等)
|
||||
SKIP_EXTENSIONS = frozenset(
|
||||
{
|
||||
".mp4",
|
||||
".mov",
|
||||
".mkv",
|
||||
".avi",
|
||||
".webm",
|
||||
".m4v",
|
||||
".flv",
|
||||
".wmv",
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".tgz",
|
||||
".bz2",
|
||||
".xz",
|
||||
".rar",
|
||||
".7z",
|
||||
".dmg",
|
||||
".iso",
|
||||
".img",
|
||||
".pt",
|
||||
".pth",
|
||||
".onnx",
|
||||
".ckpt",
|
||||
".safetensors",
|
||||
".bin",
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".wav",
|
||||
".mp3",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
".npz",
|
||||
".npy",
|
||||
".pkl",
|
||||
".pickle",
|
||||
".whl",
|
||||
".parquet",
|
||||
".arrow",
|
||||
}
|
||||
)
|
||||
|
||||
# 文件名(不含路径)强制跳过
|
||||
SKIP_FILE_NAMES = frozenset(
|
||||
{
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
"publish_log.json", # 分发日志可能巨大
|
||||
".feishu_tokens.json", # 凭证,到新机用脚本重新获取更安全;若需带走可自行拷贝
|
||||
}
|
||||
)
|
||||
|
||||
# 卡若AI 根目录(按你本机实际修改)
|
||||
KARUO_AI = Path("/Users/karuo/Documents/个人/卡若AI")
|
||||
CURSOR_SKILLS = Path.home() / ".cursor" / "skills"
|
||||
DOWNLOADS = Path.home() / "Downloads"
|
||||
|
||||
# 在永平项目下临时组装(本仓库内,便于工具写入)
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
STAMP = _dt.date.today().strftime("%Y%m%d")
|
||||
BUNDLE_TOP = f"Soul运营全链路技能包_精简_{STAMP}"
|
||||
STAGING_PARENT = REPO_ROOT / ".tmp_skill_bundle"
|
||||
STAGING = STAGING_PARENT / BUNDLE_TOP
|
||||
|
||||
|
||||
def ignore_copy(dirpath: str, names: list[str]) -> list[str]:
|
||||
"""排除缓存、浏览器运行时目录(含断链/套接字,会导致 copytree 失败)。"""
|
||||
skip_dirs = {"__pycache__", ".browser_state", "chromium_data"}
|
||||
skip_files = {".DS_Store"}
|
||||
ignored: list[str] = []
|
||||
for n in names:
|
||||
if n in skip_dirs or n in skip_files or n.endswith(".pyc"):
|
||||
ignored.append(n)
|
||||
return ignored
|
||||
# 统计
|
||||
_stats: dict[str, int] = {"files": 0, "skipped_size": 0, "skipped_ext": 0, "skipped_dir": 0, "skipped_name": 0}
|
||||
|
||||
|
||||
def copytree(src: Path, dst: Path) -> None:
|
||||
if not src.exists():
|
||||
print(f"SKIP 不存在: {src}", file=sys.stderr)
|
||||
def should_skip_file(path: Path) -> tuple[bool, str]:
|
||||
name = path.name
|
||||
if name in SKIP_FILE_NAMES:
|
||||
return True, "name"
|
||||
ext = path.suffix.lower()
|
||||
if ext in SKIP_EXTENSIONS:
|
||||
return True, "ext"
|
||||
try:
|
||||
sz = path.stat().st_size
|
||||
except OSError:
|
||||
return True, "stat"
|
||||
limit = MAX_CODE_DOC_BYTES if ext in CODE_DOC_EXT else MAX_FILE_BYTES
|
||||
if sz > limit:
|
||||
return True, "size"
|
||||
return False, ""
|
||||
|
||||
|
||||
def copy_tree_selective(src: Path, dst_root: Path, rel_base: Path) -> None:
|
||||
"""将 src 下文件复制到 dst_root / rel_base,遵守跳过规则。"""
|
||||
if not src.is_dir():
|
||||
print(f"SKIP 非目录: {src}", file=sys.stderr)
|
||||
return
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True, ignore=ignore_copy)
|
||||
for root, dirnames, filenames in os_walk_topdown(src):
|
||||
root_path = Path(root)
|
||||
# 过滤要进入的子目录
|
||||
for d in list(dirnames):
|
||||
if d in SKIP_DIR_NAMES:
|
||||
dirnames.remove(d)
|
||||
_stats["skipped_dir"] += 1
|
||||
rel = root_path.relative_to(src)
|
||||
for fname in filenames:
|
||||
fp = root_path / fname
|
||||
skip, reason = should_skip_file(fp)
|
||||
if skip:
|
||||
if reason == "size":
|
||||
_stats["skipped_size"] += 1
|
||||
elif reason == "ext":
|
||||
_stats["skipped_ext"] += 1
|
||||
elif reason == "name":
|
||||
_stats["skipped_name"] += 1
|
||||
continue
|
||||
dest_dir = dst_root / rel_base / rel
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / fname
|
||||
shutil.copy2(fp, dest)
|
||||
_stats["files"] += 1
|
||||
|
||||
|
||||
def os_walk_topdown(src: Path):
|
||||
"""与 os.walk 相同,但用 Path。"""
|
||||
for r, dnames, fnames in os.walk(str(src), topdown=True):
|
||||
yield Path(r), dnames, fnames
|
||||
|
||||
|
||||
def copy_cursor_skill(name: str) -> None:
|
||||
src = CURSOR_SKILLS / name
|
||||
if not src.is_dir():
|
||||
print(f"SKIP 无 Cursor skill: {src}", file=sys.stderr)
|
||||
return
|
||||
copy_tree_selective(src, STAGING, Path(".cursor") / "skills" / name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -50,29 +215,35 @@ def main() -> int:
|
||||
print(f"ERROR: 未找到卡若AI目录: {KARUO_AI}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
global _stats
|
||||
_stats = {k: 0 for k in _stats}
|
||||
|
||||
if STAGING.exists():
|
||||
shutil.rmtree(STAGING)
|
||||
STAGING.mkdir(parents=True)
|
||||
|
||||
# Cursor 入口
|
||||
csk = STAGING / ".cursor" / "skills"
|
||||
csk.mkdir(parents=True, exist_ok=True)
|
||||
# Cursor 入口(通常只有 SKILL.md)
|
||||
for name in ("soul-operation-report", "soul-party-project"):
|
||||
p = CURSOR_SKILLS / name
|
||||
if p.is_dir():
|
||||
copytree(p, csk / name)
|
||||
copy_cursor_skill(name)
|
||||
|
||||
kai = STAGING / "卡若AI"
|
||||
copytree(
|
||||
kai_rel = Path("卡若AI")
|
||||
|
||||
def pack_sub(src_under_karuo: Path, rel_under_kai: Path) -> None:
|
||||
"""src_under_karuo 为卡若AI下的绝对路径;打入包内 卡若AI/rel_under_kai"""
|
||||
if not src_under_karuo.exists():
|
||||
print(f"SKIP 不存在: {src_under_karuo}", file=sys.stderr)
|
||||
return
|
||||
copy_tree_selective(src_under_karuo, STAGING, kai_rel / rel_under_kai)
|
||||
|
||||
pack_sub(
|
||||
KARUO_AI / "02_卡人(水)" / "水岸_项目管理",
|
||||
kai / "02_卡人(水)" / "水岸_项目管理",
|
||||
Path("02_卡人(水)") / "水岸_项目管理",
|
||||
)
|
||||
bridge = KARUO_AI / "02_卡人(水)" / "水桥_平台对接"
|
||||
for sub in ("飞书管理", "智能纪要", "Soul创业实验"):
|
||||
copytree(bridge / sub, kai / "02_卡人(水)" / "水桥_平台对接" / sub)
|
||||
pack_sub(bridge / sub, Path("02_卡人(水)") / "水桥_平台对接" / sub)
|
||||
|
||||
wood = KARUO_AI / "03_卡木(木)" / "木叶_视频内容"
|
||||
wdst = kai / "03_卡木(木)" / "木叶_视频内容"
|
||||
for sub in (
|
||||
"视频切片",
|
||||
"多平台分发",
|
||||
@@ -82,45 +253,74 @@ def main() -> int:
|
||||
"小红书发布",
|
||||
"快手发布",
|
||||
):
|
||||
copytree(wood / sub, wdst / sub)
|
||||
pack_sub(wood / sub, Path("03_卡木(木)") / "木叶_视频内容" / sub)
|
||||
|
||||
idx = KARUO_AI / "运营中枢" / "工作台" / "00_账号与API索引.md"
|
||||
if idx.is_file():
|
||||
(kai / "运营中枢" / "工作台").mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(idx, kai / "运营中枢" / "工作台" / idx.name)
|
||||
skip, _ = should_skip_file(idx)
|
||||
if not skip:
|
||||
dest = STAGING / kai_rel / "运营中枢" / "工作台" / idx.name
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(idx, dest)
|
||||
_stats["files"] += 1
|
||||
|
||||
readme = STAGING / "解压后必读.md"
|
||||
readme.write_text(
|
||||
"""# Soul 运营全链路技能包
|
||||
# 写入 requirements 汇总(若各目录有 requirements.txt,只列路径提示,不合并)
|
||||
req_hint = STAGING / "重装依赖说明.md"
|
||||
req_hint.write_text(
|
||||
f"""# 重装依赖说明(精简包)
|
||||
|
||||
## 包含内容
|
||||
本包**不含**大文件与本地状态,到新电脑请:
|
||||
|
||||
- `.cursor/skills/`:`soul-operation-report`、`soul-party-project`
|
||||
- `卡若AI/02_卡人(水)/水岸_项目管理/`
|
||||
- `卡若AI/02_卡人(水)/水桥_平台对接/飞书管理/`、`智能纪要/`、`Soul创业实验/`
|
||||
- `卡若AI/03_卡木(木)/木叶_视频内容/`:视频切片、多平台分发、各平台发布
|
||||
- `卡若AI/运营中枢/工作台/00_账号与API索引.md`(若源机存在)
|
||||
1. **Python**:建议 3.10+;进入各含 `requirements.txt` 的脚本目录执行 `pip install -r requirements.txt`(以各 SKILL 为准)。
|
||||
2. **系统**:`ffmpeg`、`ffprobe`(视频切片);视频转录见 SKILL 中的 **conda mlx-whisper** 环境说明。
|
||||
3. **Playwright**(若飞书脚本需要):`playwright install` 并按脚本说明登录;**`.browser_state` 未打包**。
|
||||
4. **多平台分发**:包内**不含 `cookies/` 目录**,需在新机各平台重新登录导出 Cookie(见多平台分发 SKILL)。
|
||||
5. **飞书 Token**:精简包默认**不含** `.feishu_tokens.json`,请在新机用脚本流程重新授权;若你刻意要迁移凭证请单独拷贝(注意安全)。
|
||||
|
||||
## 另一台电脑怎么用
|
||||
---
|
||||
|
||||
1. 解压后,将 `卡若AI/` **合并**到你本机卡若AI根目录(先备份)。
|
||||
2. 将 `.cursor/skills/` 下两个目录复制到 `~/.cursor/skills/`。
|
||||
3. 安装 Python/FFmpeg/conda 等依赖,按各 SKILL 与 `Soul创业实验/上传/环境与TOKEN配置.md` 配置 Token、Cookie、永平项目 `.env`。
|
||||
4. 文档或脚本里的 `/Users/karuo/...` 需改成本机路径。
|
||||
打包策略摘要(自动生成):
|
||||
|
||||
**安全**:包内可能有凭证说明,勿上传公开网盘。
|
||||
- 代码/文档类(`.py`、`.md` 等)单文件大于 **{MAX_CODE_DOC_BYTES // (1024 * 1024)} MB** 跳过;其它类型大于 **{MAX_FILE_BYTES // 1024} KB** 跳过
|
||||
- 跳过扩展名:媒体、压缩包、模型权重等
|
||||
- 跳过目录:`cookies`、`node_modules`、`.browser_state`、`venv` 等
|
||||
|
||||
打包日期:**{STAMP}**
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
for p in list(STAGING.rglob("__pycache__")):
|
||||
if p.is_dir():
|
||||
shutil.rmtree(p, ignore_errors=True)
|
||||
for p in STAGING.rglob("*.pyc"):
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
readme = STAGING / "解压后必读.md"
|
||||
readme.write_text(
|
||||
f"""# Soul 运营全链路技能包(精简版)
|
||||
|
||||
## 本包特点
|
||||
|
||||
- **体积小**:不含视频/大日志/模型/Cookie 目录等;到新机器按 `重装依赖说明.md` **重装环境与凭证**。
|
||||
- **日期**:{STAMP}
|
||||
|
||||
## 包含
|
||||
|
||||
- `.cursor/skills/`:`soul-operation-report`、`soul-party-project`
|
||||
- `卡若AI/` 下水岸、飞书管理、智能纪要、Soul创业实验、视频切片、多平台分发与各平台发布目录中的 **SKILL、脚本、小配置**(受大小与类型过滤)
|
||||
|
||||
## 合并步骤
|
||||
|
||||
1. 解压后把 `卡若AI/` **合并**进你的卡若AI根目录(先备份)。
|
||||
2. 将 `.cursor/skills/` 下两个文件夹复制到 `~/.cursor/skills/`。
|
||||
3. 阅读 **`重装依赖说明.md`**,安装 Python 依赖、FFmpeg、conda 环境等。
|
||||
4. 配置飞书、妙记、各平台 Cookie、永平 `.env`(见各 SKILL 与 `Soul创业实验/上传/环境与TOKEN配置.md`)。
|
||||
|
||||
**安全**:勿将含密钥的压缩包上传公开网盘。
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# 打包统计写入 JSON(便于核对)
|
||||
(STAGING / "_pack_stats.json").write_text(
|
||||
json.dumps({**_stats, "max_file_bytes": MAX_FILE_BYTES, "stamp": STAMP}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
DOWNLOADS.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = DOWNLOADS / f"{BUNDLE_TOP}.zip"
|
||||
@@ -132,7 +332,11 @@ def main() -> int:
|
||||
zf.write(f, arcname.as_posix())
|
||||
|
||||
mb = zip_path.stat().st_size / (1024 * 1024)
|
||||
print(f"完成: {zip_path} ({mb:.2f} MB)")
|
||||
print(f"完成: {zip_path}")
|
||||
print(f"大小: {mb:.2f} MB | 打入文件数: {_stats['files']}")
|
||||
print(
|
||||
f"跳过: 超体积 {_stats['skipped_size']} | 扩展名 {_stats['skipped_ext']} | 文件名 {_stats['skipped_name']} | 目录 {_stats['skipped_dir']}"
|
||||
)
|
||||
print(f"临时目录(可删): {STAGING}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到 Soul 彩民团队飞书群(默认 webhook)。
|
||||
生成章节海报(标题=章节标题、摘要+小程序码),上传到飞书并发送到开发群(默认 webhook,见 FEISHU_DEV_GROUP_WEBHOOK)。
|
||||
海报样式:深蓝背景、顶部装饰条、主标题为章节标题、摘要、底部「长按识别小程序码」+ 二维码。
|
||||
用法:
|
||||
python3 send_chapter_poster_to_feishu.py 9.24 "第112场|一个人起头,维权挣了大半套房"
|
||||
@@ -30,8 +30,11 @@ except ImportError:
|
||||
|
||||
# 与 post_to_feishu 保持一致
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
# 默认发到 Soul 彩民团队飞书群
|
||||
WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1"
|
||||
# 默认:Soul 开发群(派对 AI / 卡若 AI 与项目复盘统一入口,见 .cursor/docs/feishu_开发群与项目复盘.md)
|
||||
WEBHOOK = os.environ.get(
|
||||
"FEISHU_DEV_GROUP_WEBHOOK",
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
|
||||
)
|
||||
BACKEND_QRCODE_URL = "https://soulapi.quwanzhi.com/api/miniprogram/qrcode"
|
||||
MINIPROGRAM_READ_BASE = "https://soul.quwanzhi.com/read"
|
||||
|
||||
|
||||
138
scripts/send_feishu_text_and_images.py
Normal file
138
scripts/send_feishu_text_and_images.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
向开发群 webhook 发送一条长文本 + 若干本地 PNG(先上传飞书再发 image_key)。
|
||||
依赖:与 send_chapter_poster_to_feishu.py 相同,需 scripts/.env.feishu 内 FEISHU_APP_ID / FEISHU_APP_SECRET。
|
||||
|
||||
用法:
|
||||
python3 send_feishu_text_and_images.py --text-file recap.txt \\
|
||||
--images a.png b.png
|
||||
python3 send_feishu_text_and_images.py -t "单行文本" --images x.png
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("pip install requests", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
DEFAULT_WEBHOOK = os.environ.get(
|
||||
"FEISHU_DEV_GROUP_WEBHOOK",
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494",
|
||||
)
|
||||
|
||||
|
||||
def load_env_feishu():
|
||||
p = SCRIPT_DIR / ".env.feishu"
|
||||
if not p.is_file():
|
||||
return
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
||||
|
||||
|
||||
def tenant_token() -> str | None:
|
||||
load_env_feishu()
|
||||
app_id = os.environ.get("FEISHU_APP_ID", "")
|
||||
sec = os.environ.get("FEISHU_APP_SECRET", "")
|
||||
if not app_id or not sec:
|
||||
print("缺少 FEISHU_APP_ID / FEISHU_APP_SECRET(.env.feishu)", file=sys.stderr)
|
||||
return None
|
||||
r = requests.post(
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||||
json={"app_id": app_id, "app_secret": sec},
|
||||
timeout=15,
|
||||
)
|
||||
data = r.json() or {}
|
||||
if data.get("code") != 0:
|
||||
print("token 失败:", data, file=sys.stderr)
|
||||
return None
|
||||
return data.get("tenant_access_token")
|
||||
|
||||
|
||||
def send_text(webhook: str, text: str) -> bool:
|
||||
r = requests.post(webhook, json={"msg_type": "text", "content": {"text": text}}, timeout=15)
|
||||
d = r.json() or {}
|
||||
if d.get("code") != 0:
|
||||
print("文本发送失败:", d, file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def upload_png(token: str, path: Path) -> str | None:
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/images"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with path.open("rb") as f:
|
||||
r = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
files={"image": (path.name, f, "image/png")},
|
||||
data={"image_type": "message"},
|
||||
timeout=60,
|
||||
)
|
||||
out = r.json() or {}
|
||||
if out.get("code") != 0:
|
||||
print("上传失败", path, out, file=sys.stderr)
|
||||
return None
|
||||
return (out.get("data") or {}).get("image_key")
|
||||
|
||||
|
||||
def send_image(webhook: str, image_key: str) -> bool:
|
||||
r = requests.post(
|
||||
webhook,
|
||||
json={"msg_type": "image", "content": {"image_key": image_key}},
|
||||
timeout=15,
|
||||
)
|
||||
d = r.json() or {}
|
||||
if d.get("code") != 0:
|
||||
print("图片消息失败:", d, file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("-t", "--text", default="", help="直接传入文本")
|
||||
ap.add_argument("--text-file", type=Path, help="从文件读文本(utf-8)")
|
||||
ap.add_argument("--webhook", "-w", default=DEFAULT_WEBHOOK)
|
||||
ap.add_argument("--images", "-i", nargs="*", default=[], help="PNG 路径列表")
|
||||
args = ap.parse_args()
|
||||
|
||||
body = args.text.strip()
|
||||
if args.text_file:
|
||||
body = args.text_file.read_text(encoding="utf-8").strip()
|
||||
if not body:
|
||||
ap.error("需要 -t 或 --text-file")
|
||||
|
||||
if not send_text(args.webhook, body[:20000]):
|
||||
sys.exit(1)
|
||||
print("已发文本")
|
||||
|
||||
if not args.images:
|
||||
return
|
||||
|
||||
tok = tenant_token()
|
||||
if not tok:
|
||||
sys.exit(1)
|
||||
for p in args.images:
|
||||
path = Path(p).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
print("跳过(不存在):", path, file=sys.stderr)
|
||||
continue
|
||||
key = upload_png(tok, path)
|
||||
if key and send_image(args.webhook, key):
|
||||
print("已发图:", path.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,9 +4,8 @@
|
||||
|
||||
重要说明(微信官方限制):
|
||||
- 代码上传:可用本机「微信开发者工具」CLI 或 miniprogram-ci。
|
||||
- submit_audit:开放平台文档标明主要为「第三方平台代调用」;自有主体使用小程序 appid+secret
|
||||
换取的 access_token 调用时,常见返回 errcode=86000(仅允许第三方代调用),此时必须在
|
||||
mp 后台手动点「提交审核」。
|
||||
- submit_audit:主要为「第三方平台代调用」;自有主体用 appid+secret 常返回 errcode=86000,
|
||||
无法在仓库内替代网页提审;`release` 默认只跑上传+接口调用,不弹浏览器、不提示手动操作。
|
||||
- 「自动过审」不可能由开发者脚本保证:是否通过由微信审核决定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -149,6 +148,8 @@ def cmd_submit_audit(
|
||||
version_desc: str,
|
||||
item_json: Path | None,
|
||||
privacy_api_not_use: bool | None,
|
||||
*,
|
||||
quiet: bool = False,
|
||||
) -> dict:
|
||||
token = get_access_token(appid, secret)
|
||||
if item_json and item_json.is_file():
|
||||
@@ -184,18 +185,19 @@ def cmd_submit_audit(
|
||||
except urllib.error.HTTPError as e:
|
||||
raise SystemExit(f"submit_audit HTTP 错误: {e}") from e
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
if data.get("errcode") == 86000:
|
||||
print(
|
||||
"\n说明 errcode=86000:该接口仅支持「第三方平台」代小程序调用。"
|
||||
"自有主体请在浏览器打开公众平台 → 管理 → 版本管理 → 提交审核。\n"
|
||||
"可先运行: python3 scripts/wechat_miniprogram_release.py open-mp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif data.get("errcode") == 61039:
|
||||
print(
|
||||
"\n说明 errcode=61039:上传后隐私/代码检测任务未完成,请等待数分钟后再提交审核。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if not quiet:
|
||||
if data.get("errcode") == 86000:
|
||||
print(
|
||||
"\n说明 errcode=86000:该接口仅支持「第三方平台」代小程序调用。"
|
||||
"自有主体请在浏览器打开公众平台 → 管理 → 版本管理 → 提交审核。\n"
|
||||
"可先运行: python3 scripts/wechat_miniprogram_release.py open-version",
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif data.get("errcode") == 61039:
|
||||
print(
|
||||
"\n说明 errcode=61039:上传后隐私/代码检测任务未完成,请等待数分钟后再提交审核。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
@@ -291,7 +293,10 @@ def main() -> None:
|
||||
)
|
||||
p_uo.add_argument("--desc", "-d", default="", help="默认:版本 v<版本号>")
|
||||
|
||||
p_rel = sub.add_parser("release", help="先 upload 再 submit-audit(提审失败仍可到后台操作)")
|
||||
p_rel = sub.add_parser(
|
||||
"release",
|
||||
help="上传 → 尝试 submit_audit(默认不弹浏览器、不提示手动打开;可加 --open-browser)",
|
||||
)
|
||||
p_rel.add_argument(
|
||||
"--version",
|
||||
"-v",
|
||||
@@ -305,6 +310,11 @@ def main() -> None:
|
||||
action=argparse.BooleanOptionalAction,
|
||||
default=None,
|
||||
)
|
||||
p_rel.add_argument(
|
||||
"--open-browser",
|
||||
action="store_true",
|
||||
help="完成后打开公众平台版本管理页(默认关闭)",
|
||||
)
|
||||
|
||||
args = p.parse_args()
|
||||
|
||||
@@ -336,18 +346,17 @@ def main() -> None:
|
||||
if args.cmd == "release":
|
||||
d = args.desc.strip() or f"版本 v{args.version}"
|
||||
cmd_upload(args.version, d)
|
||||
if not appid or not secret:
|
||||
print(
|
||||
"未设置 WECHAT_APPID / WECHAT_APPSECRET,跳过 submit-audit。",
|
||||
file=sys.stderr,
|
||||
if appid and secret:
|
||||
vd = (args.version_desc or "").strip() or d
|
||||
cmd_submit_audit(
|
||||
appid,
|
||||
secret,
|
||||
vd,
|
||||
args.item_json,
|
||||
args.privacy_api_not_use,
|
||||
quiet=True,
|
||||
)
|
||||
cmd_open_mp_version()
|
||||
return
|
||||
vd = (args.version_desc or "").strip() or d
|
||||
res = cmd_submit_audit(
|
||||
appid, secret, vd, args.item_json, args.privacy_api_not_use
|
||||
)
|
||||
if res.get("errcode") == 86000:
|
||||
if getattr(args, "open_browser", False):
|
||||
cmd_open_mp_version()
|
||||
return
|
||||
|
||||
|
||||
1
soul-admin/dist/assets/index-CDCvtX8z.css
vendored
Normal file
1
soul-admin/dist/assets/index-CDCvtX8z.css
vendored
Normal file
File diff suppressed because one or more lines are too long
965
soul-admin/dist/assets/index-CW7Mmh6Q.js
vendored
Normal file
965
soul-admin/dist/assets/index-CW7Mmh6Q.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-DYq6N0y0.css
vendored
1
soul-admin/dist/assets/index-DYq6N0y0.css
vendored
File diff suppressed because one or more lines are too long
955
soul-admin/dist/assets/index-etcBHhA9.js
vendored
955
soul-admin/dist/assets/index-etcBHhA9.js
vendored
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-etcBHhA9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DYq6N0y0.css">
|
||||
<script type="module" crossorigin src="/assets/index-CW7Mmh6Q.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CDCvtX8z.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,9 @@ import {
|
||||
Link2,
|
||||
FileText,
|
||||
Cloud,
|
||||
Smile,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
|
||||
@@ -72,6 +75,8 @@ interface MpConfig {
|
||||
mchId?: string
|
||||
minWithdraw?: number
|
||||
auditMode?: boolean
|
||||
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
|
||||
mpUi?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface OssConfig {
|
||||
@@ -113,6 +118,39 @@ const defaultFeatures: FeatureConfig = {
|
||||
aboutEnabled: true,
|
||||
}
|
||||
|
||||
/** 与管理端保存后、后端 deepMergeMpUi 的默认结构对齐,供「填入模板」与文档说明 */
|
||||
const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
|
||||
tabBar: { home: '首页', chapters: '目录', match: '找伙伴', my: '我的' },
|
||||
chaptersPage: {
|
||||
bookTitle: '一场SOUL的创业实验场',
|
||||
bookSubtitle: '来自Soul派对房的真实商业故事',
|
||||
},
|
||||
homePage: {
|
||||
logoTitle: '卡若创业派对',
|
||||
logoSubtitle: '来自派对房的真实故事',
|
||||
linkKaruoText: '点击链接卡若',
|
||||
searchPlaceholder: '搜索章节标题或内容...',
|
||||
bannerTag: '推荐',
|
||||
bannerReadMoreText: '点击阅读',
|
||||
superSectionTitle: '超级个体',
|
||||
superSectionLinkText: '获客入口',
|
||||
superSectionLinkPath: '/pages/match/match',
|
||||
pickSectionTitle: '精选推荐',
|
||||
latestSectionTitle: '最新新增',
|
||||
},
|
||||
myPage: {
|
||||
cardLabel: '名片',
|
||||
vipLabelVip: '会员中心',
|
||||
vipLabelGuest: '成为会员',
|
||||
cardPath: '',
|
||||
vipPath: '/pages/vip/vip',
|
||||
readStatLabel: '已读章节',
|
||||
recentReadTitle: '最近阅读',
|
||||
readStatPath: '/pages/reading-records/reading-records?focus=all',
|
||||
recentReadPath: '/pages/reading-records/reading-records?focus=recent',
|
||||
},
|
||||
}
|
||||
|
||||
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
|
||||
type TabKey = (typeof TAB_KEYS)[number]
|
||||
|
||||
@@ -124,6 +162,7 @@ export function SettingsPage() {
|
||||
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
||||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||||
const [mpUiJson, setMpUiJson] = useState('{}')
|
||||
const [ossConfig, setOssConfig] = useState<OssConfig>({})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -133,6 +172,39 @@ export function SettingsPage() {
|
||||
const [dialogIsError, setDialogIsError] = useState(false)
|
||||
const [featureSwitchSaving, setFeatureSwitchSaving] = useState(false)
|
||||
|
||||
const MBTI_TYPES = [
|
||||
'INTJ','INTP','ENTJ','ENTP',
|
||||
'INFJ','INFP','ENFJ','ENFP',
|
||||
'ISTJ','ISFJ','ESTJ','ESFJ',
|
||||
'ISTP','ISFP','ESTP','ESFP',
|
||||
]
|
||||
const [mbtiAvatars, setMbtiAvatars] = useState<Record<string, string>>({})
|
||||
const [mbtiLoading, setMbtiLoading] = useState(false)
|
||||
const [mbtiSaving, setMbtiSaving] = useState(false)
|
||||
|
||||
const loadMbtiAvatars = async () => {
|
||||
setMbtiLoading(true)
|
||||
try {
|
||||
const res = await get<{ success?: boolean; avatars?: Record<string, string> }>('/api/admin/mbti-avatars')
|
||||
if (res?.avatars) setMbtiAvatars(res.avatars)
|
||||
} catch { /* ignore */ }
|
||||
finally { setMbtiLoading(false) }
|
||||
}
|
||||
|
||||
const saveMbtiAvatars = async () => {
|
||||
setMbtiSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/mbti-avatars', { avatars: mbtiAvatars })
|
||||
if (!res || res.success === false) {
|
||||
showResult('保存失败', res?.error ?? '未知错误', true)
|
||||
return
|
||||
}
|
||||
showResult('已保存', 'MBTI 头像映射已保存,无头像的超级个体将自动使用对应性格头像。')
|
||||
} catch (error) {
|
||||
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
|
||||
} finally { setMbtiSaving(false) }
|
||||
}
|
||||
|
||||
const showResult = (title: string, message: string, isError = false) => {
|
||||
setDialogTitle(title)
|
||||
setDialogMessage(message)
|
||||
@@ -153,8 +225,18 @@ export function SettingsPage() {
|
||||
if (!res || (res as { success?: boolean }).success === false) return
|
||||
if (res.featureConfig && Object.keys(res.featureConfig).length)
|
||||
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
|
||||
if (res.mpConfig && typeof res.mpConfig === 'object')
|
||||
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
|
||||
if (res.mpConfig && typeof res.mpConfig === 'object') {
|
||||
const merged = { ...res.mpConfig } as MpConfig
|
||||
setMpConfig((prev) => ({ ...prev, ...merged }))
|
||||
const raw = merged.mpUi
|
||||
setMpUiJson(
|
||||
JSON.stringify(
|
||||
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (res.ossConfig && typeof res.ossConfig === 'object')
|
||||
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
|
||||
if (res.siteSettings && typeof res.siteSettings === 'object') {
|
||||
@@ -175,6 +257,7 @@ export function SettingsPage() {
|
||||
}
|
||||
}
|
||||
load()
|
||||
loadMbtiAvatars()
|
||||
}, [])
|
||||
|
||||
const saveFeatureConfigOnly = async (
|
||||
@@ -235,6 +318,25 @@ export function SettingsPage() {
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
let mpUi: Record<string, unknown> = {}
|
||||
try {
|
||||
const t = mpUiJson.trim()
|
||||
if (t) {
|
||||
const parsed: unknown = JSON.parse(t)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
mpUi = parsed as Record<string, unknown>
|
||||
} else {
|
||||
showResult('保存失败', '小程序文案 mpUi 须为 JSON 对象(非数组)', true)
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
showResult('保存失败', '小程序文案 mpUi 不是合法 JSON', true)
|
||||
setIsSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
|
||||
featureConfig,
|
||||
siteSettings: {
|
||||
@@ -251,6 +353,7 @@ export function SettingsPage() {
|
||||
mchId: mpConfig.mchId || '',
|
||||
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
|
||||
auditMode: mpConfig.auditMode ?? false,
|
||||
mpUi,
|
||||
},
|
||||
ossConfig: Object.keys(ossConfig).length
|
||||
? {
|
||||
@@ -599,6 +702,31 @@ export function SettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-gray-700/50">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label className="text-gray-300">小程序界面文案 mpUi(JSON)</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-600 text-gray-200"
|
||||
onClick={() => setMpUiJson(JSON.stringify(MP_UI_TEMPLATE_OBJECT, null, 2))}
|
||||
>
|
||||
填入默认模板
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
覆盖 Tab 文案、首页/目录标题、我的页名片与阅读记录路径等;仅填需要改的字段也可(与后端默认值深合并)。保存后小程序约 5
|
||||
分钟内通过 config 缓存刷新。
|
||||
</p>
|
||||
<Textarea
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm min-h-[280px]"
|
||||
spellCheck={false}
|
||||
value={mpUiJson}
|
||||
onChange={(e) => setMpUiJson(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -804,6 +932,86 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Smile className="w-5 h-5 text-[#38bdac]" />
|
||||
MBTI 头像组
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
为 16 种 MBTI 性格类型配置默认头像 URL。无头像的超级个体将自动使用对应性格的头像。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{mbtiLoading ? (
|
||||
<p className="text-gray-500 text-sm">加载中...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{MBTI_TYPES.map((t) => (
|
||||
<div key={t} className="flex items-center gap-2">
|
||||
<span className="text-xs text-[#38bdac] font-mono w-10 flex-shrink-0">{t}</span>
|
||||
{mbtiAvatars[t] && (
|
||||
<img src={mbtiAvatars[t]} alt={t} className="w-8 h-8 rounded-full flex-shrink-0 object-cover border border-gray-600" />
|
||||
)}
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white text-xs h-8 flex-1"
|
||||
placeholder="头像 URL"
|
||||
value={mbtiAvatars[t] ?? ''}
|
||||
onChange={(e) =>
|
||||
setMbtiAvatars((prev) => ({ ...prev, [t]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
onClick={saveMbtiAvatars}
|
||||
disabled={mbtiSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
size="sm"
|
||||
>
|
||||
<Save className="w-3 h-3 mr-1" />
|
||||
{mbtiSaving ? '保存中...' : '保存头像映射'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Eye className="w-5 h-5 text-[#38bdac]" />
|
||||
小程序模块显隐说明
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
以下模块受上方开关和审核模式共同控制
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{[
|
||||
{ mod: '找伙伴', ctrl: '找伙伴功能开关', icon: <Users className="w-3 h-3" /> },
|
||||
{ mod: '推广中心 / 推荐好友', ctrl: '推广功能开关', icon: <Gift className="w-3 h-3" /> },
|
||||
{ mod: '搜索', ctrl: '搜索功能开关', icon: <BookOpen className="w-3 h-3" /> },
|
||||
{ mod: '关于页面', ctrl: '关于页面开关', icon: <UserCircle className="w-3 h-3" /> },
|
||||
{ mod: '支付 / VIP / 充值 / 收益', ctrl: '审核模式', icon: <ShieldCheck className="w-3 h-3" /> },
|
||||
{ mod: '超级个体名片', ctrl: '审核模式', icon: <Smile className="w-3 h-3" /> },
|
||||
{ mod: '首页获客入口', ctrl: '已移除', icon: <EyeOff className="w-3 h-3" /> },
|
||||
].map((r) => (
|
||||
<div key={r.mod} className="flex items-center gap-2 p-2 rounded bg-[#0a1628] border border-gray-700/30">
|
||||
{r.icon}
|
||||
<div>
|
||||
<span className="text-white">{r.mod}</span>
|
||||
<span className="text-gray-500 ml-1">← {r.ctrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -177,6 +177,13 @@ export function UsersPage() {
|
||||
// ===== 用户旅程总览 =====
|
||||
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
|
||||
const [journeyLoading, setJourneyLoading] = useState(false)
|
||||
const [journeyStage, setJourneyStage] = useState<string | null>(null)
|
||||
const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone: string; createdAt: string }[]>([])
|
||||
const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
|
||||
const [trackUserId, setTrackUserId] = useState<string | null>(null)
|
||||
const [trackUserNick, setTrackUserNick] = useState('')
|
||||
const [userTracks, setUserTracks] = useState<{ id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[]>([])
|
||||
const [userTracksLoading, setUserTracksLoading] = useState(false)
|
||||
|
||||
// ===== 获客列表(存客宝) =====
|
||||
const [leadsRecords, setLeadsRecords] = useState<{
|
||||
@@ -599,6 +606,23 @@ export function UsersPage() {
|
||||
if (data?.success && data.stats) setJourneyStats(data.stats)
|
||||
} catch { } finally { setJourneyLoading(false) }
|
||||
}, [])
|
||||
const loadJourneyUsers = useCallback(async (stage: string) => {
|
||||
setJourneyStage(stage)
|
||||
setJourneyUsersLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone: string; createdAt: string }[] }>(`/api/db/users/journey-users?stage=${stage}&limit=50`)
|
||||
if (data?.success && data.users) setJourneyUsers(data.users)
|
||||
} catch { } finally { setJourneyUsersLoading(false) }
|
||||
}, [])
|
||||
const loadUserTracks = useCallback(async (userId: string, nick: string) => {
|
||||
setTrackUserId(userId)
|
||||
setTrackUserNick(nick)
|
||||
setUserTracksLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; tracks?: { id: string; action: string; actionLabel: string; target: string; chapterTitle: string; module: string; createdAt: string; timeAgo: string }[] }>(`/api/db/users/tracks?userId=${userId}&limit=50`)
|
||||
if (data?.success && data.tracks) setUserTracks(data.tracks)
|
||||
} catch { } finally { setUserTracksLoading(false) }
|
||||
}, [])
|
||||
|
||||
// ===== 批量用户补全 =====
|
||||
const [batchEnrichLoading, setBatchEnrichLoading] = useState(false)
|
||||
@@ -1124,13 +1148,8 @@ export function UsersPage() {
|
||||
<div key={stage.id} className="relative flex flex-col items-center">
|
||||
{/* 阶段卡片 */}
|
||||
<div
|
||||
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity`}
|
||||
onClick={() => {
|
||||
const sp = new URLSearchParams(searchParams)
|
||||
sp.delete('tab')
|
||||
sp.set('search', stage.label)
|
||||
setSearchParams(sp)
|
||||
}}
|
||||
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer hover:opacity-80 transition-opacity ${journeyStage === stage.id ? 'ring-2 ring-[#38bdac]' : ''}`}
|
||||
onClick={() => loadJourneyUsers(stage.id)}
|
||||
title={`点击查看「${stage.label}」阶段的用户`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{stage.icon}</div>
|
||||
@@ -1167,7 +1186,7 @@ export function UsersPage() {
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
{[
|
||||
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '引导填写头像' },
|
||||
{ step: '① 注册', action: '微信 OAuth 或手机号注册', next: '提示设置头像与昵称' },
|
||||
{ step: '② 浏览', action: '点击章节/阅读免费内容', next: '触发绑定手机' },
|
||||
{ step: '③ 首付', action: '购买单章或全书', next: '推送分销功能' },
|
||||
{ step: '④ VIP', action: '¥1980 购买全书', next: '进入 VIP 私域群' },
|
||||
@@ -1217,12 +1236,95 @@ export function UsersPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 阶段用户列表(点击阶段卡片后展开) */}
|
||||
{journeyStage && (
|
||||
<div className="mt-6 bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">
|
||||
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.icon}{' '}
|
||||
{JOURNEY_STAGES.find(s => s.id === journeyStage)?.label} 阶段用户
|
||||
</span>
|
||||
<Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">{journeyUsers.length} 人</Badge>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setJourneyStage(null)} className="text-gray-400 hover:text-white"><X className="w-4 h-4" /></Button>
|
||||
</div>
|
||||
{journeyUsersLoading ? (
|
||||
<div className="flex items-center justify-center py-8"><RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" /></div>
|
||||
) : journeyUsers.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-6">该阶段暂无用户</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-gray-700">
|
||||
<TableHead className="text-gray-400">昵称</TableHead>
|
||||
<TableHead className="text-gray-400">手机号</TableHead>
|
||||
<TableHead className="text-gray-400">注册时间</TableHead>
|
||||
<TableHead className="text-gray-400 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{journeyUsers.map(u => (
|
||||
<TableRow key={u.id} className="border-gray-700/50 hover:bg-[#0a1628]">
|
||||
<TableCell className="text-white">{u.nickname || '微信用户'}</TableCell>
|
||||
<TableCell className="text-gray-300">{u.phone || '-'}</TableCell>
|
||||
<TableCell className="text-gray-400 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleString('zh-CN') : '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" className="text-[#38bdac] hover:bg-[#38bdac]/10" onClick={() => loadUserTracks(u.id, u.nickname || '微信用户')}>
|
||||
<Eye className="w-4 h-4 mr-1" /> 行为轨迹
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户行为轨迹弹窗 */}
|
||||
<Dialog open={!!trackUserId} onOpenChange={(open) => { if (!open) setTrackUserId(null) }}>
|
||||
<DialogContent className="sm:max-w-[600px] bg-[#0f2137] border-gray-700 text-white max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Navigation className="w-5 h-5 text-[#38bdac]" />
|
||||
{trackUserNick} 的行为轨迹
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{userTracksLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
|
||||
) : userTracks.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">该用户暂无行为记录</p>
|
||||
) : (
|
||||
<div className="relative pl-6 space-y-0">
|
||||
<div className="absolute left-[11px] top-2 bottom-2 w-0.5 bg-gray-700" />
|
||||
{userTracks.map((t, idx) => (
|
||||
<div key={t.id || idx} className="relative flex items-start gap-3 py-2">
|
||||
<div className="absolute left-[-13px] top-3 w-2.5 h-2.5 rounded-full bg-[#38bdac] border-2 border-[#0f2137] z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-white text-sm font-medium">{t.actionLabel}</span>
|
||||
{t.module && <Badge className="bg-purple-500/10 text-purple-400 border border-purple-500/30 text-[10px]">{t.module}</Badge>}
|
||||
</div>
|
||||
{(t.chapterTitle || t.target) && (
|
||||
<p className="text-gray-400 text-xs mt-0.5 truncate">{t.chapterTitle || t.target}</p>
|
||||
)}
|
||||
<p className="text-gray-600 text-[10px] mt-0.5">{t.timeAgo} · {t.createdAt ? new Date(t.createdAt).toLocaleString('zh-CN') : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 规则配置 ===== */}
|
||||
<TabsContent value="rules">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-gray-400 text-sm">用户旅程引导规则,定义各行为节点的触发条件与引导内容</p>
|
||||
<p className="text-gray-400 text-sm">用户旅程触达规则:各行为节点的触发条件与展示文案(偏利他说明,少用命令式)</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadRules} disabled={rulesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${rulesLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
@@ -1244,23 +1346,26 @@ export function UsersPage() {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className={`p-4 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<PenLine className="w-4 h-4 text-[#38bdac] shrink-0" />
|
||||
<span className="text-white font-medium">{rule.title}</span>
|
||||
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-xs">触发:{rule.trigger}</Badge>}
|
||||
<Badge className={`text-xs border-0 ${rule.enabled ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>{rule.enabled ? '启用' : '禁用'}</Badge>
|
||||
</div>
|
||||
{rule.description && <p className="text-gray-400 text-sm ml-6">{rule.description}</p>}
|
||||
<div key={rule.id} className={`p-3 rounded-lg border transition-all ${rule.enabled ? 'bg-[#0f2137] border-gray-700/50' : 'bg-[#0a1628]/50 border-gray-700/30 opacity-55'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<span className="text-gray-600 text-xs font-mono w-5 shrink-0 text-right">#{rule.sort}</span>
|
||||
<PenLine className="w-3.5 h-3.5 text-[#38bdac] shrink-0" />
|
||||
<span className="text-white font-medium text-sm truncate">{rule.title}</span>
|
||||
{rule.trigger && <Badge className="bg-[#38bdac]/10 text-[#38bdac] border border-[#38bdac]/30 text-[10px] shrink-0">{rule.trigger}</Badge>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-4 shrink-0">
|
||||
<div className="flex items-center gap-1.5 ml-3 shrink-0">
|
||||
<Switch checked={rule.enabled} onCheckedChange={() => handleToggleRule(rule)} />
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"><Edit3 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10"><Trash2 className="w-4 h-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setEditingRule(rule); setRuleForm({ title: rule.title, description: rule.description, trigger: rule.trigger, sort: rule.sort, enabled: rule.enabled }); setShowRuleModal(true) }} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10 h-7 w-7 p-0"><Edit3 className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteRule(rule.id)} className="text-red-400 hover:text-red-300 hover:bg-red-500/10 h-7 w-7 p-0"><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
{rule.description && (
|
||||
<details className="ml-[52px] mt-1">
|
||||
<summary className="text-gray-500 text-xs cursor-pointer hover:text-gray-400 select-none">查看描述</summary>
|
||||
<p className="text-gray-400 text-sm mt-1 pl-1 border-l-2 border-gray-700">{rule.description}</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -111,6 +111,12 @@ func Init(dsn string) error {
|
||||
if err := db.AutoMigrate(&model.UserRule{}); err != nil {
|
||||
log.Printf("database: user_rules migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserTrack{}); err != nil {
|
||||
log.Printf("database: user_tracks migrate warning: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.UserRuleCompletion{}); err != nil {
|
||||
log.Printf("database: user_rule_completions migrate warning: %v", err)
|
||||
}
|
||||
log.Println("database: connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
88
soul-api/internal/handler/admin_mbti_avatars.go
Normal file
88
soul-api/internal/handler/admin_mbti_avatars.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const mbtiAvatarsConfigKey = "mbti_avatars"
|
||||
const mbtiAvatarsDescription = "MBTI 16型人格头像映射"
|
||||
|
||||
// AdminMbtiAvatarsGet GET /api/admin/mbti-avatars 读取 MBTI 头像映射(system_config.mbti_avatars)
|
||||
func AdminMbtiAvatarsGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var row model.SystemConfig
|
||||
err := db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": map[string]string{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "读取配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
out := make(map[string]string)
|
||||
if len(row.ConfigValue) > 0 {
|
||||
if uerr := json.Unmarshal(row.ConfigValue, &out); uerr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "配置 JSON 无效: " + uerr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "avatars": out})
|
||||
}
|
||||
|
||||
// AdminMbtiAvatarsPost POST /api/admin/mbti-avatars 保存 MBTI 头像映射(upsert)
|
||||
func AdminMbtiAvatarsPost(c *gin.Context) {
|
||||
var body struct {
|
||||
Avatars map[string]string `json:"avatars"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
|
||||
return
|
||||
}
|
||||
avatars := body.Avatars
|
||||
if avatars == nil {
|
||||
avatars = map[string]string{}
|
||||
}
|
||||
valBytes, err := json.Marshal(avatars)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "序列化失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
desc := mbtiAvatarsDescription
|
||||
var row model.SystemConfig
|
||||
err = db.Where("config_key = ?", mbtiAvatarsConfigKey).First(&row).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
row = model.SystemConfig{
|
||||
ConfigKey: mbtiAvatarsConfigKey,
|
||||
ConfigValue: valBytes,
|
||||
Description: &desc,
|
||||
}
|
||||
if cerr := db.Create(&row).Error; cerr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + cerr.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "查询配置失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
row.ConfigValue = valBytes
|
||||
row.Description = &desc
|
||||
if serr := db.Save(&row).Error; serr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + serr.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
_mbtiAvatarCacheTs = 0
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "MBTI 头像映射已保存"})
|
||||
}
|
||||
@@ -37,7 +37,8 @@ func DBUserRulesList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
}
|
||||
|
||||
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules 小程序规则引擎:返回启用的规则,无需鉴权
|
||||
// MiniprogramUserRulesGet GET /api/miniprogram/user-rules?userId=xxx
|
||||
// 返回启用的规则,并标记当前用户已完成的规则
|
||||
func MiniprogramUserRulesGet(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var rules []model.UserRule
|
||||
@@ -45,7 +46,43 @@ func MiniprogramUserRulesGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": rules})
|
||||
userId := c.Query("userId")
|
||||
completedSet := make(map[uint]bool)
|
||||
if userId != "" {
|
||||
var completions []model.UserRuleCompletion
|
||||
db.Where("user_id = ?", userId).Find(&completions)
|
||||
for _, comp := range completions {
|
||||
completedSet[comp.RuleID] = true
|
||||
}
|
||||
}
|
||||
out := make([]gin.H, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
out = append(out, gin.H{
|
||||
"id": r.ID, "title": r.Title, "description": r.Description,
|
||||
"trigger": r.Trigger, "sort": r.Sort, "completed": completedSet[r.ID],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "rules": out})
|
||||
}
|
||||
|
||||
// MiniprogramUserRuleComplete POST /api/miniprogram/user-rules/complete
|
||||
func MiniprogramUserRuleComplete(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
RuleID uint `json:"ruleId" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
return
|
||||
}
|
||||
db := database.DB()
|
||||
comp := model.UserRuleCompletion{UserID: body.UserID, RuleID: body.RuleID}
|
||||
result := db.Where("user_id = ? AND rule_id = ?", body.UserID, body.RuleID).FirstOrCreate(&comp)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": result.Error.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "alreadyCompleted": result.RowsAffected == 0})
|
||||
}
|
||||
|
||||
// DBUserRulesAction POST/PUT/DELETE /api/db/user-rules
|
||||
|
||||
@@ -7,11 +7,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -417,6 +419,21 @@ func CKBIndexLead(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
|
||||
personName := "卡若"
|
||||
if defaultPerson.Name != "" {
|
||||
personName = defaultPerson.Name
|
||||
}
|
||||
go sendLeadWebhook(db, leadWebhookPayload{
|
||||
LeadName: name,
|
||||
Phone: phone,
|
||||
Wechat: wechatId,
|
||||
PersonName: personName,
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
}
|
||||
@@ -446,13 +463,15 @@ func CKBIndexLead(c *gin.Context) {
|
||||
// 请求体:phone/wechatId(至少一个)、userId(补全昵称)、targetUserId(Person.token)、targetNickname、source(如 article_mention、member_detail_avatar)
|
||||
func CKBLead(c *gin.Context) {
|
||||
var body struct {
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
TargetUserID string `json:"targetUserId"` // 被@的 personId(文章 mention 场景)
|
||||
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
|
||||
Source string `json:"source"` // index_lead / article_mention
|
||||
UserID string `json:"userId"`
|
||||
Phone string `json:"phone"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Name string `json:"name"`
|
||||
TargetUserID string `json:"targetUserId"` // 被@的 personId(文章 mention / 超级个体人物 token)
|
||||
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
|
||||
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id(无 person token 时全局留资,写入 params 便于运营)
|
||||
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params,不误导读为「对方会联系您」)
|
||||
Source string `json:"source"` // index_lead / article_mention / member_detail_global
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
phone := strings.TrimSpace(body.Phone)
|
||||
@@ -510,7 +529,8 @@ func CKBLead(c *gin.Context) {
|
||||
}
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
|
||||
"targetUserId": body.TargetUserID, "source": source,
|
||||
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
|
||||
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
|
||||
})
|
||||
_ = db.Create(&model.CkbLeadRecord{
|
||||
UserID: body.UserID,
|
||||
@@ -585,7 +605,16 @@ func CKBLead(c *gin.Context) {
|
||||
}
|
||||
data["repeatedSubmit"] = repeatedSubmit
|
||||
|
||||
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
|
||||
go sendLeadWebhook(db, leadWebhookPayload{
|
||||
LeadName: name,
|
||||
Phone: phone,
|
||||
Wechat: wechatId,
|
||||
PersonName: who,
|
||||
MemberName: strings.TrimSpace(body.TargetMemberName),
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
|
||||
return
|
||||
@@ -649,7 +678,16 @@ func CKBLead(c *gin.Context) {
|
||||
} else {
|
||||
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
|
||||
}
|
||||
go sendLeadWebhook(db, name, phone, wechatId, who, source, repeatedSubmit)
|
||||
go sendLeadWebhook(db, leadWebhookPayload{
|
||||
LeadName: name,
|
||||
Phone: phone,
|
||||
Wechat: wechatId,
|
||||
PersonName: who,
|
||||
MemberName: strings.TrimSpace(body.TargetMemberName),
|
||||
Source: source,
|
||||
Repeated: repeatedSubmit,
|
||||
LeadUserID: body.UserID,
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
|
||||
return
|
||||
}
|
||||
@@ -676,7 +714,64 @@ func CKBLead(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, respObj)
|
||||
}
|
||||
|
||||
func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, repeated bool) {
|
||||
type leadWebhookPayload struct {
|
||||
LeadName string // 留资客户姓名
|
||||
Phone string
|
||||
Wechat string
|
||||
PersonName string // 对接人(Person 表 name / targetNickname)
|
||||
MemberName string // 超级个体名称(targetMemberName)
|
||||
Source string // 技术来源标识
|
||||
Repeated bool
|
||||
LeadUserID string // 留资用户ID,用于查询行为轨迹
|
||||
}
|
||||
|
||||
func leadSourceLabel(source string) string {
|
||||
switch source {
|
||||
case "member_detail_global":
|
||||
return "超级个体详情页·全局链接"
|
||||
case "member_detail_avatar":
|
||||
return "超级个体详情页·点击头像"
|
||||
case "article_mention":
|
||||
return "文章正文·@提及人物"
|
||||
case "index_link_button":
|
||||
return "首页·链接卡若按钮"
|
||||
case "index_lead":
|
||||
return "首页·留资弹窗"
|
||||
default:
|
||||
if source == "" {
|
||||
return "未知来源"
|
||||
}
|
||||
return source
|
||||
}
|
||||
}
|
||||
|
||||
var _webhookDedupCache = struct {
|
||||
sync.Mutex
|
||||
m map[string]string
|
||||
}{m: make(map[string]string)}
|
||||
|
||||
func webhookShouldSkip(userId string) bool {
|
||||
if userId == "" {
|
||||
return false
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
_webhookDedupCache.Lock()
|
||||
defer _webhookDedupCache.Unlock()
|
||||
if _webhookDedupCache.m[userId] == today {
|
||||
return true
|
||||
}
|
||||
_webhookDedupCache.m[userId] = today
|
||||
if len(_webhookDedupCache.m) > 10000 {
|
||||
_webhookDedupCache.m = map[string]string{userId: today}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sendLeadWebhook(db *gorm.DB, p leadWebhookPayload) {
|
||||
if p.LeadUserID != "" && webhookShouldSkip(p.LeadUserID) {
|
||||
log.Printf("webhook: skip duplicate for user %s today", p.LeadUserID)
|
||||
return
|
||||
}
|
||||
var cfg model.SystemConfig
|
||||
if db.Where("config_key = ?", "ckb_lead_webhook_url").First(&cfg).Error != nil {
|
||||
return
|
||||
@@ -690,19 +785,41 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
|
||||
return
|
||||
}
|
||||
|
||||
tag := "新获客"
|
||||
if repeated {
|
||||
tag = "重复获客"
|
||||
tag := "📋 新获客"
|
||||
if p.Repeated {
|
||||
tag = "🔄 重复获客"
|
||||
}
|
||||
text := fmt.Sprintf("[%s] %s → %s\n姓名: %s", tag, source, target, name)
|
||||
if phone != "" {
|
||||
text += fmt.Sprintf("\n手机: %s", phone)
|
||||
sourceLabel := leadSourceLabel(p.Source)
|
||||
|
||||
contactPerson := p.PersonName
|
||||
if contactPerson == "" {
|
||||
contactPerson = p.MemberName
|
||||
}
|
||||
if wechat != "" {
|
||||
text += fmt.Sprintf("\n微信: %s", wechat)
|
||||
if contactPerson == "" || contactPerson == "对方" {
|
||||
contactPerson = "(公共获客池)"
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s\n来源: %s\n对接人: %s", tag, sourceLabel, contactPerson)
|
||||
text += "\n━━━━━━━━━━"
|
||||
text += fmt.Sprintf("\n姓名: %s", p.LeadName)
|
||||
if p.Phone != "" {
|
||||
text += fmt.Sprintf("\n手机: %s", p.Phone)
|
||||
}
|
||||
if p.Wechat != "" {
|
||||
text += fmt.Sprintf("\n微信: %s", p.Wechat)
|
||||
}
|
||||
text += fmt.Sprintf("\n时间: %s", time.Now().Format("2006-01-02 15:04"))
|
||||
|
||||
if p.LeadUserID != "" {
|
||||
recentTracks := GetUserRecentTracks(db, p.LeadUserID, 5)
|
||||
if len(recentTracks) > 0 {
|
||||
text += "\n━━━━━━━━━━\n最近行为:"
|
||||
for i, line := range recentTracks {
|
||||
text += fmt.Sprintf("\n %d. %s", i+1, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var payload []byte
|
||||
if strings.Contains(webhookURL, "qyapi.weixin.qq.com") {
|
||||
payload, _ = json.Marshal(map[string]interface{}{
|
||||
@@ -721,5 +838,5 @@ func sendLeadWebhook(db *gorm.DB, name, phone, wechat, target, source string, re
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", target, resp.StatusCode)
|
||||
fmt.Printf("[CKBWebhook] 已推送获客通知 → %s (status=%d)\n", contactPerson, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,80 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// defaultMpUi 小程序文案与导航默认值,存于 mp_config.mpUi;管理端系统设置可部分覆盖(深合并)
|
||||
func defaultMpUi() gin.H {
|
||||
return gin.H{
|
||||
"tabBar": gin.H{
|
||||
"home": "首页", "chapters": "目录", "match": "找伙伴", "my": "我的",
|
||||
},
|
||||
"chaptersPage": gin.H{
|
||||
"bookTitle": "一场SOUL的创业实验场",
|
||||
"bookSubtitle": "来自Soul派对房的真实商业故事",
|
||||
},
|
||||
"homePage": gin.H{
|
||||
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
|
||||
"linkKaruoText": "点击链接卡若", "searchPlaceholder": "搜索章节标题或内容...",
|
||||
"bannerTag": "推荐", "bannerReadMoreText": "点击阅读",
|
||||
"superSectionTitle": "超级个体", "superSectionLinkText": "获客入口",
|
||||
"superSectionLinkPath": "/pages/match/match",
|
||||
"pickSectionTitle": "精选推荐",
|
||||
"latestSectionTitle": "最新新增",
|
||||
},
|
||||
"myPage": gin.H{
|
||||
"cardLabel": "名片", "vipLabelVip": "会员中心", "vipLabelGuest": "成为会员",
|
||||
"cardPath": "", "vipPath": "/pages/vip/vip",
|
||||
"readStatLabel": "已读章节", "recentReadTitle": "最近阅读",
|
||||
"readStatPath": "/pages/reading-records/reading-records?focus=all",
|
||||
"recentReadPath": "/pages/reading-records/reading-records?focus=recent",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func asStringMap(v interface{}) map[string]interface{} {
|
||||
if v == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// deepMergeMpUi 将 DB 中的 mpUi 与默认值深合并(嵌套 map)
|
||||
func deepMergeMpUi(base gin.H, overRaw interface{}) gin.H {
|
||||
over := asStringMap(overRaw)
|
||||
out := gin.H{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range over {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
bv := out[k]
|
||||
vm := asStringMap(v)
|
||||
if len(vm) == 0 && v != nil {
|
||||
// 非 map 覆盖
|
||||
out[k] = v
|
||||
continue
|
||||
}
|
||||
if len(vm) > 0 {
|
||||
bm := asStringMap(bv)
|
||||
if len(bm) == 0 {
|
||||
out[k] = deepMergeMpUi(gin.H{}, vm)
|
||||
} else {
|
||||
sub := gin.H{}
|
||||
for sk, sv := range bm {
|
||||
sub[sk] = sv
|
||||
}
|
||||
out[k] = deepMergeMpUi(sub, vm)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMiniprogramConfig 从 DB 构建小程序配置,供 GetPublicDBConfig 与 WarmConfigCache 复用
|
||||
func buildMiniprogramConfig() gin.H {
|
||||
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
|
||||
@@ -36,6 +110,7 @@ func buildMiniprogramConfig() gin.H {
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"shareIcon": "", // 分享图标URL,由管理端配置
|
||||
"mpUi": defaultMpUi(),
|
||||
}
|
||||
|
||||
out := gin.H{
|
||||
@@ -87,6 +162,7 @@ func buildMiniprogramConfig() gin.H {
|
||||
for k, v := range m {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
|
||||
out["mpConfig"] = merged
|
||||
out["configs"].(gin.H)["mp_config"] = merged
|
||||
}
|
||||
@@ -380,6 +456,7 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
"minWithdraw": float64(10),
|
||||
"auditMode": false,
|
||||
"supportWechat": true,
|
||||
"mpUi": defaultMpUi(),
|
||||
}
|
||||
out := gin.H{
|
||||
"success": true,
|
||||
@@ -416,6 +493,7 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
for k, v := range m {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["mpUi"] = deepMergeMpUi(defaultMpUi(), m["mpUi"])
|
||||
out["mpConfig"] = merged
|
||||
}
|
||||
case "oss_config":
|
||||
|
||||
@@ -476,12 +476,16 @@ func MyEarnings(c *gin.Context) {
|
||||
if availableEarnings < 0 {
|
||||
availableEarnings = 0
|
||||
}
|
||||
var activeReferralCount int64
|
||||
db.Model(&model.ReferralBinding{}).
|
||||
Where("referrer_id = ? AND status = 'active' AND expiry_date > ?", userId, time.Now()).
|
||||
Count(&activeReferralCount)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"totalCommission": round(totalCommission, 2),
|
||||
"availableEarnings": round(availableEarnings, 2),
|
||||
"referralCount": getIntValue(user.ReferralCount),
|
||||
"referralCount": activeReferralCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -550,6 +551,46 @@ func UserReadingProgressGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
|
||||
}
|
||||
|
||||
// parseCompletedAtPtr 解析 completedAt:RFC3339 字符串、毫秒/秒时间戳(float64)
|
||||
func parseCompletedAtPtr(v interface{}) *time.Time {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch x := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(x)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return &t
|
||||
}
|
||||
return nil
|
||||
case float64:
|
||||
if x <= 0 {
|
||||
return nil
|
||||
}
|
||||
var t time.Time
|
||||
if x >= 1e12 {
|
||||
t = time.UnixMilli(int64(x))
|
||||
} else {
|
||||
t = time.Unix(int64(x), 0)
|
||||
}
|
||||
return &t
|
||||
case int64:
|
||||
if x <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.UnixMilli(x)
|
||||
return &t
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseDuration 从 JSON 解析 duration,兼容数字与字符串(防止客户端传字符串导致累加异常)
|
||||
func parseDuration(v interface{}) int {
|
||||
if v == nil {
|
||||
@@ -578,7 +619,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
Progress int `json:"progress"`
|
||||
Duration interface{} `json:"duration"` // 兼容 int/float64/string,防止字符串导致累加异常
|
||||
Status string `json:"status"`
|
||||
CompletedAt *string `json:"completedAt"`
|
||||
CompletedAt interface{} `json:"completedAt"` // 兼容 ISO 字符串或历史客户端误传的时间戳数字
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少必要参数"})
|
||||
@@ -602,11 +643,8 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if newStatus == "" {
|
||||
newStatus = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
} else if existing.CompletedAt != nil {
|
||||
completedAt := parseCompletedAtPtr(body.CompletedAt)
|
||||
if completedAt == nil && existing.CompletedAt != nil {
|
||||
completedAt = existing.CompletedAt
|
||||
}
|
||||
db.Model(&existing).Updates(map[string]interface{}{
|
||||
@@ -618,11 +656,7 @@ func UserReadingProgressPost(c *gin.Context) {
|
||||
if status == "" {
|
||||
status = "reading"
|
||||
}
|
||||
var completedAt *time.Time
|
||||
if body.CompletedAt != nil && *body.CompletedAt != "" {
|
||||
t, _ := time.Parse(time.RFC3339, *body.CompletedAt)
|
||||
completedAt = &t
|
||||
}
|
||||
completedAt := parseCompletedAtPtr(body.CompletedAt)
|
||||
db.Create(&model.ReadingProgress{
|
||||
UserID: body.UserID, SectionID: body.SectionID, Progress: body.Progress, Duration: duration,
|
||||
Status: status, CompletedAt: completedAt, FirstOpenAt: &now, LastOpenAt: &now,
|
||||
@@ -786,6 +820,106 @@ func UserTrackGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": formatted, "stats": stats, "total": len(formatted)})
|
||||
}
|
||||
|
||||
// DBUserTracksList GET /api/db/users/tracks?userId=xxx&limit=20 管理端查看某用户行为轨迹
|
||||
func DBUserTracksList(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "需要 userId"})
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
db := database.DB()
|
||||
var tracks []model.UserTrack
|
||||
db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks)
|
||||
titleMap := resolveChapterTitlesForTracks(db, tracks)
|
||||
out := make([]gin.H, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
target := ""
|
||||
if t.Target != nil {
|
||||
target = *t.Target
|
||||
}
|
||||
chTitle := ""
|
||||
if t.ChapterID != nil {
|
||||
chTitle = titleMap[strings.TrimSpace(*t.ChapterID)]
|
||||
}
|
||||
if chTitle == "" && target != "" {
|
||||
chTitle = titleMap[strings.TrimSpace(target)]
|
||||
}
|
||||
var extra map[string]interface{}
|
||||
if len(t.ExtraData) > 0 {
|
||||
_ = json.Unmarshal(t.ExtraData, &extra)
|
||||
}
|
||||
module := ""
|
||||
if extra != nil {
|
||||
if m, ok := extra["module"].(string); ok {
|
||||
module = m
|
||||
}
|
||||
}
|
||||
var createdAt time.Time
|
||||
if t.CreatedAt != nil {
|
||||
createdAt = *t.CreatedAt
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": t.ID, "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action),
|
||||
"target": target, "chapterTitle": chTitle, "module": module,
|
||||
"createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": out, "total": len(out)})
|
||||
}
|
||||
|
||||
// GetUserRecentTracks 内部复用:获取用户最近 N 条有效行为的可读文字(用于 webhook 等)
|
||||
func GetUserRecentTracks(db *gorm.DB, userId string, limit int) []string {
|
||||
if userId == "" || limit < 1 {
|
||||
return nil
|
||||
}
|
||||
var tracks []model.UserTrack
|
||||
db.Where("user_id = ?", userId).Order("created_at DESC").Limit(limit).Find(&tracks)
|
||||
titleMap := resolveChapterTitlesForTracks(db, tracks)
|
||||
lines := make([]string, 0, len(tracks))
|
||||
for _, t := range tracks {
|
||||
label := userTrackActionLabelCN(t.Action)
|
||||
target := ""
|
||||
if t.ChapterID != nil {
|
||||
if v := titleMap[strings.TrimSpace(*t.ChapterID)]; v != "" {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
if target == "" && t.Target != nil {
|
||||
target = *t.Target
|
||||
if v := titleMap[strings.TrimSpace(target)]; v != "" {
|
||||
target = v
|
||||
}
|
||||
}
|
||||
var extra map[string]interface{}
|
||||
if len(t.ExtraData) > 0 {
|
||||
_ = json.Unmarshal(t.ExtraData, &extra)
|
||||
}
|
||||
module := ""
|
||||
if extra != nil {
|
||||
if m, ok := extra["module"].(string); ok {
|
||||
module = m
|
||||
}
|
||||
}
|
||||
var line string
|
||||
if target != "" {
|
||||
line = fmt.Sprintf("%s: %s", label, sanitizeDisplayOneLine(target))
|
||||
} else if module != "" {
|
||||
line = fmt.Sprintf("%s (%s)", label, module)
|
||||
} else {
|
||||
line = label
|
||||
}
|
||||
if t.CreatedAt != nil {
|
||||
line += " · " + humanTimeAgoCN(*t.CreatedAt)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// UserTrackPost POST /api/user/track 记录行为(GORM)
|
||||
func UserTrackPost(c *gin.Context) {
|
||||
var body struct {
|
||||
@@ -940,22 +1074,75 @@ func UserDashboardStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 遍历:统计 readSectionIds / totalReadSeconds,同时去重取最近 5 个不重复章节
|
||||
readCount := len(progressList)
|
||||
// 2. 按章节去重:已读数 = 不重复 section_id 数量;列表按「最近一次打开」倒序
|
||||
type secAgg struct {
|
||||
lastOpen time.Time
|
||||
}
|
||||
secMap := make(map[string]*secAgg)
|
||||
totalReadSeconds := 0
|
||||
recentIDs := make([]string, 0, 5)
|
||||
seenRecent := make(map[string]bool)
|
||||
readSectionIDs := make([]string, 0, len(progressList))
|
||||
for _, item := range progressList {
|
||||
totalReadSeconds += item.Duration
|
||||
if item.SectionID != "" {
|
||||
readSectionIDs = append(readSectionIDs, item.SectionID)
|
||||
// 去重:同一章节只保留最近一次
|
||||
if !seenRecent[item.SectionID] && len(recentIDs) < 5 {
|
||||
seenRecent[item.SectionID] = true
|
||||
recentIDs = append(recentIDs, item.SectionID)
|
||||
}
|
||||
sid := strings.TrimSpace(item.SectionID)
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
var t time.Time
|
||||
if item.LastOpenAt != nil {
|
||||
t = *item.LastOpenAt
|
||||
} else if !item.UpdatedAt.IsZero() {
|
||||
t = item.UpdatedAt
|
||||
} else {
|
||||
t = item.CreatedAt
|
||||
}
|
||||
if agg, ok := secMap[sid]; ok {
|
||||
if t.After(agg.lastOpen) {
|
||||
agg.lastOpen = t
|
||||
}
|
||||
} else {
|
||||
secMap[sid] = &secAgg{lastOpen: t}
|
||||
}
|
||||
}
|
||||
|
||||
// 2b. 已购买的章节(orders 表)也计入已读;用 pay_time 作为 lastOpen
|
||||
var purchasedRows []struct {
|
||||
ProductID string
|
||||
PayTime *time.Time
|
||||
}
|
||||
db.Model(&model.Order{}).
|
||||
Select("product_id, pay_time").
|
||||
Where("user_id = ? AND product_type = 'section' AND status IN ? AND product_id IS NOT NULL AND product_id != ''",
|
||||
userID, []string{"paid", "completed", "success"}).
|
||||
Scan(&purchasedRows)
|
||||
for _, row := range purchasedRows {
|
||||
sid := strings.TrimSpace(row.ProductID)
|
||||
if sid == "" {
|
||||
continue
|
||||
}
|
||||
var pt time.Time
|
||||
if row.PayTime != nil {
|
||||
pt = *row.PayTime
|
||||
}
|
||||
if agg, ok := secMap[sid]; ok {
|
||||
if !pt.IsZero() && pt.After(agg.lastOpen) {
|
||||
agg.lastOpen = pt
|
||||
}
|
||||
} else {
|
||||
secMap[sid] = &secAgg{lastOpen: pt}
|
||||
}
|
||||
}
|
||||
|
||||
readCount := len(secMap)
|
||||
sortedSectionIDs := make([]string, 0, len(secMap))
|
||||
for sid := range secMap {
|
||||
sortedSectionIDs = append(sortedSectionIDs, sid)
|
||||
}
|
||||
sort.Slice(sortedSectionIDs, func(i, j int) bool {
|
||||
return secMap[sortedSectionIDs[i]].lastOpen.After(secMap[sortedSectionIDs[j]].lastOpen)
|
||||
})
|
||||
readSectionIDs := sortedSectionIDs
|
||||
recentIDs := sortedSectionIDs
|
||||
if len(recentIDs) > 5 {
|
||||
recentIDs = recentIDs[:5]
|
||||
}
|
||||
|
||||
// 不足 60 秒但有阅读记录时,至少显示 1 分钟
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -345,6 +346,9 @@ func formatVipMember(db *gorm.DB, u *model.User, isVip bool) gin.H {
|
||||
if avatar == "" {
|
||||
avatar = getUrlValue(u.VipAvatar)
|
||||
}
|
||||
if avatar == "" && u.Mbti != nil && *u.Mbti != "" {
|
||||
avatar = getMbtiAvatar(db, strings.ToUpper(strings.TrimSpace(*u.Mbti)))
|
||||
}
|
||||
avatar = resolveAvatarURL(avatar)
|
||||
project := getStringValue(u.VipProject)
|
||||
if project == "" {
|
||||
@@ -420,3 +424,24 @@ func formatVipMember(db *gorm.DB, u *model.User, isVip bool) gin.H {
|
||||
func parseInt(s string) (int, error) {
|
||||
return strconv.Atoi(s)
|
||||
}
|
||||
|
||||
var _mbtiAvatarCache map[string]string
|
||||
var _mbtiAvatarCacheTs int64
|
||||
|
||||
func getMbtiAvatar(db *gorm.DB, mbti string) string {
|
||||
now := time.Now().Unix()
|
||||
if _mbtiAvatarCache != nil && now-_mbtiAvatarCacheTs < 300 {
|
||||
return _mbtiAvatarCache[mbti]
|
||||
}
|
||||
var cfg model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "mbti_avatars").First(&cfg).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
m := make(map[string]string)
|
||||
if err := json.Unmarshal([]byte(cfg.ConfigValue), &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
_mbtiAvatarCache = m
|
||||
_mbtiAvatarCacheTs = now
|
||||
return m[mbti]
|
||||
}
|
||||
|
||||
@@ -35,13 +35,12 @@ type Person struct {
|
||||
StartTime string `gorm:"column:start_time;size:10;default:'09:00'" json:"startTime"`
|
||||
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
|
||||
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
|
||||
// 置顶到小程序首页
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"`
|
||||
|
||||
// PersonSource 来源:空=后台手工添加;vip_sync=超级个体自动同步(共用统一计划)
|
||||
PersonSource string `gorm:"column:person_source;size:32;default:''" json:"personSource"`
|
||||
|
||||
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"` // 置顶到小程序首页
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
// User 对应表 users,JSON 输出与现网接口 1:1(小写驼峰)
|
||||
// 软删除:管理端删除仅设置 deleted_at,用户再次登录会创建新账号
|
||||
type User struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
|
||||
SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON
|
||||
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
|
||||
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
|
||||
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
|
||||
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
OpenID *string `gorm:"column:open_id;size:100" json:"openId,omitempty"`
|
||||
SessionKey *string `gorm:"column:session_key;size:200" json:"-"` // 微信 session_key,不输出到 JSON
|
||||
Nickname *string `gorm:"column:nickname;size:100" json:"nickname,omitempty"`
|
||||
Avatar *string `gorm:"column:avatar;size:500" json:"avatar,omitempty"`
|
||||
Phone *string `gorm:"column:phone;size:20" json:"phone,omitempty"`
|
||||
WechatID *string `gorm:"column:wechat_id;size:100" json:"wechatId,omitempty"`
|
||||
Tags *string `gorm:"column:tags;type:text" json:"tags,omitempty"`
|
||||
// P3 资料扩展(stitch_soul)
|
||||
Mbti *string `gorm:"column:mbti;size:16" json:"mbti,omitempty"`
|
||||
Region *string `gorm:"column:region;size:100" json:"region,omitempty"`
|
||||
@@ -43,18 +43,18 @@ type User struct {
|
||||
Source *string `gorm:"column:source;size:50" json:"source,omitempty"`
|
||||
|
||||
// 用户标签(管理端编辑、神射手回填共用 ckb_tags 列,JSON 数组字符串)
|
||||
CkbTags *string `gorm:"column:ckb_tags;type:text" json:"ckbTags,omitempty"`
|
||||
CkbTags *string `gorm:"column:ckb_tags;type:text" json:"ckbTags,omitempty"`
|
||||
// VIP 相关(与 next-project 线上 users 表一致,支持手动设置;管理端需读写)
|
||||
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
|
||||
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
|
||||
VipActivatedAt *time.Time `gorm:"column:vip_activated_at" json:"vipActivatedAt,omitempty"` // 成为 VIP 时间,排序用:付款=pay_time,手动=now
|
||||
VipSort *int `gorm:"column:vip_sort" json:"vipSort,omitempty"` // 手动排序,越小越前,NULL 按 vip_activated_at
|
||||
VipRole *string `gorm:"column:vip_role;size:50" json:"vipRole,omitempty"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
|
||||
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"vipAvatar,omitempty"`
|
||||
VipProject *string `gorm:"column:vip_project;size:200" json:"vipProject,omitempty"`
|
||||
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
|
||||
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
|
||||
IsVip *bool `gorm:"column:is_vip" json:"isVip,omitempty"`
|
||||
VipExpireDate *time.Time `gorm:"column:vip_expire_date" json:"vipExpireDate,omitempty"`
|
||||
VipActivatedAt *time.Time `gorm:"column:vip_activated_at" json:"vipActivatedAt,omitempty"` // 成为 VIP 时间,排序用:付款=pay_time,手动=now
|
||||
VipSort *int `gorm:"column:vip_sort" json:"vipSort,omitempty"` // 手动排序,越小越前,NULL 按 vip_activated_at
|
||||
VipRole *string `gorm:"column:vip_role;size:50" json:"vipRole,omitempty"` // 角色:从 vip_roles 选或手动填写
|
||||
VipName *string `gorm:"column:vip_name;size:100" json:"vipName,omitempty"`
|
||||
VipAvatar *string `gorm:"column:vip_avatar;size:500" json:"vipAvatar,omitempty"`
|
||||
VipProject *string `gorm:"column:vip_project;size:200" json:"vipProject,omitempty"`
|
||||
VipContact *string `gorm:"column:vip_contact;size:100" json:"vipContact,omitempty"`
|
||||
VipBio *string `gorm:"column:vip_bio;type:text" json:"vipBio,omitempty"`
|
||||
|
||||
// 软删除:管理端假删除,用户再次登录会新建账号
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
|
||||
|
||||
@@ -2,7 +2,7 @@ package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserRule 用户旅程引导规则(如:匹配后填写头像、付款1980需填写信息等)
|
||||
// UserRule 用户旅程触达规则(各节点弹窗标题/说明,由管理端配置)
|
||||
type UserRule struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
|
||||
12
soul-api/internal/model/user_rule_completion.go
Normal file
12
soul-api/internal/model/user_rule_completion.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type UserRuleCompletion struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserID string `gorm:"column:user_id;size:100;not null;uniqueIndex:idx_user_rule" json:"userId"`
|
||||
RuleID uint `gorm:"column:rule_id;not null;uniqueIndex:idx_user_rule" json:"ruleId"`
|
||||
CompletedAt time.Time `gorm:"column:completed_at;autoCreateTime" json:"completedAt"`
|
||||
}
|
||||
|
||||
func (UserRuleCompletion) TableName() string { return "user_rule_completions" }
|
||||
@@ -117,8 +117,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.GET("/super-individual/stats", handler.AdminSuperIndividualStats)
|
||||
admin.GET("/user/track", handler.UserTrackGet)
|
||||
admin.GET("/track/stats", handler.AdminTrackStats)
|
||||
admin.GET("/dashboard/leads", handler.AdminDashboardLeads)
|
||||
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
|
||||
admin.GET("/mbti-avatars", handler.AdminMbtiAvatarsGet)
|
||||
admin.POST("/mbti-avatars", handler.AdminMbtiAvatarsPost)
|
||||
}
|
||||
|
||||
// ----- 鉴权 -----
|
||||
@@ -193,6 +194,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
db.GET("/users/referrals", handler.DBUsersReferrals)
|
||||
db.GET("/users/rfm", handler.DBUsersRFM)
|
||||
db.GET("/users/journey-stats", handler.DBUsersJourneyStats)
|
||||
db.GET("/users/journey-users", handler.DBUsersJourneyUsers)
|
||||
db.GET("/users/tracks", handler.DBUserTracksList)
|
||||
db.GET("/vip-roles", handler.DBVipRolesList)
|
||||
db.POST("/vip-roles", handler.DBVipRolesAction)
|
||||
db.PUT("/vip-roles", handler.DBVipRolesAction)
|
||||
@@ -371,8 +374,9 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.GET("/persons/pinned", handler.DBPersonPinnedList)
|
||||
// 埋点
|
||||
miniprogram.POST("/track", handler.MiniprogramTrackPost)
|
||||
// 规则引擎(用户旅程引导)
|
||||
// 规则引擎(用户旅程触达)
|
||||
miniprogram.GET("/user-rules", handler.MiniprogramUserRulesGet)
|
||||
miniprogram.POST("/user-rules/complete", handler.MiniprogramUserRuleComplete)
|
||||
// 余额
|
||||
miniprogram.GET("/balance", handler.BalanceGet)
|
||||
miniprogram.GET("/balance/transactions", handler.BalanceTransactionsGet)
|
||||
|
||||
@@ -341,32 +341,32 @@ def upload_and_extract(cfg, tarball_path, no_restart=False, restart_method="auto
|
||||
if not ok and restart_method in ("auto", "ssh"):
|
||||
# SSH:正式环境固定监听 DEPLOY_PORT(默认 8080)。用 fuser 释放端口,避免宝塔守护
|
||||
# 启动的进程 cwd 与项目目录不一致导致 pgrep+cwd 校验永远失败。
|
||||
# 用 timeout + bash -c 单引号包裹,避免远端偶发挂死导致 Paramiko stdout.read 永久阻塞。
|
||||
restart_inner = (
|
||||
# 拆成两次 exec:先短命令起进程,本机 sleep 后再 curl,避免单条远程命令+管道偶发拖死 Paramiko。
|
||||
start_cmd = (
|
||||
"cd %s && (fuser -k %d/tcp 2>/dev/null || true) && sleep 2 && "
|
||||
"setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & "
|
||||
"sleep 12 && curl -sf --connect-timeout 5 --max-time 15 "
|
||||
"( setsid nohup ./soul-api >> soul-api.log 2>&1 </dev/null & ) && "
|
||||
"sleep 1 && echo START_OK"
|
||||
) % (project_path, DEPLOY_PORT)
|
||||
stdin, stdout, stderr = client.exec_command(
|
||||
"timeout 45 bash -c " + shlex.quote(start_cmd),
|
||||
timeout=55,
|
||||
get_pty=True,
|
||||
)
|
||||
start_out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
if "START_OK" not in start_out:
|
||||
print(" [stderr] 起进程输出: %s" % start_out[:300])
|
||||
time.sleep(12)
|
||||
health_cmd = (
|
||||
"curl -sf --connect-timeout 5 --max-time 15 "
|
||||
"http://127.0.0.1:%d/health 2>/dev/null | grep -q '\"status\"' "
|
||||
"&& echo RESTART_OK || echo RESTART_FAIL"
|
||||
) % (project_path, DEPLOY_PORT, DEPLOY_PORT)
|
||||
restart_cmd = "timeout 95 bash -c " + shlex.quote(restart_inner)
|
||||
stdin, stdout, stderr = client.exec_command(restart_cmd, timeout=110)
|
||||
err_holder = []
|
||||
|
||||
def _drain_stderr():
|
||||
try:
|
||||
err_holder.append(stderr.read().decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
err_holder.append("")
|
||||
|
||||
t = threading.Thread(target=_drain_stderr)
|
||||
t.daemon = True
|
||||
t.start()
|
||||
) % DEPLOY_PORT
|
||||
stdin, stdout, stderr = client.exec_command(
|
||||
"timeout 25 bash -c " + shlex.quote(health_cmd),
|
||||
timeout=35,
|
||||
get_pty=True,
|
||||
)
|
||||
out = stdout.read().decode("utf-8", errors="replace").strip()
|
||||
t.join(timeout=5)
|
||||
err = (err_holder[0] if err_holder else "").strip()
|
||||
if err:
|
||||
print(" [stderr] %s" % err[:200])
|
||||
ok = "RESTART_OK" in out
|
||||
if ok:
|
||||
print(" [成功] soulApp 已通过 SSH 重启")
|
||||
|
||||
40
soul-api/scripts/insert_vip_linpengcheng_kingdee.sql
Normal file
40
soul-api/scripts/insert_vip_linpengcheng_kingdee.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- 林鹏程:金蝶生态与企业数字化主责;含「超级个体课题」表述(写入 project_intro 专段)
|
||||
-- 若已存在同 id 请先删或改 id
|
||||
INSERT INTO users (
|
||||
id, nickname, avatar, referral_code, source,
|
||||
is_vip, vip_expire_date, vip_activated_at, vip_sort,
|
||||
vip_name, vip_role, vip_avatar, vip_bio, vip_project,
|
||||
mbti, region, industry, position, business_scale, skills,
|
||||
story_best_month, story_achievement, story_turning,
|
||||
help_offer, help_need, project_intro,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
'soulvip_linpengcheng_04',
|
||||
'林鹏程',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=linpengcheng&size=256',
|
||||
'SOULLPC01',
|
||||
'vip_showcase',
|
||||
1,
|
||||
'2035-12-31 23:59:59',
|
||||
NOW(),
|
||||
14,
|
||||
'林鹏程',
|
||||
'金蝶生态 · 企业数字化',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=linpengcheng&size=256',
|
||||
'金蝶体系一线负责人:销售、实施、运维与续费,服务制造与政企等传统客户的 ERP 与数字化。',
|
||||
'金蝶项目获客与交付;招投标与大客户运维。超级个体课题:可复制「获客—交付—续费」打法。',
|
||||
NULL,
|
||||
'福建厦门',
|
||||
'企业服务 / 金蝶与 ERP',
|
||||
'金蝶业务负责人 · 数字化交付',
|
||||
'深耕金蝶系 ToB:线索、招投标、实施交付到运维续费;与团队资产侧(郑清土)分工,各管一摊。',
|
||||
'金蝶销售与大客户拓展、项目交付与团队管理、制造业数字化方案、招投标与回款节奏',
|
||||
'大单回款与多个项目同时验收的月份——交付线吃紧但现金流最厚,是金蝶 ToB 的常态高峰。',
|
||||
'从单点销售到能带实施团队做交付与续费,在厦门制造与政企圈里把金蝶这条线跑成稳定基本盘。',
|
||||
'认清自己适合扛「重交付、重文档、重回款」的企业服务,与只做资产底盘的合伙人并排协作。',
|
||||
'金蝶/ERP 选型与报价路径、招投标与实施排期、大客户运维与续费、制造现场数字化落地方案梳理。',
|
||||
'有预算的制造与政企数字化项目;靠谱售前与交付搭档;资金与验收节奏清晰的甲方。',
|
||||
'【我是谁】\n林鹏程,金蝶生态与企业数字化这条线的主责:制造业、政企等客户的 ERP、财务供应链与现场数字化,从线索、招投标到实施、运维与续费。\n\n【我的业务】\n不是郑清土那边的房产叙事——金蝶相关获客、方案、交付、回款,都以我这条业务线为准;与清土在团队里分工:他更重资产与配置,我更重 ToB 交付与续费。\n\n【超级个体课题】\n把「金蝶生态获客 → 项目交付 → 客户续费/增购」跑成可复制的项目制打法:同一套方法论套不同制造与政企客户,减少对人手感的依赖,把超级个体的个人产能放大成团队产能。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULLPC01',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
104
soul-api/scripts/insert_vip_xilanhua_liuguang_zhengqingtu.sql
Normal file
104
soul-api/scripts/insert_vip_xilanhua_liuguang_zhengqingtu.sql
Normal file
@@ -0,0 +1,104 @@
|
||||
-- 三位超级个体展示用户:西兰花(叶总)、流光(刘光)、郑清土
|
||||
-- 林鹏程(金蝶线)见 insert_vip_linpengcheng_kingdee.sql
|
||||
-- 资料口径来自 Soul 派对转写与卡若侧归档摘要;无 open_id,仅用于首页横滑与会员详情展示
|
||||
-- 头像为 DiceBear 占位图;若小程序不显示,请在后台配置 downloadFile 合法域名或换成本地上传图
|
||||
|
||||
INSERT INTO users (
|
||||
id, nickname, avatar, referral_code, source,
|
||||
is_vip, vip_expire_date, vip_activated_at, vip_sort,
|
||||
vip_name, vip_role, vip_avatar, vip_bio, vip_project,
|
||||
mbti, region, industry, position, business_scale, skills,
|
||||
story_best_month, story_achievement, story_turning,
|
||||
help_offer, help_need, project_intro,
|
||||
created_at, updated_at
|
||||
) VALUES
|
||||
(
|
||||
'soulvip_xilanhua_yezong01',
|
||||
'西兰花',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=xilanhuaYe&size=256',
|
||||
'SOULXLH01',
|
||||
'vip_showcase',
|
||||
1,
|
||||
'2035-12-31 23:59:59',
|
||||
NOW(),
|
||||
11,
|
||||
'西兰花',
|
||||
'金融业务负责人',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=xilanhuaYe&size=256',
|
||||
'群里的叶总:助贷与金融落地极强,团队里常对接渠道与放款合作。',
|
||||
'厦门助贷与金融业务;派对里阿满类合作可找叶总聊城市复制与分成机制。',
|
||||
NULL,
|
||||
'福建厦门',
|
||||
'助贷 / 金融服务',
|
||||
'叶总(业务对接)',
|
||||
'前端约百人跑金融业务;偏好线下见过、可控性高的产品与合作方。',
|
||||
'渠道搭建、团队动员、银行与资方对接、分润与裂变机制设计',
|
||||
'团队跑通规模化获客与投产,数据曾聊到比同城老牌助贷更高倍数。',
|
||||
'厦门国际银行等渠道放款成绩突出,被视作团队里金融板块核心人物之一。',
|
||||
'从单点成交到百人前端:更重渠道可持续与及时分润,避免断链。',
|
||||
'可聊助贷与城市复制、渠道怎么分钱才不断;具体额度与合规边界当面捋。',
|
||||
'要实际跑通过的产品与靠谱后端;不接受纯概念或无法落地的合作。',
|
||||
'【我是谁】\n昵称西兰花,群里大家称叶总。长期做助贷与金融公司落地,厦门资源深。\n\n【聊天里的画像】\n派对转写里提到:前端大量人力跑金融业务,对合作方与产品「要摸得着、控得住」;也参与供应链裂变场景,重视给渠道大头与及时返现。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULXLH01',
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'soulvip_liuguang_liuguang02',
|
||||
'流光',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=liuguangSoul&size=256',
|
||||
'SOULLG02',
|
||||
'vip_showcase',
|
||||
1,
|
||||
'2035-12-31 23:59:59',
|
||||
NOW(),
|
||||
12,
|
||||
'流光',
|
||||
'战略与人脉操盘手',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=liuguangSoul&size=256',
|
||||
'刘光(流光):识人用人、战略规划、人脉链接,偏幕后撮合与顶层设计。',
|
||||
'知己项目、MBTI 与人才体系、流量与私域项目中的链接与活动侧。',
|
||||
'ENFP',
|
||||
'福建厦门',
|
||||
'咨询 / 人才与组织发展',
|
||||
'联合创始人 / 战略顾问向',
|
||||
'多年流量与私域项目撮合经验;擅长把人与场攒在一起。',
|
||||
'识人用人体系、战略规划、线上线下活动与资源整合',
|
||||
'在流量合作与知己方向,把 MBTI 与人才视角带进团队决策。',
|
||||
'最看重「把对的人放在对的位置」,用活动与链接放大信任。',
|
||||
'意识到强链接之后要配强执行,主动找 ENTJ 型搭档补落地。',
|
||||
'可帮你做人才与合伙匹配、活动与资源引荐、项目叙事与节奏梳理。',
|
||||
'需要执行力强的合伙人或 PM 把方案落到日更、周更。',
|
||||
'【我是谁】\n流光,本名刘光。聊天纪要里:在迷茫期把 MBTI 与人才视角带进团队,强项是链接人与攒局。\n\n【聊天里的画像】\n相关人物「流光」目录多聊私域与流量合作;近期记录里和知己项目、神仙团队、企业数字中台等方向绑定。自我定位偏战略与人脉,执行需与强落地型搭档互补。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULLG02',
|
||||
NOW(),
|
||||
NOW()
|
||||
),
|
||||
(
|
||||
'soulvip_zhengqingtu_00003',
|
||||
'郑清土',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=zhengqingtu&size=256',
|
||||
'SOULZQT03',
|
||||
'vip_showcase',
|
||||
1,
|
||||
'2035-12-31 23:59:59',
|
||||
NOW(),
|
||||
13,
|
||||
'郑清土',
|
||||
'厦门房产 · 资产配置',
|
||||
'https://api.dicebear.com/7.x/notionists/png?seed=zhengqingtu&size=256',
|
||||
'五缘湾一带有家族房产底盘,以持有、长租与家族配置为主。金蝶与企业数字化由合伙人林鹏程主责,需要可引荐。',
|
||||
'厦门房产长租与持有;本地实业资源协作。金蝶/ERP 交付请对接林鹏程。',
|
||||
NULL,
|
||||
'福建厦门',
|
||||
'房产投资 / 资产管理',
|
||||
'创业者 · 房产与资源配置',
|
||||
'厦门核心板块(含五缘湾)多套自持与家族房产;团队内企业服务(金蝶)由林鹏程团队一线交付,本人侧重资产侧与协作。',
|
||||
'房产选址与持有、租金与空置管理、资产配置与家族协作、本地商务资源引荐',
|
||||
'租金季稳定入账,再叠一笔实业协作或分红回款——现金流不绑单一风口时最踏实。',
|
||||
'在厦门把房产层做成可复用的底盘;把重招投标、重交付的金蝶线交给更擅长的合伙人林鹏程去扛,分工清晰。',
|
||||
'从「什么都自己扛」到「资产守底盘、金蝶与服务找鹏程」——各展所长,减少角色错位。',
|
||||
'厦门房产持有与出租实操、长租租客筛选、本地资源撮合(金蝶类需求转介林鹏程)。',
|
||||
'优质长租租客与物业资源;实业合伙里偏资产与资本侧的靠谱窗口。',
|
||||
'【我是谁】\n郑清土,厦门本地人向,长期以房产与资产配置为底盘。\n\n【重要说明】\n「金蝶 / ERP / 企业数字化」这条业务线是合伙人林鹏程的主业与一线交付,不是本人亲自扛的赛道;有金蝶获客、实施、招投标、运维续费,请直接找小程序超级个体「林鹏程」或群内对接他。\n\n【房产】\n五缘湾等核心板块自持与家族持有,更看重地段与现金流;长租、持有、空置与维护可深聊。\n\n【协作】\n本地实业与资源对接可聊;涉及金蝶体系一律引荐林鹏程团队。\n\n【延伸】\n电竞、RWA、算力等仅作个人关注,不在此展开。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULZQT03',
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
17
soul-api/scripts/update_user_amiao_profile.sql
Normal file
17
soul-api/scripts/update_user_amiao_profile.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- 爱赛车的阿猫 · 资料完善(与小程序 profile-show / member-detail 字段对齐)
|
||||
UPDATE users SET
|
||||
region = '福建厦门 · 鼓浪屿(常住)',
|
||||
industry = '房产运营 · 投资管理',
|
||||
position = '包租婆 / 个人投资人',
|
||||
business_scale = '鼓浪屿多套房源自持与精细化运营,现金流稳定为先;同时少量参与早期项目与副业赛道。',
|
||||
skills = '长租与旅居式运营、租金定价与空置管理、轻量资产配置、早期项目看人看事、资源整合',
|
||||
story_best_month = '把车爱好和生活节奏理顺:一边打理岛上的房子,一边用投资视角挑案子;租金与分红叠在一起时最有体感——钱在干活,人还能留时间去赛道边上看机会。',
|
||||
story_achievement = '在鼓浪屿把工作与生活揉在一起:白天处理租客与维护,傍晚骑车吹风。最有成就感的是让房子持续产生现金,同时还能留时间给真正想跟的创业项目。',
|
||||
story_turning = '从「只收租」到「租+投」双轨:现金流打底,遇到靠谱的创始人与赛道再出手。转折点是承认自己更适合做场上补给,而不是天天追风口。',
|
||||
help_offer = '鼓浪屿租房/旅居踩坑指南;小户型民宿运营心得;早期商业计划书吐槽与撮合(不一定投,但愿意聊清楚)。',
|
||||
help_need = '优质内容团队与品牌操盘手;可信的私域/电商合作窗口;同频的投资与创业圈子引荐。',
|
||||
project_intro = '【我是谁】\n常住鼓浪屿的包租婆,也是闲不住的个人投资人。喜欢赛车与机械硬核感,做事习惯先算现金流、再谈情怀。\n\n【我在做什么】\n· 岛上自持房源长租与精细化运营,追求稳定出租率与口碑。\n· 少量天使/跟投,偏好有护城河、创始人靠谱的早期项目。\n\n【链接与入口】\n· 创业派对与同名内容:https://soul.quwanzhi.com/?ref=SOULMO8AHO\n· 合作或看房况:欢迎通过本小程序资料页已绑定的手机与我联系(可加微信后深聊)。\n\n【合作期待】\n希望遇到能把故事讲清楚、把账算明白的合伙人;闲聊勿扰,尊重彼此时间。',
|
||||
vip_bio = '鼓浪屿常住 · 包租婆 · 个人投资人 · 赛车爱好者。房产现金流打底,少量跟投早期项目;欢迎通过小程序联系。',
|
||||
vip_project = '鼓浪屿自持房产运营 · 早期项目投资跟投 · 小程序内可直达内容与推荐链接',
|
||||
updated_at = NOW()
|
||||
WHERE id = 'ogpTW5cVMxd5afBBtXdvmeMO8aho' AND deleted_at IS NULL;
|
||||
25
soul-api/scripts/update_user_chenzhou_renzituo_vip.sql
Normal file
25
soul-api/scripts/update_user_chenzhou_renzituo_vip.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- 陈周(人字拖):桂花糕 + 3D 打印文创 / 特产店 / 京东旗舰店 + 本地生活引流
|
||||
-- 用户 id:user_268099bf(原昵称 微信用户89VE,2026-03-18 注册,已是 VIP)
|
||||
-- 口径:Soul 妙记 20260320–20260321「用所选项目新建的文件夹」
|
||||
UPDATE users SET
|
||||
nickname = '陈周(人字拖)',
|
||||
avatar = 'https://api.dicebear.com/7.x/notionists/png?seed=chenzhouRenzituo&size=256',
|
||||
vip_name = '陈周(人字拖)',
|
||||
vip_role = '桂花糕 · 3D打印文创',
|
||||
vip_avatar = 'https://api.dicebear.com/7.x/notionists/png?seed=chenzhouRenzituo&size=256',
|
||||
vip_bio = '外号人字拖,好记。一条线是家里桂花糕与特产(京东旗舰店背书 + 本地生活引流);一条线是 3D 打印文创与定制,从一张图到可交付。',
|
||||
vip_project = '桂花糕/桂花酒等综合品类电商;3D 打印设备与文创定制、文旅伴手礼打样。',
|
||||
vip_sort = 20,
|
||||
region = '福建(特产与文旅向)',
|
||||
industry = '食品电商 / 3D打印文创',
|
||||
position = '创始人 · 桂花糕与3D定制',
|
||||
business_scale = '已跑京东旗舰店强背书;本地生活引流;店内综合品类(桂花糕引流 + 桂花酒等)。3D 线:文旅特产店场景 + 车模/旅游附件等个性定制,设备可做塑料壳类打样。',
|
||||
skills = '全平台内容引流、京东店运营、本地生活获客、3D打印文创与设计交付、客户需求一轮迭代打磨',
|
||||
story_best_month = '派对与视频号带来曝光时,桂花糕咨询和 3D 定制线索叠在一起——同一套「先讲清楚卖什么」的话术,两条线都能接。',
|
||||
story_achievement = '把「陈州/陈周 + 人字拖」做成好记的个人 IP;桂花糕生意垒到京东旗舰店 + 本地引流闭环;3D 打印链接工厂与文旅资源。',
|
||||
story_turning = '从早年卖手机的微信昵称记忆点,转到「人字拖」这种更好亲近的称呼;业务上从单一品类走到「特产 + 3D 定制」双引擎。',
|
||||
help_offer = '桂花糕与特产电商起盘、京东旗舰店与本地生活怎么配;3D 打印文创从需求描述到一版可改的设计交付流程。',
|
||||
help_need = '稳定供应链与包材;有预算的文旅/工厂 3D 打样与批量意向;同城内容与派对联动曝光。',
|
||||
project_intro = '【我是谁】\n陈周,大家叫我「人字拖」,名字好记、好亲近。派对妙记里自我介绍:陈州/陈周和人字拖是一组记忆点。\n\n【桂花糕与特产】\n这段时间也在帮家人把桂花糕这条线做大:已上京东旗舰店,用平台背书;本地生活做引流。店里做综合品类,以桂花糕带流量,延伸桂花酒等。妙记里也提到全平台、简单重复也能出结果,关键是方法要多、平台要铺开。\n\n【3D 打印】\n另一条线是 3D 打印:设备侧能做塑料壳等;文创侧帮客户把想法落成实物——文旅场景我们有特产店与定价锚点,定制线可接车模、旅游附件等。一张图 + 描述需求,通常一轮修改就能接近预期。也关注德化等地工厂把瓷器开模转向 3D 的趋势。\n\n【内容】\n本人也是运动向博主,账号曾因帮家人做桂花糕不够「垂直」,但仍在积累关注与推荐;主业不靠单条爆款,靠业务闭环。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULTG89VE',
|
||||
updated_at = NOW()
|
||||
WHERE id = 'user_268099bf' AND deleted_at IS NULL;
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 已 superseded:金蝶线划归林鹏程后请用 update_zhengqingtu_split_kingdee_to_linpengcheng.sql
|
||||
-- (历史)郑清土:强化「厦门房产底盘」与「金蝶系等传统 ToB 业务」表述
|
||||
UPDATE users SET
|
||||
vip_role = '厦门房产 · 传统ToB业务',
|
||||
vip_bio = '五缘湾一带有家族房产底盘;多年金蝶销售与实施服务,传统企业服务与大客户交付是主业现金流。',
|
||||
vip_project = '厦门房产配置与长租/持有策略;金蝶生态销售、实施与运维类传统 ToB 项目;新经济方向仅作延伸关注。',
|
||||
industry = '房产持有 / 企业服务(ToB)',
|
||||
position = '创业者 · 房产与传统业务负责人',
|
||||
business_scale = '厦门核心板块(含五缘湾)多套自持与家族房产;传统业务侧长期服务制造业与政企类金蝶项目,现金流稳、决策偏谨慎。',
|
||||
skills = '房产选址与持有、租金与资产配置、金蝶系销售与大客户拓展、项目交付与团队管理、传统 IT 服务商务谈判',
|
||||
story_best_month = '传统业务大单回款与租金季叠在一起的那几个月——一边是 ToB 交付收尾,一边是房租按时入账,现金流最踏实。',
|
||||
story_achievement = '把金蝶这条线从销售做到能带团队做实施与续费,同时在厦门把房产这一层慢慢垒成「睡后收入」底盘,不靠风口讲故事。',
|
||||
story_turning = '早年写代码后来转销售与管理,再往后明白:传统业务要深、房产要早布局;新业务可以试,但不能动底盘。',
|
||||
help_offer = '厦门房产持有与出租实操、金蝶类传统 ToB 获客与交付经验、大客户招投标与回款节奏。',
|
||||
help_need = '优质长租租客与物业资源;传统制造业/政企数字化预算内的靠谱项目线索。',
|
||||
project_intro = '【我是谁】\n郑清土,厦门本地人向的创业者。底盘两块:一是家里在五缘湾等核心板块有房产配置与持有;二是自己多年扎在金蝶体系的销售、实施与服务,做的是制造业、政企等传统客户的数字化与 ERP 类项目。\n\n【房产】\n更看重「地段 + 现金流」而不是短期炒作:长租、持有、家族资产配置一起讨论都可以。厦门岛内外的租金逻辑、空置与维护,更愿意用经验换时间。\n\n【传统业务】\n金蝶系 ToB 是老本行:从线索、招投标到交付、运维与续费,习惯按项目制把团队搭起来。偏保守,不接看不懂预算来源的单。\n\n【延伸】\n电竞、RWA、算力等新赛道有关注,但公开资料里不作为主业描述;合作优先聊房产与传统 ToB。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULZQT03',
|
||||
updated_at = NOW()
|
||||
WHERE id = 'soulvip_zhengqingtu_00003' AND deleted_at IS NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 郑清土:金蝶/ERP/企业数字化为林鹏程主业,郑清土侧重房产与资产配置;二者分工写清楚
|
||||
UPDATE users SET
|
||||
vip_role = '厦门房产 · 资产配置',
|
||||
vip_bio = '五缘湾一带有家族房产底盘,以持有、长租与家族配置为主。金蝶与企业数字化由合伙人林鹏程主责,需要可引荐。',
|
||||
vip_project = '厦门房产长租与持有;本地实业资源协作。金蝶/ERP 交付请对接林鹏程。',
|
||||
industry = '房产投资 / 资产管理',
|
||||
position = '创业者 · 房产与资源配置',
|
||||
business_scale = '厦门核心板块(含五缘湾)多套自持与家族房产;团队内企业服务(金蝶)由林鹏程团队一线交付,本人侧重资产侧与协作。',
|
||||
skills = '房产选址与持有、租金与空置管理、资产配置与家族协作、本地商务资源引荐',
|
||||
story_best_month = '租金季稳定入账,再叠一笔实业协作或分红回款——现金流不绑单一风口时最踏实。',
|
||||
story_achievement = '在厦门把房产层做成可复用的底盘;把重招投标、重交付的金蝶线交给更擅长的合伙人林鹏程去扛,分工清晰。',
|
||||
story_turning = '从「什么都自己扛」到「资产守底盘、金蝶与服务找鹏程」——各展所长,减少角色错位。',
|
||||
help_offer = '厦门房产持有与出租实操、长租租客筛选、本地资源撮合(金蝶类需求转介林鹏程)。',
|
||||
help_need = '优质长租租客与物业资源;实业合伙里偏资产与资本侧的靠谱窗口。',
|
||||
project_intro = '【我是谁】\n郑清土,厦门本地人向,长期以房产与资产配置为底盘。\n\n【重要说明】\n「金蝶 / ERP / 企业数字化」这条业务线是合伙人林鹏程的主业与一线交付,不是本人亲自扛的赛道;有金蝶获客、实施、招投标、运维续费,请直接找小程序超级个体「林鹏程」或群内对接他。\n\n【房产】\n五缘湾等核心板块自持与家族持有,更看重地段与现金流;长租、持有、空置与维护可深聊。\n\n【协作】\n本地实业与资源对接可聊;涉及金蝶体系一律引荐林鹏程团队。\n\n【延伸】\n电竞、RWA、算力等仅作个人关注,不在此展开。\n\n【链接】\nhttps://soul.quwanzhi.com/?ref=SOULZQT03',
|
||||
updated_at = NOW()
|
||||
WHERE id = 'soulvip_zhengqingtu_00003' AND deleted_at IS NULL;
|
||||
Reference in New Issue
Block a user