diff --git a/.cursor/docs/feishu_开发群与项目复盘.md b/.cursor/docs/feishu_开发群与项目复盘.md new file mode 100644 index 00000000..e572a2de --- /dev/null +++ b/.cursor/docs/feishu_开发群与项目复盘.md @@ -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** 推送配置统一指向本开发群,避免复盘散落多个群。 diff --git a/.cursor/skills/karuo-party/SKILL.md b/.cursor/skills/karuo-party/SKILL.md index acf194c6..83681e0b 100644 --- a/.cursor/skills/karuo-party/SKILL.md +++ b/.cursor/skills/karuo-party/SKILL.md @@ -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 自动化登录后提取 diff --git a/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md b/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md index e0c023df..7717b056 100644 --- a/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md +++ b/.cursor/skills/karuo-party/skills/多平台分发_SKILL.md @@ -3,18 +3,25 @@ name: 多平台分发 description: > 一键将视频分发到 5 个平台(抖音、B站、视频号、小红书、快手)。 API 优先策略:视频号纯 API、B站 bilibili-api-python、抖音纯 API。 - 支持定时排期(第1条立即发,后续 30-120 分钟随机间隔)、并行分发、去重、失败自动重试。 + 支持定时排期(默认智能错峰;可选 legacy)、默认静默不弹窗登录、并行分发、去重、失败自动重试。 triggers: 多平台分发、一键分发、全平台发布、批量分发、视频分发 owner: 木叶 group: 木 -version: "4.0" -updated: "2026-03-11" +version: "4.3" +updated: "2026-03-23" --- -# 多平台分发 Skill(v4.0) +# 多平台分发 Skill(v4.3) > **核心原则**:API 发布为主,Playwright 为辅。确保确定性地分发到各平台。 -> **v4.0 变更**:视频号已切换为纯 API、统一元数据生成器、定时排期优化、简介/标签/分区自动填充。 +> **v4.3**:默认**静默**(不自动 `channels_login`);需弹窗时 `--auto-channels-login` 或 `CHANNELS_AUTO_LOGIN=1`(独立脚本)。**v4.2**:智能排期与去重下标对齐。 + +## 〇、执行原则(第一性原理) + +- **视频号两步**:先扫码落盘 Cookie 再上传;`video_channels_resume.py` **默认弹 Chromium 窗**扫码,扫完再继续传;`--silent-login` 无头。 +- **目标优先**:全平台分发 → 直接 `distribute_all.py`;视频号助手态需**事先**手动 `channels_login.py` 或显式 `--auto-channels-login`。 +- **Cookie 优先**:登录成功必须落盘;视频号双路径同步见 `cookie_manager.sync_channels_cookie_files`。 +- **默认静默**:无人值守跑命令时不弹浏览器。 --- @@ -32,6 +39,8 @@ updated: "2026-03-11" > 按《视频号与腾讯相关 API 整理》结论,微信官方目前**没有开放「短视频上传/发布」接口**;本 Skill 中的视频号发布能力,属于对 `https://channels.weixin.qq.com` 视频号助手网页协议的逆向封装(DFS 上传 + `post_create`),仅在你本机使用,需自行承担协议变更与合规风险。 > 官方可控能力(直播记录、橱窗、留资、罗盘数据、本地生活等)的服务端 API 入口为:`https://developers.weixin.qq.com/doc/channels/api/`,如需做直播/橱窗/留资集成,可基于该文档在单独 Skill 中扩展。 +> **「视频号 API token」与成片上传**:公众号 **`access_token`** **不能**替代视频号助手网页态;`channels_api_publish` 依赖 **`channels_storage_state.json`**。127 场静默全平台:`python3 distribute_all.py --video-dir "/Users/karuo/Movies/soul视频/第127场_20260318_output/成片"`(须各平台 Cookie 已就绪)。 + --- ## 二、一键命令 @@ -39,9 +48,12 @@ updated: "2026-03-11" ```bash cd /Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/多平台分发/脚本 -# 定时排期:第1条立即,后续 30-120min 随机间隔 +# 默认智能错峰排期 python3 distribute_all.py +# 旧版随机间隔 +python3 distribute_all.py --legacy-schedule + # 立即全部发布 python3 distribute_all.py --now @@ -54,24 +66,30 @@ python3 distribute_all.py --video-dir "/path/to/videos/" # 检查 Cookie / 重试失败 python3 distribute_all.py --check python3 distribute_all.py --retry + +# 需要脚本自动弹窗扫视频号(默认不弹) +python3 distribute_all.py --platforms 视频号 --auto-channels-login --video-dir "/path/to/成片" +# 独立 channels_api_publish 允许自动登录:CHANNELS_AUTO_LOGIN=1 +# 强制永不自动登录:NO_AUTO_CHANNELS_LOGIN=1 ``` --- -## 三、定时排期(v4.0 优化) +## 三、定时排期(v4.2+) -### 3.1 排期规则 -- **第 1 条**:立即发布(`first_delay=0`) -- **第 2 条起**:前一条 + random(30, 120) 分钟 -- 若总跨度 > 24h,自动按比例压缩 -- 12 条视频典型跨度 ~10-14h +### 3.1 默认(`generate_smart_schedule`) +- 第 1 条立即;间隔与总跨度随条数自适应;本地 0–7 点尽量挪到午间(`SCHEDULE_NO_NIGHT_REFINE=1` 关闭) +- `--legacy-schedule` + `--min-gap` / `--max-gap` / `--max-hours` 为旧逻辑 +- 去重时排期与目录列表下标对齐 -### 3.2 各平台定时实现 +### 3.2 独立 `channels_api_publish.py`:同上智能排期转 Unix + +### 3.3 各平台定时实现 | 平台 | 定时方式 | 参数 | |------|----------|------| | B站 | API `meta.dtime` | Unix 时间戳(秒) | -| 视频号 | API 暂不支持原生定时 | 描述中标注时间/手动设置 | +| 视频号 | API `postTimingInfo.postTime`(秒级 Unix);首条若时间过近则立即发 | `channels_api_publish._scheduled_ts_for_channels` | | 抖音 | API `timing_ts` | Unix 时间戳 | | 快手 | Playwright UI | `schedule_helper.py` | | 小红书 | Playwright UI | `schedule_helper.py` | @@ -91,12 +109,12 @@ meta.description("B站") # 标题 + 标签 + 品牌标记 meta.tags_str("B站") # AI工具,效率提升,Soul派对,... meta.bilibili_meta() # B站投稿完整 meta(含 tid/tag/desc) meta.title_short() # 小红书短标题(≤20字) -meta.hashtags("视频号") # #AI工具 #效率提升 ... #小程序 卡若创业派对 +meta.hashtags("视频号") # … + #小程序卡若创业派对 #公众号卡若-4点起床的男人 ``` ### 4.1 内容结构 - **标题**:手工优化标题库优先,否则从文件名智能提取 -- **简介**:标题 + 换行 + 话题标签 + `#小程序 卡若创业派对` +- **简介**:标题 + 换行 + 话题标签;**视频号**固定追加 `#小程序卡若创业派对` `#公众号卡若-4点起床的男人`(其它平台仍为 `#小程序 卡若创业派对`) - **标签**:基于关键词匹配(AI/创业/副业/Soul 等 12 类)+ 通用标签 - **分区**:B站 tid=160(生活>日常) - **风控过滤**:`content_filter.py` 自动替换敏感词(70+ 映射,严格/宽松分级) @@ -122,8 +140,11 @@ meta.hashtags("视频号") # #AI工具 #效率提升 ... #小程序 卡若创 `cookie_manager.py` 统一管理: - 中央存储:`多平台分发/cookies/{平台}_cookies.json` - 自动迁移:旧路径 → 中央存储(首次使用时) -- API 预检:5 平台各自 auth API 校验有效性 -- 防重复登录:有效 Cookie 不触发重新获取 +- **视频号双路径**:预检读中央、发布读 legacy;`sync_channels_cookie_files()` 按 **mtime 新者覆盖旧者**,避免两份不一致 +- **登录后必存**:`channels_login.py` 保存 `channels_storage_state.json` 后 **立即 copy** 到 `cookies/视频号_cookies.json` +- **登录页只在 Cursor 内**:`channels_login.py` v7 用 `cursor://vscode.simple-browser/show?url=…` 打开 **Simple Browser**,不用系统默认浏览器;**无额外 Chromium** 时需 Cursor 带 `--remote-debugging-port=9223`(或 `CHANNELS_CDP_URL`),脚本 CDP 附着后导出会话;否则 **自动回退** 本机 Chromium 仅用于写 Cookie(`--playwright-only` 强制只走 Chromium)。 +- **视频号登录**:默认不自动执行 `channels_login.py`;需要时加 `--auto-channels-login`(或事先手动登录) +- API 预检:各平台 auth API 校验有效性 --- diff --git a/.gitignore b/.gitignore index 63783f2e..6d17c5ed 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ soul-api/soul-api-new # Cursor 索引减负:db-exec 依赖(仓库根已有 node_modules/ 规则,此处显式强调子路径) .cursor/scripts/db-exec/node_modules/ + +# 本地技能包临时打包目录 +.tmp_skill_bundle/ diff --git a/miniprogram/app.js b/miniprogram/app.js index 05d644c9..6bc0d36a 100644 --- a/miniprogram/app.js +++ b/miniprogram/app.js @@ -9,6 +9,8 @@ const { checkAndExecute } = require('./utils/ruleEngine.js') const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa' const DEFAULT_MCH_ID = '1318592501' const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE' +const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com' +const CONFIG_CACHE_KEY = 'mpConfigCacheV1' // 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version(正式版),否则用本字段 const APP_DISPLAY_VERSION = '1.7.1' @@ -101,25 +103,26 @@ App({ /** 正式版强制生产 API,避免误传 localhost 导致审核/线上全挂 */ initApiBaseUrl() { - const PRODUCTION = 'https://soulapi.quwanzhi.com' const KEY = 'apiBaseUrl' try { const info = wx.getAccountInfoSync?.() const env = info?.miniProgram?.envVersion || 'release' if (env === 'release') { - this.globalData.baseUrl = PRODUCTION + this.globalData.baseUrl = PRODUCTION_BASE_URL try { const saved = wx.getStorageSync(KEY) - if (saved && saved !== PRODUCTION) wx.removeStorageSync(KEY) + if (saved && saved !== PRODUCTION_BASE_URL) wx.removeStorageSync(KEY) } catch (_) {} return } const saved = wx.getStorageSync(KEY) if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) { this.globalData.baseUrl = String(saved).replace(/\/$/, '') + } else { + this.globalData.baseUrl = PRODUCTION_BASE_URL } } catch (_) { - this.globalData.baseUrl = PRODUCTION + this.globalData.baseUrl = PRODUCTION_BASE_URL } }, @@ -393,8 +396,8 @@ App({ if (!this.globalData.isSinglePageMode) return true wx.showModal({ - title: '请前往完整小程序', - content: '当前为朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。', + title: '请打开完整小程序', + content: '当前是朋友圈预览,无法在这里登录或付款。请先点击屏幕底部「前往小程序」,进入完整版后再解锁本章。', showCancel: false, confirmText: '我知道了', }) @@ -410,7 +413,7 @@ App({ }, /** - * 头像/昵称未改则引导:老用户弹窗后跳 avatar-nickname;新用户由登录处强制 redirectTo + * 头像/昵称未改:老用户弹窗后跳 avatar-nickname;新用户由登录处强制 redirectTo * VIP 用户不在此处理,统一走 checkVipContactRequiredAndGuide 只跳 profile-edit,避免乱跳 */ checkAvatarNicknameAndGuide() { @@ -429,10 +432,10 @@ App({ if (lastDate === today) return wx.setStorageSync('lastAvatarGuideDate', today) wx.showModal({ - title: '完善个人资料', - content: '请设置头像和昵称,让其他创业者更好地认识你', - confirmText: '去完善', - cancelText: '稍后', + title: '设置头像与昵称', + content: '头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。', + confirmText: '去设置', + cancelText: '关闭', success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' }) } @@ -590,7 +593,7 @@ App({ /** * VIP 用户登录后检测:手机号/头像昵称等需完善时,统一只跳 profile-edit,避免与 avatar-nickname 乱跳。 - * 旧数据(VIP 但头像昵称未改):弹窗「为了更好服务,请完善资料」→ redirectTo profile-edit + * 旧数据(VIP 但头像昵称未改):说明原因后 redirectTo profile-edit */ async checkVipContactRequiredAndGuide() { if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return @@ -619,12 +622,12 @@ App({ const wechatId = (profileData.wechatId || profileData.wechat_id || this.globalData.userInfo?.wechatId || this.globalData.userInfo?.wechat_id || wx.getStorageSync('user_wechat') || '').trim() const needsAvatarNickname = this._needsAvatarNickname(profileData) - // VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit,弹窗「为了更好服务,请完善资料」 + // VIP 头像/昵称未改(含旧数据):统一只跳 profile-edit if (needsAvatarNickname) { wx.showModal({ - title: '完善资料', - content: '为了更好为您服务,请完善资料', - confirmText: '去完善', + title: '补全对外展示信息', + content: 'VIP 名片与派对场景会展示头像与昵称,补全后对方更容易认出你。', + confirmText: '去填写', showCancel: false, success: () => { wx.redirectTo({ url: '/pages/profile-edit/profile-edit' }) @@ -637,9 +640,9 @@ App({ // VIP 无手机号:弹窗说明后跳转 if (!phone) { wx.showModal({ - title: '完善资料', - content: 'VIP会员需完善手机号,以便使用找伙伴、提现等功能', - confirmText: '去完善', + title: '补全手机号', + content: '手机号用于找伙伴、提现验证与重要通知,仅本人可见。', + confirmText: '去填写', showCancel: false, success: () => { wx.redirectTo({ url: '/pages/profile-edit/profile-edit' }) @@ -647,12 +650,12 @@ App({ }) return } - // 有手机号但缺微信号:弹窗引导(非强制) + // 有手机号但缺微信号:可选补全(非强制) wx.showModal({ - title: '完善联系方式', - content: '请到资料页完善微信号,便于他人联系您', - confirmText: '去完善', - cancelText: '稍后', + title: '补全微信号(可选)', + content: '填写微信号后,对方在允许的场景下能更快加到你;不填也可继续使用。', + confirmText: '去填写', + cancelText: '关闭', success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) } @@ -691,6 +694,17 @@ App({ if (!forceRefresh && this.globalData.configCache && now < this.globalData.configCacheExpires) { return this.globalData.configCache } + if (!forceRefresh && !this.globalData.configCache) { + try { + const local = wx.getStorageSync(CONFIG_CACHE_KEY) + const exp = Number(local && local.expiresAt) + if (local && local.data && exp > now) { + this.globalData.configCache = local.data + this.globalData.configCacheExpires = exp + return local.data + } + } catch (_) {} + } try { const [coreRes, auditRes] = await Promise.all([ this.request({ url: '/api/miniprogram/config/core', silent: true, timeout: 5000 }), @@ -709,11 +723,45 @@ App({ } this.globalData.configCache = res this.globalData.configCacheExpires = now + CACHE_TTL + try { + wx.setStorageSync(CONFIG_CACHE_KEY, { + data: res, + expiresAt: this.globalData.configCacheExpires + }) + } catch (_) {} return res } - } catch (e) { - if (this.globalData.configCache) return this.globalData.configCache - } + } catch (_) {} + + try { + const legacy = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 }) + if (legacy) { + const cfg = (legacy.configs && legacy.configs.mp_config) || legacy.mpConfig || {} + const res = { + success: legacy.success !== false, + prices: legacy.prices || (legacy.configs && legacy.configs.chapter_config && legacy.configs.chapter_config.prices) || {}, + features: legacy.features || (legacy.configs && legacy.configs.feature_config) || {}, + userDiscount: legacy.userDiscount, + mpConfig: cfg, + configs: legacy.configs || {} + } + this.globalData.configCache = res + this.globalData.configCacheExpires = now + CACHE_TTL + try { + wx.setStorageSync(CONFIG_CACHE_KEY, { + data: res, + expiresAt: this.globalData.configCacheExpires + }) + } catch (_) {} + return res + } + } catch (_) {} + + if (this.globalData.configCache) return this.globalData.configCache + try { + const local = wx.getStorageSync(CONFIG_CACHE_KEY) + if (local && local.data) return local.data + } catch (_) {} return null }, @@ -851,6 +899,79 @@ App({ return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg }, + _shouldFallbackToProduction(err) { + const msg = String((err && err.errMsg) || (err && err.message) || '').toLowerCase() + return ( + msg.includes('connection_failed') || + msg.includes('err_proxy_connection_failed') || + msg.includes('dns') || + msg.includes('name not resolved') || + msg.includes('failed to fetch') || + msg.includes('econnrefused') || + msg.includes('network') || + msg.includes('timeout') + ) + }, + + _switchBaseUrlToProduction() { + this.globalData.baseUrl = PRODUCTION_BASE_URL + try { + wx.setStorageSync('apiBaseUrl', PRODUCTION_BASE_URL) + } catch (_) {} + }, + + _requestOnce(url, options = {}, silent = false) { + const showError = (msg) => { + if (!silent && msg) wx.showToast({ title: msg, icon: 'none', duration: 2500 }) + } + return new Promise((resolve, reject) => { + const token = wx.getStorageSync('token') + wx.request({ + url: this.globalData.baseUrl + url, + method: options.method || 'GET', + data: options.data || {}, + timeout: options.timeout || 15000, + header: { + 'Content-Type': 'application/json', + 'Authorization': token ? `Bearer ${token}` : '', + ...options.header + }, + success: (res) => { + const data = res.data + if (res.statusCode === 200) { + if (data && data.success === false) { + const msg = this._getApiErrorMsg(data, '操作失败') + if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) { + this.logout() + } + showError(msg) + reject(new Error(msg)) + return + } + resolve(data) + return + } + if (res.statusCode === 401) { + this.logout() + showError('未授权,请重新登录') + reject(new Error('未授权')) + return + } + const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败') + showError(msg) + reject(new Error(msg)) + }, + fail: (err) => { + const msg = (err && err.errMsg) + ? (String(err.errMsg).indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') + : '网络异常,请重试' + showError(msg) + reject(err || new Error(msg)) + } + }) + }) + }, + /** * 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。 * GET 请求 200ms 内相同 url 去重,避免并发重复请求。 @@ -870,68 +991,21 @@ App({ } const method = (options.method || 'GET').toUpperCase() const silent = !!options.silent - const showError = (msg) => { - if (!silent && msg) { - wx.showToast({ title: msg, icon: 'none', duration: 2500 }) - } - } - // GET 短时去重:相同 url 的并发请求共享同一 promise if (method === 'GET') { const dedupKey = url + (options.data ? JSON.stringify(options.data) : '') const pending = this._requestPending || (this._requestPending = {}) - if (pending[dedupKey]) { - return pending[dedupKey].promise - } + if (pending[dedupKey]) return pending[dedupKey].promise } - const promise = new Promise((resolve, reject) => { - const token = wx.getStorageSync('token') - wx.request({ - url: this.globalData.baseUrl + url, - method: options.method || 'GET', - data: options.data || {}, - timeout: options.timeout || 15000, - header: { - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - ...options.header - }, - success: (res) => { - const data = res.data - if (res.statusCode === 200) { - // 业务失败:success === false,soul-api 用 message 或 error 返回原因 - if (data && data.success === false) { - const msg = this._getApiErrorMsg(data, '操作失败') - // 登录态不一致:本地有 token/userInfo,但后端查不到该用户 - // 典型原因:切换环境(baseUrl)、换库/清库、用户被删除、token 与用户不匹配 - if (msg && (msg.includes('用户不存在') || msg.toLowerCase().includes('user not found'))) { - this.logout() - } - showError(msg) - reject(new Error(msg)) - return - } - resolve(data) - return - } - if (res.statusCode === 401) { - this.logout() - showError('未授权,请重新登录') - reject(new Error('未授权')) - return - } - // 4xx/5xx:优先用返回体的 message/error - const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败') - showError(msg) - reject(new Error(msg)) - }, - fail: (err) => { - const msg = (err && err.errMsg) ? (err.errMsg.indexOf('timeout') !== -1 ? '请求超时,请重试' : '网络异常,请重试') : '网络异常,请重试' - showError(msg) - reject(new Error(msg)) - } - }) + const promise = this._requestOnce(url, options, silent).catch(async (err) => { + const currentBase = String(this.globalData.baseUrl || '').replace(/\/$/, '') + if (currentBase !== PRODUCTION_BASE_URL && this._shouldFallbackToProduction(err)) { + this._switchBaseUrlToProduction() + return this._requestOnce(url, options, silent) + } + const msg = (err && err.message) ? err.message : '网络异常,请重试' + throw new Error(msg) }) if (method === 'GET') { @@ -1169,6 +1243,22 @@ App({ wx.setStorageSync('readSectionIds', list) }, + /** + * 记录「最近打开过」某章节(含仅预览/未登录),供「我的」最近阅读展示; + * 不写入 readSectionIds,避免把「点开预览」算进已读章节数。 + */ + touchRecentSection(sectionId) { + if (!sectionId) return + try { + let list = wx.getStorageSync('recent_section_opens') + if (!Array.isArray(list)) list = [] + const now = Date.now() + list = list.filter((x) => x && x.id !== sectionId) + list.unshift({ id: String(sectionId), t: now }) + wx.setStorageSync('recent_section_opens', list.slice(0, 40)) + } catch (_) {} + }, + // 已读章节数(用于首页展示) getReadCount() { return (this.globalData.readSectionIds || []).length diff --git a/miniprogram/app.json b/miniprogram/app.json index feeafc49..8dcb6322 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -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", diff --git a/miniprogram/components/login-modal/login-modal.wxml b/miniprogram/components/login-modal/login-modal.wxml index b07bc3c7..f821e40e 100644 --- a/miniprogram/components/login-modal/login-modal.wxml +++ b/miniprogram/components/login-modal/login-modal.wxml @@ -6,14 +6,14 @@ 登录 卡若创业派对 {{desc}} - 为获取手机号,请先同意《用户隐私保护指引》 - + 取消 diff --git a/miniprogram/components/login-modal/login-modal.wxss b/miniprogram/components/login-modal/login-modal.wxss index 116bef83..7084b74b 100644 --- a/miniprogram/components/login-modal/login-modal.wxss +++ b/miniprogram/components/login-modal/login-modal.wxss @@ -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; } diff --git a/miniprogram/custom-tab-bar/index.js b/miniprogram/custom-tab-bar/index.js index 27268ea8..6b27182d 100644 --- a/miniprogram/custom-tab-bar/index.js +++ b/miniprogram/custom-tab-bar/index.js @@ -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() diff --git a/miniprogram/pages/about/about.wxml b/miniprogram/pages/about/about.wxml index 2987434e..aaee8f00 100644 --- a/miniprogram/pages/about/about.wxml +++ b/miniprogram/pages/about/about.wxml @@ -61,7 +61,7 @@ - + 联系作者 diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.js b/miniprogram/pages/avatar-nickname/avatar-nickname.js index 1de09a3b..7390cc52 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.js +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.js @@ -1,6 +1,6 @@ /** - * 卡若创业派对 - 头像昵称引导页 - * 登录后资料未完善时引导用户修改默认头像和昵称,仅包含头像+昵称两项 + * 卡若创业派对 - 头像与昵称设置页 + * 登录后若仍为默认头像/昵称,在此修改;仅头像与昵称两项 */ const app = getApp() diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.json b/miniprogram/pages/avatar-nickname/avatar-nickname.json index 4d1ce1a1..c9671edb 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.json +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.json @@ -1,4 +1,4 @@ { - "navigationBarTitleText": "完善资料", + "navigationBarTitleText": "头像与昵称", "usingComponents": {} } diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.wxml b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml index e844082b..c3bbdf27 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.wxml +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.wxml @@ -1,18 +1,17 @@ - + - 完善资料 + 头像与昵称 - - 完善头像和昵称 - 让他人更好地认识你,展示更专业的形象 + 设置对外展示信息 + 头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。 @@ -62,7 +61,7 @@ - 完善更多资料 + 编辑完整档案 diff --git a/miniprogram/pages/avatar-nickname/avatar-nickname.wxss b/miniprogram/pages/avatar-nickname/avatar-nickname.wxss index 075774c2..c472ae32 100644 --- a/miniprogram/pages/avatar-nickname/avatar-nickname.wxss +++ b/miniprogram/pages/avatar-nickname/avatar-nickname.wxss @@ -1,4 +1,4 @@ -/* 卡若创业派对 - 头像昵称引导页 */ +/* 卡若创业派对 - 头像与昵称设置页 */ .page { background: #050B14; min-height: 100vh; diff --git a/miniprogram/pages/chapters/chapters.js b/miniprogram/pages/chapters/chapters.js index 67400f7b..a48bd084 100644 --- a/miniprogram/pages/chapters/chapters.js +++ b/miniprogram/pages/chapters/chapters.js @@ -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 diff --git a/miniprogram/pages/chapters/chapters.wxml b/miniprogram/pages/chapters/chapters.wxml index bd03e997..1cc3ff76 100644 --- a/miniprogram/pages/chapters/chapters.wxml +++ b/miniprogram/pages/chapters/chapters.wxml @@ -34,18 +34,21 @@ - - + + - 一场SOUL的创业实验场 - 来自Soul派对房的真实商业故事 + {{chaptersBookTitle}} + {{chaptersBookSubtitle}} - - {{totalSections}} - 章节 + + + {{totalSections}} + 章节 + + {{bookCollapsed ? '展开 ▸' : '折叠 ▾'}} @@ -65,11 +68,12 @@ - + - {{item.number}} + + {{item.title[0] || '篇'}} {{item.title}} {{item.subtitle}} diff --git a/miniprogram/pages/chapters/chapters.wxss b/miniprogram/pages/chapters/chapters.wxss index 48c3adc2..c2226657 100644 --- a/miniprogram/pages/chapters/chapters.wxss +++ b/miniprogram/pages/chapters/chapters.wxss @@ -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; diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js index 5f244bba..29078092 100644 --- a/miniprogram/pages/index/index.js +++ b/miniprogram/pages/index/index.js @@ -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() } }, diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml index b373cd0a..85868358 100644 --- a/miniprogram/pages/index/index.wxml +++ b/miniprogram/pages/index/index.wxml @@ -12,16 +12,11 @@ - 卡若创业派对 - 来自派对房的真实故事 - - - - - - 点击链接卡若 + {{mpUiLogoTitle}} + {{mpUiLogoSubtitle}} + @@ -29,7 +24,7 @@ - 搜索章节标题或内容... + {{mpUiSearchPlaceholder}} @@ -38,26 +33,26 @@ - 超级个体 - 获客入口 + {{mpUiSuperTitle}} + {{mpUiSuperLinkText}} @@ -100,7 +95,7 @@ - 精选推荐 + {{mpUiPickTitle}} - 最新新增 + {{mpUiLatestTitle}} @@ -165,25 +160,4 @@ - - - - - 留下联系方式 - 方便卡若与您联系 - - - 为获取手机号,请先同意《用户隐私保护指引》 - - - 或手动输入 - - - - - - - - - diff --git a/miniprogram/pages/match/match.js b/miniprogram/pages/match/match.js index 903c21d7..4ac3403c 100644 --- a/miniprogram/pages/match/match.js +++ b/miniprogram/pages/match/match.js @@ -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) diff --git a/miniprogram/pages/member-detail/member-detail.js b/miniprogram/pages/member-detail/member-detail.js index 3ab48b40..6051a6ff 100644 --- a/miniprogram/pages/member-detail/member-detail.js +++ b/miniprogram/pages/member-detail/member-detail.js @@ -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 } }) diff --git a/miniprogram/pages/member-detail/member-detail.wxml b/miniprogram/pages/member-detail/member-detail.wxml index c28ff406..2af31522 100644 --- a/miniprogram/pages/member-detail/member-detail.wxml +++ b/miniprogram/pages/member-detail/member-detail.wxml @@ -1,20 +1,29 @@ - + - 个人资料 - + {{isOwnProfile ? '我的名片' : '个人资料'}} + + 编辑 + + - + - + @@ -30,10 +39,39 @@ {{member.region}} + 点头像 · 申请对接 + + + + + + {{(member.name && member.name[0]) || '创'}} + + VIP + + {{member.name}} + + {{member.mbti}} + + + {{member.region}} + + + 这是我的超级个体名片 · 可转发分享 · 点头像去编辑 - + + 联系方式 - - - 暂未公开联系方式 - diff --git a/miniprogram/pages/member-detail/member-detail.wxss b/miniprogram/pages/member-detail/member-detail.wxss index 52ed1939..4b5d489c 100644 --- a/miniprogram/pages/member-detail/member-detail.wxss +++ b/miniprogram/pages/member-detail/member-detail.wxss @@ -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; diff --git a/miniprogram/pages/my/my.js b/miniprogram/pages/my/my.js index ab249dc9..ac109d75 100644 --- a/miniprogram/pages/my/my.js +++ b/miniprogram/pages/my/my.js @@ -8,21 +8,8 @@ const app = getApp() const { formatStatNum } = require('../../utils/util.js') const { trackClick } = require('../../utils/trackClick') const { cleanSingleLineField } = require('../../utils/contentParser.js') - -/** 是否视为「单章解锁」类订单(排除全书/VIP 等聚合商品名) */ -function isSectionUnlockOrder(o) { - const name = String(o.product_name || o.title || '').trim() - if (/全书|全書|VIP|会员|年费|买断/.test(name)) return false - const pid = String(o.product_id || o.section_id || o.sectionId || '') - if (/^\d+\.\d+/.test(pid)) return true - return !!pid && pid.length > 0 -} - -function parseOrderTimeMs(o) { - const raw = o.created_at || o.createdAt || o.pay_time || 0 - const t = new Date(raw).getTime() - return Number.isFinite(t) ? t : 0 -} +const { navigateMpPath } = require('../../utils/mpNavigate.js') +const { isSafeImageSrc } = require('../../utils/imageUrl.js') Page({ data: { @@ -97,10 +84,12 @@ Page({ // 我的余额 walletBalanceText: '--', - // 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开) - unlockedChaptersFull: [], - displayUnlockedChapters: [], - unlockedExpanded: false, + // mp_config.mpUi.myPage(后台可改文案/跳转) + mpUiCardLabel: '名片', + mpUiVipLabelVip: '会员中心', + mpUiVipLabelGuest: '成为会员', + mpUiReadStatLabel: '已读章节', + mpUiRecentTitle: '最近阅读', }, onLoad() { @@ -130,6 +119,27 @@ Page({ } } this.initUserStatus() + this._applyMyMpUiLabels() + }, + + _getMyPageUi() { + const cache = app.globalData.configCache || {} + const fromNew = cache?.mpConfig?.mpUi?.myPage + if (fromNew && typeof fromNew === 'object') return fromNew + const fromLegacy = cache?.configs?.mp_config?.mpUi?.myPage + if (fromLegacy && typeof fromLegacy === 'object') return fromLegacy + return {} + }, + + _applyMyMpUiLabels() { + const my = this._getMyPageUi() + this.setData({ + mpUiCardLabel: String(my.cardLabel || '名片').trim() || '名片', + mpUiVipLabelVip: String(my.vipLabelVip || '会员中心').trim() || '会员中心', + mpUiVipLabelGuest: String(my.vipLabelGuest || '成为会员').trim() || '成为会员', + mpUiReadStatLabel: String(my.readStatLabel || '已读章节').trim() || '已读章节', + mpUiRecentTitle: String(my.recentReadTitle || '最近阅读').trim() || '最近阅读' + }) }, async loadFeatureConfig() { @@ -144,9 +154,11 @@ Page({ app.globalData.auditMode = auditMode app.globalData.features = { matchEnabled, referralEnabled, searchEnabled } this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode }) + this._applyMyMpUiLabels() } catch (error) { console.log('加载功能配置失败:', error) this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true }) + this._applyMyMpUiLabels() } }, @@ -158,11 +170,17 @@ Page({ const userId = userInfo.id || '' const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || '' - + const safeUser = { ...userInfo } + if (!isSafeImageSrc(safeUser.avatar)) safeUser.avatar = '' + app.globalData.userInfo = safeUser + try { + wx.setStorageSync('userInfo', safeUser) + } catch (_) {} + // 先设基础信息;阅读统计与收益再分别从后端刷新 this.setData({ isLoggedIn: true, - userInfo, + userInfo: safeUser, userIdShort, userWechat, readCount: 0, @@ -182,9 +200,9 @@ Page({ this.loadPendingConfirm() this.loadVipStatus() this.loadWalletBalance() - this.loadUnlockedChapters() } else { const guestReadCount = app.getReadCount() + const guestRecent = this._mergeRecentChaptersFromLocal([]) this.setData({ isLoggedIn: false, userInfo: null, @@ -195,10 +213,7 @@ Page({ earnings: '-', pendingEarnings: '-', earningsLoading: false, - recentChapters: [], - unlockedChaptersFull: [], - displayUnlockedChapters: [], - unlockedExpanded: false, + recentChapters: guestRecent, totalReadTime: 0, matchHistory: 0, totalReadTimeText: '0', @@ -207,88 +222,83 @@ Page({ } }, - /** - * 已解锁章节:优先订单接口(按支付时间倒序);失败时用 purchasedSections + bookData 兜底 - */ - async loadUnlockedChapters() { - if (!app.globalData.isLoggedIn || !app.globalData.userInfo?.id) { - this.setData({ - unlockedChaptersFull: [], - displayUnlockedChapters: [], - unlockedExpanded: false - }) - return - } - const userId = app.globalData.userInfo.id - const expanded = this.data.unlockedExpanded - const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : [] - const metaById = (id) => { - const row = bookFlat.find((s) => s.id === id) - return { - mid: row?.mid ?? row?.MID ?? 0, - title: cleanSingleLineField(row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || '') - } - } + /** 本地已打开的章节 id(reading_progress 键 + 历史 readSectionIds),用于与服务端合并展示 */ + _localSectionIdsFromStorage() { try { - const res = await app.request({ url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`, silent: true }) - let rows = [] - if (res && res.success && Array.isArray(res.data)) { - rows = res.data - .map((item) => ({ - id: item.product_id || item.section_id, - mid: item.section_mid ?? item.mid ?? item.MID ?? 0, - title: cleanSingleLineField(item.product_name || ''), - _ts: parseOrderTimeMs(item) - })) - .filter((r) => r.id && isSectionUnlockOrder({ product_id: r.id, product_name: r.title })) - } - rows.sort((a, b) => b._ts - a._ts) - const seen = new Set() - const deduped = [] - for (const r of rows) { - if (seen.has(r.id)) continue - seen.add(r.id) - const meta = metaById(r.id) - deduped.push({ - id: r.id, - mid: r.mid || meta.mid, - title: cleanSingleLineField(r.title || meta.title || `章节 ${r.id}`) - }) - } - if (deduped.length === 0) { - const ids = [...(app.globalData.purchasedSections || [])] - ids.reverse() - for (const id of ids) { - if (seen.has(id)) continue - seen.add(id) - const meta = metaById(id) - deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) }) - } - } - const display = expanded ? deduped : deduped.slice(0, 5) - this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display }) - } catch (e) { - const ids = [...(app.globalData.purchasedSections || [])].reverse() - const seen = new Set() - const deduped = [] - for (const id of ids) { - if (!id || seen.has(id)) continue - seen.add(id) - const meta = metaById(id) - deduped.push({ id, mid: meta.mid, title: cleanSingleLineField(meta.title || `章节 ${id}`) }) - } - const display = expanded ? deduped : deduped.slice(0, 5) - this.setData({ unlockedChaptersFull: deduped, displayUnlockedChapters: display }) + const progressData = wx.getStorageSync('reading_progress') || {} + const fromProgress = Object.keys(progressData).filter(Boolean) + let fromReadList = [] + try { + const rs = wx.getStorageSync('readSectionIds') + if (Array.isArray(rs)) fromReadList = rs.filter(Boolean) + } catch (_) {} + return [...new Set([...fromProgress, ...fromReadList])] + } catch (_) { + return [] } }, - expandUnlockedChapters() { - if (this.data.unlockedExpanded) return - trackClick('my', 'tab_click', '已解锁章节_展开') - const full = this.data.unlockedChaptersFull || [] + /** 接口无最近阅读时,用本地 reading_progress + recent_section_opens + bookData 补全 */ + _mergeRecentChaptersFromLocal(apiList) { + const normalized = Array.isArray(apiList) + ? apiList.map((item) => ({ + id: item.id, + mid: item.mid, + title: cleanSingleLineField(item.title || '') || `章节 ${item.id}` + })) + : [] + if (normalized.length > 0) return normalized + try { + const progressData = wx.getStorageSync('reading_progress') || {} + let opens = wx.getStorageSync('recent_section_opens') + if (!Array.isArray(opens)) opens = [] + const bookFlat = Array.isArray(app.globalData.bookData) ? app.globalData.bookData : [] + const titleOf = (id) => { + const row = bookFlat.find((s) => s.id === id) + return cleanSingleLineField( + row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || '' + ) || `章节 ${id}` + } + const midOf = (id) => { + const row = bookFlat.find((s) => s.id === id) + return row?.mid ?? row?.MID ?? 0 + } + const latest = new Map() + const bump = (sid, ts) => { + if (!sid) return + const id = String(sid) + const t = typeof ts === 'number' && !isNaN(ts) ? ts : 0 + const prev = latest.get(id) || 0 + if (t >= prev) latest.set(id, t) + } + Object.keys(progressData).forEach((id) => { + const row = progressData[id] + bump(id, row?.lastOpenAt || row?.last_open_at || 0) + }) + opens.forEach((o) => bump(o && o.id, o && o.t)) + return [...latest.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([id]) => ({ id, mid: midOf(id), title: titleOf(id) })) + } catch (e) { + return [] + } + }, + + /** 接口失败或无 data 时,仅用本地 reading_progress / readSectionIds 刷新已读与最近 */ + _hydrateReadStatsFromLocal() { + const localExtra = this._localSectionIdsFromStorage() + const readSectionIds = [...new Set(localExtra.filter(Boolean))] + app.globalData.readSectionIds = readSectionIds + try { + wx.setStorageSync('readSectionIds', readSectionIds) + } catch (_) {} + const recentChapters = this._mergeRecentChaptersFromLocal([]) + const readCount = readSectionIds.length this.setData({ - unlockedExpanded: true, - displayUnlockedChapters: full + readCount, + readCountText: formatStatNum(readCount), + recentChapters }) }, @@ -302,21 +312,29 @@ Page({ silent: true }) - if (!res?.success || !res.data) return + if (!res?.success || !res.data) { + this._hydrateReadStatsFromLocal() + return + } + + const apiIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds.filter(Boolean) : [] + const localExtra = this._localSectionIdsFromStorage() + const prevGlobal = Array.isArray(app.globalData.readSectionIds) ? app.globalData.readSectionIds.filter(Boolean) : [] + const readSectionIds = [...new Set([...apiIds, ...prevGlobal, ...localExtra])] - const readSectionIds = Array.isArray(res.data.readSectionIds) ? res.data.readSectionIds : [] app.globalData.readSectionIds = readSectionIds wx.setStorageSync('readSectionIds', readSectionIds) - const recentChapters = Array.isArray(res.data.recentChapters) + const apiRecent = Array.isArray(res.data.recentChapters) ? res.data.recentChapters.map((item) => ({ id: item.id, mid: item.mid, title: item.title || `章节 ${item.id}` })) : [] + const recentChapters = this._mergeRecentChaptersFromLocal(apiRecent) - const readCount = Number(res.data.readCount || 0) + const readCount = readSectionIds.length const totalReadTime = Number(res.data.totalReadMinutes || 0) const matchHistory = Number(res.data.matchHistory || 0) const orderCount = Number(res.data.orderCount || 0) @@ -334,6 +352,7 @@ Page({ }) } catch (e) { console.log('[My] 拉取阅读统计失败:', e && e.message) + this._hydrateReadStatsFromLocal() } }, @@ -574,7 +593,11 @@ Page({ wx.showToast({ title: '已刷新', icon: 'success' }) }, - // 微信原生获取头像(button open-type="chooseAvatar" 回调,点击头像直接唤起选择器) + tapAvatar() { + if (!this.data.isLoggedIn) { this.showLogin(); return } + wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) + }, + async onChooseAvatar(e) { const tempAvatarUrl = e.detail?.avatarUrl if (!tempAvatarUrl) return @@ -856,9 +879,33 @@ Page({ wx.navigateTo({ url: `/pages/read/read?${q}` }) }, - // 跳转到目录 - goToChapters() { + // 已读章节:进入阅读记录页(有列表);路径可由 mpUi.myPage.readStatPath 配置 + goToReadStat() { trackClick('my', 'nav_click', '已读章节') + if (!this.data.isLoggedIn) { + this.showLogin() + return + } + const p = String(this._getMyPageUi().readStatPath || '').trim() + if (p && navigateMpPath(p)) return + navigateMpPath('/pages/reading-records/reading-records?focus=all') + }, + + /** 最近阅读区块标题点击:进入阅读记录(最近维度) */ + goToRecentReadHub() { + trackClick('my', 'nav_click', '最近阅读区块') + if (!this.data.isLoggedIn) { + this.showLogin() + return + } + const p = String(this._getMyPageUi().recentReadPath || '').trim() + if (p && navigateMpPath(p)) return + navigateMpPath('/pages/reading-records/reading-records?focus=recent') + }, + + // 去目录(空状态等) + goToChapters() { + trackClick('my', 'nav_click', '去目录') wx.switchTab({ url: '/pages/chapters/chapters' }) }, @@ -932,10 +979,22 @@ Page({ goToVip() { trackClick('my', 'btn_click', '会员中心') if (!this.data.isLoggedIn) { this.showLogin(); return } + const p = String(this._getMyPageUi().vipPath || '').trim() + if (p && navigateMpPath(p)) return wx.navigateTo({ url: '/pages/vip/vip' }) }, - // 进入个人资料编辑页(stitch_soul) + // 本人对外名片:默认与「超级个体」同款 member-detail;mpUi.myPage.cardPath 可覆盖(需含完整 query) + goToMySuperCard() { + trackClick('my', 'btn_click', '名片') + if (!this.data.isLoggedIn) { this.showLogin(); return } + const uid = this.data.userInfo?.id + if (!uid) return + const p = String(this._getMyPageUi().cardPath || '').trim() + if (p && navigateMpPath(p)) return + wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` }) + }, + goToProfileEdit() { trackClick('my', 'nav_click', '资料编辑') if (!this.data.isLoggedIn) { this.showLogin(); return } diff --git a/miniprogram/pages/my/my.wxml b/miniprogram/pages/my/my.wxml index e41e7836..eeefac3c 100644 --- a/miniprogram/pages/my/my.wxml +++ b/miniprogram/pages/my/my.wxml @@ -1,10 +1,7 @@ - + - - - 我的 @@ -23,34 +20,28 @@ - + - + {{userInfo.nickname ? userInfo.nickname[0] : '?'}} VIP VIP - {{userInfo.nickname || '点击设置昵称'}} - - {{isVip ? '会员中心' : '成为会员'}} - - - 会员 - 匹配 - 排行 + + {{mpUiCardLabel}} + {{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}} - 微信号: {{userWechat}} - + {{readCountText || '0'}} - 已读章节 + {{mpUiReadStatLabel}} {{referralCount}} @@ -117,43 +108,13 @@ - - - - - - - - - {{index + 1}} - {{item.title}} - - 阅读 - - - - - - + - + - 最近阅读 + {{mpUiRecentTitle}} 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' }) diff --git a/miniprogram/pages/profile-edit/profile-edit.wxml b/miniprogram/pages/profile-edit/profile-edit.wxml index b83adc38..52fe55d9 100644 --- a/miniprogram/pages/profile-edit/profile-edit.wxml +++ b/miniprogram/pages/profile-edit/profile-edit.wxml @@ -1,158 +1,311 @@ - + - 编辑资料 + {{wizardMode ? '分步档案(' + wizardStep + '/3)' : '编辑资料'}} 加载中... - - - - {{fromVip ? '恭喜成为VIP!完善资料后即可使用找伙伴、提现等功能,手机号必填' : '温馨提示:手机号必填,微信号建议填写,以便使用提现和找伙伴功能'}} - - - - - - - - - - - 昵称 - - - - 微信用户可点击自动填充昵称,或手动输入 + + + + 1 + + 2 + + 3 - - - MBTI - - {{mbti || '请选择'}} - + 分步完善,保存后将进入「我的超级个体名片」,可转发分享 + + + + + 第 1 步:先设置对外展示的头像与昵称 - - 地区 - - - + + + + + + 昵称* + + + + 可用微信昵称或手动输入 - - - 行业 - - - - 业务体量 - - - - 职位 - - - - 我擅长 - - - + - - - - - 核心联系方式 - - - 手机号* - - - - 微信号 - - - + + + + 第 2 步:补充职业画像(可后补,尽量填写便于匹配) + + + + + MBTI + + {{mbti || '请选择'}} + + + + 地区 + + + + + + + + 行业 + + + + 业务体量 + + + + 职位 + + + + 我擅长 + + + + - - - - - 个人故事 - - - 你最赚钱的一个月做的是什么 -