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:
卡若
2026-03-23 18:38:23 +08:00
parent cb6e2bff56
commit fa3da12b16
82 changed files with 5621 additions and 2723 deletions

View 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** 推送配置统一指向本开发群,避免复盘散落多个群。

View File

@@ -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 自动化登录后提取

View File

@@ -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"
---
# 多平台分发 Skillv4.0
# 多平台分发 Skillv4.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 条立即;间隔与总跨度随条数自适应;本地 07 点尽量挪到午间(`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
View File

@@ -46,3 +46,6 @@ soul-api/soul-api-new
# Cursor 索引减负db-exec 依赖(仓库根已有 node_modules/ 规则,此处显式强调子路径)
.cursor/scripts/db-exec/node_modules/
# 本地技能包临时打包目录
.tmp_skill_bundle/

View File

@@ -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 === falsesoul-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

View File

@@ -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",

View File

@@ -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">

View File

@@ -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; }

View File

@@ -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()

View File

@@ -61,7 +61,7 @@
</view>
</view>
<!-- 联系方式 - 引导到Soul派对房 -->
<!-- 联系方式 - Soul 派对房 -->
<view class="contact-card" wx:if="{{!authorLoading}}">
<text class="card-title">联系作者</text>
<view class="contact-item">

View File

@@ -1,6 +1,6 @@
/**
* 卡若创业派对 - 头像昵称引导
* 登录后资料未完善时引导用户修改默认头像昵称,仅包含头像+昵称两项
* 卡若创业派对 - 头像昵称设置
* 登录后若仍为默认头像/昵称,在此修改;仅头像昵称两项
*/
const app = getApp()

View File

@@ -1,4 +1,4 @@
{
"navigationBarTitleText": "完善资料",
"navigationBarTitleText": "头像与昵称",
"usingComponents": {}
}

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
/* 卡若创业派对 - 头像昵称引导页 */
/* 卡若创业派对 - 头像昵称设置页 */
.page {
background: #050B14;
min-height: 100vh;

View File

@@ -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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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()
}
},

View File

@@ -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>

View File

@@ -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)

View File

@@ -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
}
})

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 || '')
}
}
/** 本地已打开的章节 idreading_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-detailmpUi.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 }

View File

@@ -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

View File

@@ -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 {

View File

@@ -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' })

View File

@@ -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>

View File

@@ -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%;
}

View File

@@ -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),

View File

@@ -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()

View File

@@ -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>

View File

@@ -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); }

View File

@@ -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(/&nbsp;/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()

View File

@@ -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>

View File

@@ -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); }
}

View 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()
}
})

View File

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

View 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>

View 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; }

View File

@@ -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: '请先绑定微信号',

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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; }

View 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 }

View 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 }

View File

@@ -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) {

View File

@@ -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 }

View File

@@ -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,

View File

@@ -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 盘或加密渠道传输。

View File

@@ -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

View File

@@ -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"

View 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()

View File

@@ -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

File diff suppressed because one or more lines are too long

965
soul-admin/dist/assets/index-CW7Mmh6Q.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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"> mpUiJSON</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>

View File

@@ -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>

View File

@@ -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
}

View 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 头像映射已保存"})
}

View File

@@ -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

View File

@@ -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补全昵称、targetUserIdPerson.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)
}

View File

@@ -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":

View File

@@ -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,
},
})
}

View File

@@ -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 解析 completedAtRFC3339 字符串、毫秒/秒时间戳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 分钟

View File

@@ -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]
}

View File

@@ -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"`
}

View File

@@ -9,14 +9,14 @@ import (
// User 对应表 usersJSON 输出与现网接口 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:"-"`

View File

@@ -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"`

View 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" }

View File

@@ -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)

View File

@@ -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 重启")

View 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()
);

View 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()
);

View 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;

View File

@@ -0,0 +1,25 @@
-- 陈周(人字拖):桂花糕 + 3D 打印文创 / 特产店 / 京东旗舰店 + 本地生活引流
-- 用户 iduser_268099bf原昵称 微信用户89VE2026-03-18 注册,已是 VIP
-- 口径Soul 妙记 2026032020260321「用所选项目新建的文件夹」
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;

View File

@@ -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;

View File

@@ -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;