Merge branch 'devlop' into yongxu-dev

# Conflicts:
#	.cursor/skills/miniprogram-dev/SKILL.md   resolved by devlop version
#	miniprogram/pages/index/index.js   resolved by devlop version
#	miniprogram/pages/index/index.wxml   resolved by devlop version
#	miniprogram/pages/my/my.wxml   resolved by devlop version
#	miniprogram/pages/read/read.wxml   resolved by devlop version
#	miniprogram/pages/read/read.wxss   resolved by devlop version
#	soul-admin/dist/index.html   resolved by devlop version
#	soul-admin/src/pages/content/ChapterTree.tsx   resolved by devlop version
#	soul-admin/src/pages/content/ContentPage.tsx   resolved by devlop version
#	soul-admin/src/pages/distribution/DistributionPage.tsx   resolved by devlop version
#	soul-api/internal/handler/book.go   resolved by devlop version
#	soul-api/internal/handler/ckb.go   resolved by devlop version
#	soul-api/internal/handler/db_book.go   resolved by devlop version
#	soul-api/internal/handler/db_ckb_leads.go   resolved by devlop version
#	soul-api/internal/handler/db_person.go   resolved by devlop version
#	soul-api/internal/model/chapter.go   resolved by devlop version
This commit is contained in:
Alex-larget
2026-03-24 14:27:07 +08:00
470 changed files with 60847 additions and 3748 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,127 +1,21 @@
# Soul 创业派对 - .cursor 配置说明
# Soul 创业派对 · `.cursor` 速览
本目录按 **cursor标准模板** 重构rules、skills、agent 为**开发团队**服务,用于约束开发、防止互窜、经验升级。
## 路径约定
---
- 所有 Skill、会议纪要、agent 经验路径均以 **本 Git 仓库根** 为基准(与 `miniprogram/``soul-api/` 同级)。
- Rules 中「必须 Read」的路径形如 `.cursor/skills/{name}/SKILL.md`
- Python 脚本统一可用 `config/paths.py``ROOT``SKILLS``AGENT``MEETING`
## 目录结构
## 入口优先级
```
.cursor/
├── README.md # 本说明(入口)
├── config/ # 配置paths.py、workspace.txt、model_switch.json
├── rules/ # 规则boundary、checklist、助理、会议、老板分身-索引)
├── skills/ # Skills按角色分配
├── agent/ # 智能体(标准开发团队结构)
│ ├── 老板分身/ # 最高权限,协调所有角色
│ ├── 开发助理/ # 规则进化、通用经验、项目索引、bat 入口
│ │ ├── evolution/ # 通用经验池
│ │ ├── script/ # 一键-列出经验池.bat、一键-添加经验.bat 等
│ │ └── 项目索引/ # 各角色开发进度(小程序.md、管理端.md 等)
│ ├── 小程序开发工程师/
│ ├── 管理端开发工程师/
│ ├── 后端工程师/
│ ├── 产品经理/
│ ├── 软件测试/
│ └── 团队/ # 跨角色共享经验
├── scripts/ # 共享脚本evolution.py、经验模板.md、db-exec
├── docs/ # 文档(职责定义、边界、分析)
├── process/ # 工作流
├── meeting/ # 会议纪要(橙子生成)
└── archive/ # 历史归档
```
1. **三端开发**`rules/soul-project-boundary.mdc` + `skills/*-dev` / `change-checklist`
2. **派对 AI**:若存在仓库根目录 `派对AI/`,可补充读其 `BOOTSTRAP.md`;与 `.cursor` 冲突时 **以 `.cursor` 三端约定为准**(见 `rules/party-ai-dev.mdc`)。
---
## 噪声与体积
## 开发团队
- `meeting/``agent/`:历史纪要/evolution 会增多,属正常;需要时可按月归档到子目录或压缩备份。
- `scripts/db-exec/node_modules/`:已在 `.cursorignore``.gitignore` 中排除,首次使用在 `db-exec` 下执行 `npm install`
| 角色 | 负责 | 主 Skill | Agent 目录 |
|------|------|----------|------------|
| 小程序开发工程师 | miniprogram/ | SKILL-小程序开发.md | agent/小程序开发工程师/ |
| 管理端开发工程师 | soul-admin/ | SKILL-管理端开发.md | agent/管理端开发工程师/ |
| 后端开发 | soul-api/ | SKILL-API开发.md | agent/后端工程师/ |
| 产品经理 | 开发文档/1、需求/、临时需求池/ | SKILL-产品经理.md | agent/产品经理/ |
| 测试人员 | miniprogram、soul-admin、soul-api | SKILL-测试.md | agent/软件测试/ |
| 助理橙子 | 讨论后记录、经验升级 | SKILL-助理橙子-文档同步.md | agent/开发助理/ |
## 文档
**经验**:每角色 `agent/{角色}/evolution/`,团队共享 `agent/团队/evolution/`。用户说「吸收经验」「升级 skills」→ 入库 + 升级 Skill说「保存开发进度」「任务完成」→ 更新 `agent/开发助理/项目索引/{角色}.md`
---
## 快速决策(必须 Read = 使用 Read 工具读取完整内容)
| 编辑/场景 | 必须 Read 的 Skill | 自动加载的 Rule |
|-----------|-------------------|----------------|
| miniprogram/ | `SKILL-小程序开发.md` | soul-miniprogram-boundary |
| soul-admin/ | `SKILL-管理端开发.md` | soul-admin-boundary |
| soul-api/ | `SKILL-API开发.md` | soul-api |
| 开发文档/1、需求/、临时需求池/ | `SKILL-产品经理.md` | product-manager |
| 测试、测试用例、回归测试、功能测试、QA | `SKILL-测试.md` | - |
| 小橙、橙子、讨论完毕、记录、同步文档 | `SKILL-助理橙子-文档同步.md` | assistant-xiaofeng |
| 吸收经验、升级 skills、保存开发进度、任务完成、搞定了 | `SKILL-助理橙子-文档同步.md` | assistant-xiaofeng |
| 跨端功能开发 | `SKILL-角色流程控制.md` | - |
| 变更完成 | `SKILL-变更关联检查.md` | soul-change-checklist |
| 开个会、团队会议、需求评审、方案讨论 | `SKILL-团队会议.md` | soul-meeting |
| 会议结束、散会 | `SKILL-助理橙子-文档同步.md`(会议收尾) | soul-meeting |
---
## Rules 一览
| 规则 | 生效范围 | 用途 |
|------|----------|------|
| soul-project-boundary | `**`alwaysApply | 项目组成、核心原则、会话自检 |
| 老板分身-索引 | `**`alwaysApply | 经验自动收集、Soul 角色推断、编码习惯 |
| soul-change-checklist | miniprogram、soul-admin、soul-api | 变更后必过 |
| assistant-xiaofeng | 触发词 | 小橙触发器 → SKILL-助理橙子-文档同步 |
| soul-miniprogram-boundary | miniprogram/** | 只调 /api/miniprogram/* |
| soul-admin-boundary | soul-admin/** | 只调 /api/admin/*、/api/db/* |
| soul-api | soul-api/** | 路由边界 + 编码规范(合并版) |
| product-manager | 开发文档/1、需求/、临时需求池/ | 产品经理 glob 触发 |
| soul-meeting | 触发词 | 开个会、团队会议、需求评审 → SKILL-团队会议 |
---
## Skills 一览
### 角色主 Skill
| 角色 | 主 Skill | 辅助 Skill |
|------|----------|------------|
| 小程序开发工程师 | SKILL-小程序开发 | 三端架构 → API开发 → 变更关联检查 |
| 管理端开发工程师 | SKILL-管理端开发 | 三端架构 → API开发 → 变更关联检查 |
| 后端开发 | SKILL-API开发 | soul-api 规范 → 三端架构 → 变更关联检查 → MySQL直接操作 |
| 产品经理 | SKILL-产品经理 | 需求汇总、运营与变更 |
| 测试人员 | SKILL-测试 | 变更关联检查、小程序/管理端/API 规范 |
| 助理橙子 | SKILL-助理橙子-文档同步 | - |
### 场景 Skill
| 场景 | Skill |
|------|-------|
| 跨端协同 | SKILL-角色流程控制 |
| 变更检查 | SKILL-变更关联检查、soul-change-checklist |
| 文档同步、经验升级 | SKILL-助理橙子-文档同步 |
| **多角色会议** | **SKILL-团队会议** |
| next-project | SKILL-next-project仅预览 |
| 项目拆解 | SKILL-Next全栈拆解为前后端分离与小程序 |
---
## 文档与脚本
| 文档 | 说明 |
|------|------|
| [开发团队职责定义](./docs/开发团队职责定义.md) | 六角色职责、Skills 分配 |
| [三角色边界定义](./docs/三角色边界定义.md) | 开发三角色源码与业务边界 |
| [config/目录地图](./config/目录地图.md) | paths.py 路径别名 |
| [meeting/](./meeting/) | 会议纪要(橙子生成) |
| [经验清单](./agent/开发助理/经验清单.md) | 跨角色经验索引 |
| evolution 脚本 | `python .cursor/scripts/evolution.py list` 列出经验池;`add --stdin` 添加经验 |
| 一键 bat | `agent/开发助理/script/一键-列出经验池.bat` 等 |
---
## 会话启动自检
新 Cursor 打开本项目时,优先执行 soul-project-boundary 中的「会话启动自检」:仅沿用本项目的 rules、skills、开发风格与配置排除无关规则。
- 架构与迭代说明:`docs/cursor规则与架构分析及优化建议.md`

View File

@@ -13,5 +13,3 @@
| 2026-03-14 | 我的页设置入口隐藏;资料修改引导场景梳理(登录后、@某人、找伙伴、链接卡若) | [2026-03-14.md](./2026-03-14.md) |
| 2026-03-16 | 编辑资料页分享名片:转发/朋友圈特殊处理Canvas 绘制封面,标题「昵称+为您分享名片」 | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付美团式:读页→代付页→分享;详情页双态(发起人/好友);目录 loading、最新新增 5 条折叠 | [2026-03-17.md](./2026-03-17.md) |
| 2026-03-19 | 原生按钮覆盖定位chooseAvatar 等用绝对定位 overlay 覆盖,禁止 button 包裹,避免原生样式影响 | [2026-03-19-原生按钮覆盖定位.md](./2026-03-19-原生按钮覆盖定位.md) |
| 2026-03-20 | 手机号一键登录与公用组件getPhoneNumber 耦合 agreePrivacyAuthorizationlogin-modal 组件;登录后手机号同步 | [2026-03-20-手机号登录与公用组件.md](./2026-03-20-手机号登录与公用组件.md) |

View File

@@ -53,10 +53,6 @@
| 2026-03-17 | 后端、团队 | 架构/最佳实践 | api-dev SKILL | Redis 缓存parts/hot/recommended/stats/config/章节 content容灾回退 DBOSS 上传;/health 返回 database/redis 状态 |
| 2026-03-18 | 小程序、团队 | 业务规则/最佳实践 | - | 分享链路兼容好友/朋友圈 singlePage单页模式能力降级不支付/不自动领取),引导点击底部“前往小程序”进入完整版 |
| 2026-03-18 | 产品、后端、管理端、测试 | 文档归档/需求口径 | - | 文档归档整理:以《以界面定需求》为基准,各角色重整“功能需求+验收口径+风险点”并写入各自经验库;补齐《项目落地推进表》 |
| 2026-03-19 | 小程序 | 最佳实践 | miniprogram-dev SKILL §11 | 原生按钮覆盖定位chooseAvatar 等用绝对定位 overlay 覆盖,禁止 button 包裹,避免原生样式影响(灰色矩形等) |
| 2026-03-20 | 安全工程师 | 触发词约定 | security-server-ops、soul-project-boundary | 「帮我部署api到线上」→ 直接执行 soul-api/master.py |
| 2026-03-20 | 安全工程师 | 触发词约定 | security-server-ops、soul-project-boundary | 「管理端帮我部署到xx环境」→ 语义化解析正式→master.py测试→deploy.py |
| 2026-03-20 | 小程序 | 最佳实践 | miniprogram-dev SKILL §8 | 手机号登录getPhoneNumber 需耦合 agreePrivacyAuthorizationonNeedPrivacyAuthorization 支持页面;登录弹窗公用组件 login-modal |
---
@@ -67,4 +63,4 @@
---
**最后更新**2026-03-20
**最后更新**2026-03-18

View File

@@ -6,7 +6,7 @@
## 项目总结
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/`(按日期命名,以最新为主;见 `1、需求/索引.md`,项目推进表在 `开发文档/10、项目管理/项目落地推进表.md`,临时需求/分析在 `临时需求池/`
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/需求汇总.md`,项目推进表在 `开发文档/10、项目管理/项目落地推进表.md`,临时需求/分析在 `临时需求池/`
---

View File

@@ -31,11 +31,9 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-17 | 性能优化与 Redis 缓存方案落地Redis 容灾回退 DB、OSS 上传容灾;/health 返回 database/redis 状态 | 已完成 |
| 2026-03-18 | 吸收经验:分享进入链路需兼容朋友圈 singlePage单页模式不执行支付/自动领取等强动作并引导“前往小程序” | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人统一走 Person幂等键绑定 userId默认资料 flags 后端输出 | 已完成 |
| 2026-03-20 | 「帮我部署api到线上」→ 安全工程师执行 soul-api/master.pysecurity-server-ops、soul-project-boundary 触发词升级 | 已完成 |
| 2026-03-20 | 「管理端帮我部署到xx环境」→ 语义化解析:正式/线上/生产→master.py测试/dev→deploy.pysoul-admin 部署脚本索引 | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-03-20
**最后更新**2026-03-18

View File

@@ -41,11 +41,9 @@
| 2026-03-17 | 会议收尾:源码优化 5 项全部完成;开发环境测试通过 | 已完成 |
| 2026-03-18 | 吸收经验:分享链路需兼容好友/朋友圈 singlePage单页模式能力降级并引导“前往小程序”进入完整版 | 已完成 |
| 2026-03-18 | 会议:支付超级个体前/开通后资料默认校验,跳转 avatar-nickname 引导页(仅头像+昵称) | 已完成 |
| 2026-03-19 | 吸收经验原生按钮覆盖定位chooseAvatar 用绝对定位 overlay 覆盖头像,禁止 button 包裹,已升级 SKILL §11 | 已完成 |
| 2026-03-20 | 手机号一键登录getPhoneNumber 耦合 agreePrivacyAuthorizationonNeedPrivacyAuthorization 支持 read/my/gift-pay/index/settings登录弹窗公用组件 components/login-modal登录后手机号同步 _syncPhoneFromProfileAfterLogin | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-20
**最后更新**2026-03-18

View File

@@ -0,0 +1,44 @@
# 2026-03-15 Soul 创业派对全站深度测试
## 问题背景
对 Soul 创业派对项目进行首次全站深度测试(只检测不修改),覆盖管理端 20+ 页面、35 个 API 端点、25 个小程序页面、25 张数据库表。
## 解决过程
### 测试方法
1. 环境检查:确认后端/前端/数据库运行状态
2. 管理端浏览器测试:逐页逐按钮截图检查
3. API 端点测试curl 逐个测试 35 个端点,含安全边界测试
4. 小程序代码审查25 个页面 + 8 个工具文件全量代码阅读
5. 数据库一致性检查:交叉验证各 API 数据
### 关键发现42 个问题)
- 严重 11 个OSS 密钥泄露、登录守卫缺失、小程序模块混用、废弃 API
- 高 13 个硬编码、API 失败伪装成功、分页缺失
- 中 12 个:调试日志残留、模拟数据未清理
- 低 6 个:版本号未设置等
## 可提炼规则
### 安全测试
1. **密钥脱敏是硬性规则**:任何返回配置的 API密钥类字段必须脱敏
2. **SPA 路由守卫必查**:直接访问后台路径测试
3. **上传接口安全测试**:非图片文件、超大文件、空文件
### API 测试
4. **响应字段名不能假设**:先 print keys() 再解析,不同端点可能用 data/results/orders/records
5. **分页必须翻页验证**:测第一页也测 page=2
6. **交叉验证**stats 的总数 vs list API 的实际条数
### 小程序测试
7. **废弃 API 年检制度**:每年核对微信基础库废弃列表
8. **模块语法统一检查**grep -rn "export default" 快速排查
9. **死代码扫描**utils/ 每个文件是否被 pages/ 引用
### 数据库测试
10. **空表不代表无问题**:空表可能是同步逻辑失效
## 适用角色
- target_roles: ["软件测试", "团队"]

View File

@@ -53,7 +53,6 @@ AGENT_BACKEND = AGENT / "后端工程师"
AGENT_PRODUCT = AGENT / "产品经理"
AGENT_TEST = AGENT / "软件测试"
AGENT_TEAM = AGENT / "团队"
AGENT_SECURITY = AGENT / "安全工程师"
# ========== 常用文件 ==========
RULE_MAIN = RULES / "老板分身-索引.mdc"
@@ -81,9 +80,6 @@ ROLE_TO_AGENT = {
"软件测试": "软件测试",
"测试": "软件测试",
"测试人员": "软件测试",
# 安全
"安全工程师": "安全工程师",
"安全": "安全工程师",
# 通用
"团队": "团队",
}

View File

@@ -0,0 +1,156 @@
# Soul 创业派对 · .cursor 规则与架构分析及优化建议
> 分析日期2026-03-19
> 范围:`.cursor/` 下 rules、skills、agent、config、meeting、scripts
> **2026-03-20**:已批量将 `e:\Gongsi\...` 改为仓库相对路径 `.cursor/...``party-ai-dev.mdc` 已补充与 `.cursor` 的优先级;根目录 `.gitignore` 已忽略 `karuo-party/credentials/`;新增 `.cursor/README.md`、`.cursorignore`db-exec node_modules
---
## 整体架构图
![Soul 项目与 .cursor 架构](./soul-project-cursor-architecture.png)
---
## 一、整体架构总览
### 1.1 项目与 .cursor 的关系
```
项目根一场soul的创业实验-永平)
├── miniprogram/ # 微信原生小程序 C 端 → /api/miniprogram/*
├── soul-admin/ # React 管理后台(主用)→ /api/admin/*、/api/db/*
├── soul-api/ # Go + Gin + GORM 接口服务
├── next-project/ # 仅预览,非线上
├── new-soul/soul-admin/ # 新版参考,迁移时对照
└── .cursor/ # Cursor AI 规则与智能体配置
├── rules/ # 全局/场景规则alwaysApply 或 globs
├── skills/ # 按角色/场景的 Skill写作、上传、开发、会议等
├── agent/ # 角色经验与项目索引evolution、项目索引
├── config/ # paths.py、workspace 等
├── meeting/ # 会议纪要
├── scripts/ # 进化脚本、Gitea 同步、db-exec 等
└── docs/ # 本分析等文档
```
### 1.2 规则层Rules与技能层Skills关系
| 类型 | 作用 | 典型文件 |
|------|------|----------|
| **Rules** | 会话自检、项目边界、谁调哪组 API、何时加载哪个 Skill | soul-project-boundary.mdc、老板分身-索引.mdc、soul-meeting.mdc、soul-change-checklist.mdc、party-ai-dev.mdc |
| **Skills** | 具体执行规范:怎么写代码、怎么开会、怎么检查变更 | miniprogram-dev、admin-dev、api-dev、team-meeting、change-checklist、assistant-doc-sync |
- **角色推断**:按「当前编辑目录」或「用户触发词」→ 确定角色 → **必须 Read 对应 Skill 文件**后执行。
- **老板分身**:权限最高,可调度所有角色;开会时由乘风按 team-meeting 主持;经验自动收集写各角色 evolution。
### 1.3 三端与 API 路由(核心原则)
| 端 | 目录 | 允许调用的 API | 禁止 |
|----|------|----------------|------|
| 小程序 | miniprogram/ | `/api/miniprogram/*` | admin、db |
| 管理端 | soul-admin/ | `/api/admin/*``/api/db/*` | miniprogram 混用 |
| 后端 | soul-api/ | 按使用方挂 miniprogram / admin / db 分组 | 通用路径混用 |
---
## 二、优化与迭代建议
### 2.1 路径可移植性(高优先级)✅ 已落地
**原问题**rules 与部分 skills 中曾写死 **Windows 绝对路径** `e:\Gongsi\Mycontent\.cursor\skills\...`,在 macOS/Linux 或不同机器上会失效。**当前**:已统一为仓库根相对路径 `.cursor/skills/...` 等,详见 `rules/soul-project-boundary.mdc` 的「路径约定」。
**涉及文件**
- `rules/老板分身-索引.mdc`team-meeting SKILL 路径
- `rules/soul-project-boundary.mdc`:所有「必须 Read 的主 Skill 文件」表格(按编辑目录、按语义触发词、按场景触发词)
- `rules/soul-meeting.mdc`team-meeting、assistant-doc-sync 路径
- `rules/soul-change-checklist.mdc`change-checklist SKILL 路径
- `skills/assistant-doc-sync/SKILL.md`:项目索引路径
- `skills/mysql-direct/SKILL.md``cd e:\Gongsi\Mycontent`
**建议**
1. **统一改为相对项目根的路径**
例如:`项目根/.cursor/skills/team-meeting/SKILL.md`,或在规则中明确写:
「以当前项目根为基准Read `.cursor/skills/{skill-name}/SKILL.md`」。
2. 若 Cursor 支持「工作区根」变量,可写成占位符(如 `{workspace}/.cursor/skills/...`),在文档中说明各系统下的解析方式。
3. **config/paths.py** 已定义 `SKILLS = CURSOR / "skills"`,可在 `.cursor/README.md` 或 rules 中说明:**所有 Skill 路径以 `paths.py` 的 SKILLS 为准,规则中仅写相对 SKILLS 的路径**(如 `skills/team-meeting/SKILL.md`),由 AI 结合当前项目根解析。
### 2.2 跨平台脚本与入口
**问题**:老板分身规则里「若无法写文件则输出 JSON并提示用户双击 `agent/开发助理/script/一键-添加经验.bat`」。`.bat` 仅适用于 WindowsMac/Linux 用户无法使用。
**建议**
1. 增加 **Shell 版**`一键-添加经验.sh`,实现相同逻辑(或调用同一份 Python/Node 脚本)。
2. 在规则中改为:「提示用户执行 `agent/开发助理/script/一键-添加经验.bat`Windows`一键-添加经验.sh`Mac/Linux或根据环境说明」。
### 2.3 party-ai-dev.mdc 与 老板分身 的优先级
**问题**`party-ai-dev.mdc` 要求「优先使用派对 AI派对AI/BOOTSTRAP.md、SKILL_REGISTRY.md而老板分身等规则在 `.cursor/rules` 下,若同时生效可能产生「先读卡若还是先读派对」的冲突。
**建议**
1.**party-ai-dev.mdc****老板分身-索引.mdc** 中明确写清:
「当本仓库为 Soul 派对项目且存在 派对AI/ 目录时,优先按 party-ai-dev 启动顺序;否则按 .cursor/rules 与 skills 执行。」
2. 或约定:**派对AI 仅用于「在派对AI 目录下开发」的会话****在 miniprogram/soul-admin/soul-api 等目录下开发时,仅用 .cursor 的 rules+skills**,避免双重入口。
### 2.4 soul-change-checklist 与 change-checklist Skill 的引用方式
**问题**soul-change-checklist.mdc 第三十条要求 Read 的路径仍是 Windows 绝对路径。
**建议**:与 2.1 一致,改为「项目根/.cursor/skills/change-checklist/SKILL.md」或相对路径说明并在 checklist 规则末尾加一句「Skill 详细流程见 `.cursor/skills/change-checklist/SKILL.md`」。
### 2.5 会议纪要与收尾路径
**问题**soul-meeting.mdc 中会议纪要、项目索引、会议记录索引等路径未写死 Windows但 assistant-doc-sync SKILL 里项目索引写的是 `e:\Gongsi\Mycontent\.cursor\agent\...`
**建议**assistant-doc-sync 内所有路径改为「项目根/.cursor/agent/...」或相对路径,与 config/paths.py 中的 AGENT、PROJECT_INDEX 等保持一致表述。
### 2.6 角色与 Skill 的集中索引
**现状**角色→Skill 的映射分散在 soul-project-boundary按目录、按触发词、按场景和 paths.pyROLE_TO_AGENT
**建议**:在 `.cursor/README.md``docs/` 下维护一份「角色 ↔ Skill 一览表」,便于新人/新 Agent 快速查阅rules 中可写「详见 .cursor/README.md#角色与Skill映射」。
### 2.7 经验自动收集的脚本与 Mac 兼容
**现状**evolution 写入由 scripts/evolution.py 等完成paths 来自 config/paths.py已跨平台仅「一键-添加经验」的入口是 .bat。
**建议**:同 2.2,补充 .sh 或统一用 Python 脚本入口,在规则中同时给出 Windows 与 Mac/Linux 的说明。
---
## 三、规则与 Skill 清单速查
| 名称 | 类型 | 作用 |
|------|------|------|
| soul-project-boundary.mdc | Rule | 项目边界、三端 API 约定、角色推断与 Skill 加载 |
| 老板分身-索引.mdc | Rule | 老板分身权限、经验自动收集、编码习惯、三端分工 |
| soul-meeting.mdc | Rule | 开会/散会触发、会议纪要路径、收尾流程 |
| soul-change-checklist.mdc | Rule | 变更后关联检查清单(防漏改) |
| party-ai-dev.mdc | Rule | 优先派对 AI、飞书复盘、小程序上传约定 |
| miniprogram-dev | Skill | 小程序开发规范 |
| admin-dev | Skill | 管理端开发规范 |
| api-dev | Skill | 后端 API 规范 |
| product-manager | Skill | 产品需求与验收 |
| testing | Skill | 测试与回归 |
| team-meeting | Skill | 多角色会议流程 |
| assistant-doc-sync | Skill | 小橙/文档同步/经验入库/会议收尾 |
| change-checklist | Skill | 变更关联检查详细流程 |
| role-flow-control | Skill | 跨端协同与角色流程 |
| three-tier-arch | Skill | 三端架构与框架分析 |
| new-version-analyze | Skill | 新版分析、迁移对比 |
| next-preview / next-split | Skill | next-project 仅预览、拆解指引 |
| mysql-direct | Skill | MySQL 直接操作、db-exec |
---
## 四、总结
- **架构**:项目为三端(小程序 + 管理端 + soul-api.cursor 通过 rules 定边界与触发、skills 定执行细节、agent 存经验与项目索引,**config/paths.py** 为路径与角色映射中心。
- **优先迭代**
1所有 **Skill/agent 路径** 改为可移植(相对项目根或相对 .cursor
2**一键-添加经验** 增加 Mac/Linux 入口;
3**party-ai 与 .cursor** 的适用场景或优先级写清楚。
- 按上述调整后,在不同系统和不同克隆路径下都能一致生效,且便于后续扩展角色或 Skill。

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -23,7 +23,8 @@
```powershell
# 在 soul-api 目录下执行
cd e:\Gongsi\Mycontent\soul-api
# 在仓库根目录下执行(与 miniprogram、soul-api 同级)
cd soul-api
.\scripts\test-p0-endpoints.ps1
```

View File

@@ -8,9 +8,9 @@ alwaysApply: false
当用户提及**小橙、橙子、橙橙、🍊**,或说**「讨论完毕」「记录一下」「同步到开发文档」「更新文档」「吸收经验」「升级 skills」「记录经验」「保存开发进度」「更新项目索引」「记录开发进度」「任务完成」「搞定了」「完成了」「会议结束」「散会」「会开完了」**时:
**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md` 的完整内容**,然后严格按其流程执行。
**必须使用 Read 工具读取 `.cursor/skills/assistant-doc-sync/SKILL.md` 的完整内容**,然后严格按其流程执行。
### 行为摘要(供模型快速理解,完整流程以 SKILL 文件为准)
1. **文档同步**:从对话中提炼结论/待办/变更 → 写入 `开发文档/1、需求/YYYY-MM-DD-需求.md`(当日文件,以日期最新为主)、`开发文档/10、项目管理/运营与变更.md`、`临时需求池/` 等对应文档
1. **文档同步**:从对话中提炼结论/待办/变更 → 写入 `开发文档/1、需求/需求汇总.md`、`开发文档/10、项目管理/运营与变更.md`、`临时需求池/` 等对应文档
2. **经验入库**:提炼经验 → 写入 `agent/{角色}/evolution/YYYY-MM-DD.md` → 更新 `agent/开发助理/项目索引/{索引名}.md`(写日期)→ 更新 `agent/开发助理/经验清单.md` → 升级对应 SKILL

View File

@@ -0,0 +1,63 @@
# 派对 AI 开发规则
## 与本仓库 `.cursor` 的优先级(避免入口打架)
- **三端编码与接口**:以本仓库 `.cursor/rules`(如 `soul-project-boundary.mdc`)与 `.cursor/skills` 为准——小程序/管理端/soul-api 路由隔离、变更检查、角色 Skill 加载等**不因派对 AI 而绕过**。
- **派对 AI 目录**:若仓库根下存在 `派对AI/`,开发前可补充读取其 `BOOTSTRAP.md`、`SKILL_REGISTRY.md`,用于派对域身份、运营与流程;与 `.cursor` 冲突时,**代码与 API 约定以 `.cursor` 为准**。
- **卡若 AI 全局规则**:仅在本会话需要时参考;本仓库会话自检仍以 **本仓库 `.cursor/`** 为主。
## 强制:使用派对 AI 进行开发(当 `派对AI/` 存在时)
所有开发操作**优先结合派对 AI**(路径:**仓库根下的** `派对AI/`,与 `miniprogram/` 同级)。若本仓库无 `派对AI/` 目录,则跳过本节启动顺序,仅按 `.cursor` 执行。
### 启动顺序
1. 读取 `派对AI/BOOTSTRAP.md` — 了解项目 AI 身份与团队
2. 读取 `派对AI/SKILL_REGISTRY.md` — 查找对应技能
3. 按匹配的 SKILL.md 执行
### 开发复盘推送飞书群
每次开发完成后,**必须将复盘内容推送到 Soul 创业派对开发资料群**
```
飞书 Webhook: https://open.feishu.cn/open-apis/bot/v2/hook/c558df98-e13a-419f-a3c0-7e428d15f494
```
推送格式:
```json
{
"msg_type": "text",
"content": {
"text": "[开发复盘] 日期时间\n\n🎯 目标:...\n📌 结果:...\n💡 关键判断:...\n📝 遗留:...\n▶ 下一步:..."
}
}
```
### 需求必须 100% 完成(铁律)
**禁止**:说「留到下一轮迭代」「后续再处理」。
收到需求文档后,**全量开发**到每一项功能都落地为止。
若单个功能涉及跨端改动(后端 + 管理端 + 小程序),必须三端同步改完。
仅当功能在代码层面**无法实现**(如依赖第三方审核、硬件条件不具备)时,才可标注「依赖外部」并说明原因。
### 复盘格式
使用卡若 AI 标准复盘格式(🎯📌💡📝▶ 五块齐全),带日期+时间。
开发完成后**必须**立即发送飞书复盘(不需要用户提醒)。
### 小程序上传约定(强制)
- **版本号固定 `1.2.6`**每次上传小程序soul-party / 派对AI统一使用版本 `1.2.6`,不递增。
- **上传后设为体验版**:上传完成后自动设为体验版,方便扫码测试。
- **不自动提交审核**:上传后默认不提交审核。只有用户明确说「上传审核」「提交审核」时,才执行审核提交。
- **上传命令**
```bash
# 上传 + 体验版(默认,不提审)
python 开发文档/小程序管理/scripts/mp_deploy.py deploy soul-party --skip-cert
# 上传 + 体验版 + 提交审核(用户明确要求时)
python 开发文档/小程序管理/scripts/mp_deploy.py deploy soul-party --skip-cert --submit
# 单独提交审核(已上传后)
python 开发文档/小程序管理/scripts/mp_deploy.py submit soul-party
```

View File

@@ -8,4 +8,4 @@ alwaysApply: false
当编辑 **开发文档/1、需求/**、**临时需求池/**、**开发文档/10、项目管理/** 时,推断当前角色为**产品经理**。
**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md` 的完整内容**,然后按其规范执行需求分析、文档编写、验收标准制定。
**必须使用 Read 工具读取 `.cursor/skills/product-manager/SKILL.md` 的完整内容**,然后按其规范执行需求分析、文档编写、验收标准制定。

View File

@@ -26,8 +26,8 @@ alwaysApply: false
## Skill 加载(必须执行)
**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\admin-dev\SKILL.md` 的完整内容**,按其规范进行开发。该 Skill 包含代码风格、业务逻辑、API 对接细节等完整约定。
**必须使用 Read 工具读取 `.cursor/skills/admin-dev/SKILL.md` 的完整内容**,按其规范进行开发。该 Skill 包含代码风格、业务逻辑、API 对接细节等完整约定。
接口实现与路由分组的规范在 `e:\Gongsi\Mycontent\.cursor\rules\soul-api.mdc`(编辑 soul-api 时自动加载)。
接口实现与路由分组的规范在 `.cursor/rules/soul-api.mdc`(编辑 soul-api 时自动加载)。
违反上述路径或职责边界即视为「互窜」,需纠正后再提交。

View File

@@ -6,7 +6,7 @@ alwaysApply: false
# soul-api 开发规范
> **Skill 加载**:编辑 soul-api 代码时,**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\api-dev\SKILL.md` 的完整内容**,该 Skill 包含业务对接、与前端边界协同等补充约定。本规则侧重编码规范与路由边界。
> **Skill 加载**:编辑 soul-api 代码时,**必须使用 Read 工具读取 `.cursor/skills/api-dev/SKILL.md` 的完整内容**,该 Skill 包含业务对接、与前端边界协同等补充约定。本规则侧重编码规范与路由边界。
## 1. 路由按使用方归类(强制)

View File

@@ -30,7 +30,7 @@ alwaysApply: false
- **每次**在 miniprogram、soul-admin、soul-api 内完成一轮修改后,**先过一遍上表 + 二**,再视为本次变更完成。
- 若本次变更涉及多端(例如小程序新功能 + 管理端配置页),应在同一次任务内一并完成或明确记录未做项,避免漏改。
- 更详细的检查流程:**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\change-checklist\SKILL.md` 的完整内容**,按其「以领域为单位思考」的方法逐项确认。
- 更详细的检查流程:**必须使用 Read 工具读取 `.cursor/skills/change-checklist/SKILL.md` 的完整内容**(相对本仓库根),按其「以领域为单位思考」的方法逐项确认。
## 四、聊天中触发变更检查

View File

@@ -0,0 +1,22 @@
---
description: Soul 仓库内对话与卡若 AI 复盘格式对齐alwaysApply
globs: ["**"]
alwaysApply: true
---
# Soul 项目 · 卡若 AI 对话约定
> 与 `soul-project-boundary.mdc` 中「卡若 AI 对话规范」一致;本文件只强调**对话形态**,不重复三端技术边界。
## 必须遵守
0. **默认零提问**:派对/Soul 相关开发、运维、脚本、填表链路,**禁止**反问用户「是否执行」「要不要」。缺信息则读本仓库代码与配置、用合理默认推进;**仅**验证码/密钥缺失/不可逆删除等无法代劳时,用**一句**说明缺什么。
1. **语言**:面向用户的说明、结论、按钮文案解释等,默认 **简体中文**。
2. **收尾**:每一轮对用户可见的助手回复,**最后一段**必须是完整 **[卡若复盘]YYYY-MM-DD HH:mm** 块,含五段:**🎯 目标·结果·达成率**(单行 ≤30 字且含达成率 %)·**📌 过程** · **💡 反思** · **📝 总结** · **▶ 下一步执行**。复盘块内**禁止表格**。
3. **格式源**:与卡若 AI 仓库内 `运营中枢/参考资料/卡若复盘格式_固定规则.md` 保持同一种写法若当前工作区已挂载「卡若AI」目录修改复盘规则时以该文件为唯一真源。
4. **需求节奏**:仍服从「需求即执行」——先「好」再改代码再报结果;复盘块放在**全条回复最末**。
5. **直接执行**:用户说「直接做、别讲写了什么」时,**正文极短****复盘五块不可省**,可压缩过程为 12 条要点。
## 与卡若中枢的衔接
- Token/API 费用:助手侧通常无 Cursor 账单接口,在 💡 中说明「请用户在 Cursor Usage 自查」即可。

View File

@@ -6,7 +6,7 @@ alwaysApply: true
# Soul 创业派对 - 会议触发器
当用户表达**开会意图**时(包括但不限于以下触发词),**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\team-meeting\SKILL.md` 的完整内容**,然后严格按其流程主持会议。
当用户表达**开会意图**时(包括但不限于以下触发词),**必须使用 Read 工具读取 `.cursor/skills/team-meeting/SKILL.md` 的完整内容**(相对本仓库根),然后严格按其流程主持会议。
## 语义化触发词(理解意图,不必完全匹配)
@@ -24,9 +24,9 @@ alwaysApply: true
当用户说**「会议结束」「散会」「会开完了」「结束会议」**时:
1. 助理橙子立即执行收尾流程
2. **生成会议纪要**`e:\Gongsi\Mycontent\.cursor\meeting\YYYY-MM-DD_主题.md`
3. **各角色经验入库**`e:\Gongsi\Mycontent\.cursor\agent\{角色}\evolution\YYYY-MM-DD.md`
4. **更新项目索引**`agent/开发助理/项目索引/{索引名}.md` 开发进度表追加一行
5. **更新会议记录索引**`e:\Gongsi\Mycontent\.cursor\meeting\README.md`
2. **生成会议纪要**`.cursor/meeting/YYYY-MM-DD_主题.md`
3. **各角色经验入库**`.cursor/agent/{角色}/evolution/YYYY-MM-DD.md`
4. **更新项目索引**`.cursor/agent/开发助理/项目索引/{索引名}.md` 开发进度表追加一行
5. **更新会议记录索引**`.cursor/meeting/README.md`
**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md` 执行收尾。**
**必须使用 Read 工具读取 `.cursor/skills/assistant-doc-sync/SKILL.md` 执行收尾。**

View File

@@ -23,8 +23,8 @@ alwaysApply: false
## Skill 加载(必须执行)
**必须使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\miniprogram-dev\SKILL.md` 的完整内容**,按其规范进行开发。该 Skill 包含代码风格、业务逻辑、API 对接细节等完整约定。
**必须使用 Read 工具读取 `.cursor/skills/miniprogram-dev/SKILL.md` 的完整内容**,按其规范进行开发。该 Skill 包含代码风格、业务逻辑、API 对接细节等完整约定。
接口实现与路由分组的规范在 `e:\Gongsi\Mycontent\.cursor\rules\soul-api.mdc`(编辑 soul-api 时自动加载)。
接口实现与路由分组的规范在 `.cursor/rules/soul-api.mdc`(编辑 soul-api 时自动加载)。
违反上述路径或职责边界即视为「互窜」,需纠正后再提交。

View File

@@ -20,17 +20,18 @@ alwaysApply: true
| 预览/参考 | next-project/ | 仅预览,非线上 | 不依赖 |
| **新版管理端** | **new-soul/soul-admin/** | 新版参考实现,迁移时对照 | soul-api |
## 需求目录与命名约定
- **需求目录**`开发文档/1、需求/`
- **命名**`YYYY-MM-DD-需求.md` 或 `YYYY-MM-DD-简短描述.md`
- **主需求**:以日期最新的需求文件为主;同步需求时新建/更新当日文件,并更新 `1、需求/索引.md`
- **基准**`以界面定需求.md` 为界面级需求基准,新增/改版界面或业务规则时先更新该文档
## 核心原则
- 小程序只调 `/api/miniprogram/*`;管理端只调 `/api/admin/*`、`/api/db/*`;禁止混用。
- 变更完成必过 soul-change-checklist.mdc聊天中说「变更完成」「检查一下」「准备提交」时主动触发检查。
- **需求即执行 + 零提问**:收到需求后**禁止**列出分析表格再问用户选哪个执行,**禁止**「是否帮你执行」类确认。正确做法:内部定方案 → **直接改代码/跑命令** → 回复结果。缺信息:先查仓库与配置推断;**仅**在验证码、缺失密钥、不可逆删除等无法代劳时极简说明。
- **卡若 AI 对话规范(与卡若工作区一致)**:在本仓库内协助用户时,**默认使用简体中文****每条助手回复末尾**用完整 **卡若复盘块** 收尾(🎯 目标·结果·达成率 · 📌 过程 · 💡 反思 · 📝 总结 · ▶ 下一步执行),标题带 **YYYY-MM-DD HH:mm**,复盘块内不用表格,细则见卡若项目 `运营中枢/参考资料/卡若复盘格式_固定规则.md`(多根工作区时请 Read 该文件。Mongo 同步、飞书 webhook 等以卡若 `.cursor/rules/karuo-ai.mdc` 为准(本仓库 Agent 在能执行脚本时同样执行对话留存脚本)。
## 路径约定Skill / agent / meeting
- 下表及本仓库 Skill 中的路径均以 **本 Git 仓库根目录** 为基准(与 `miniprogram/`、`soul-api/` 同级),**不使用盘符或另一台机器上的绝对路径**。
- 使用 Read / Write 工具时:填写 **`仓库根/.cursor/...`**,例如 `.cursor/skills/api-dev/SKILL.md`(与规则中的写法一致即可)。
- 脚本中的物理路径以 `config/paths.py` 的 `ROOT`、`SKILLS`、`AGENT`、`MEETING` 为准。
## 角色推断与 Skill 加载(必须执行)
@@ -38,13 +39,13 @@ alwaysApply: true
### 按编辑目录
| 编辑目录 | 推断角色 | 必须 Read 的主 Skill 文件(绝对路径 |
|----------|----------|---------------------------------------|
| miniprogram/ | 小程序开发工程师 | `e:\Gongsi\Mycontent\.cursor\skills\miniprogram-dev\SKILL.md` |
| soul-admin/ | 管理端开发工程师 | `e:\Gongsi\Mycontent\.cursor\skills\admin-dev\SKILL.md` |
| soul-api/ | 后端开发 | `e:\Gongsi\Mycontent\.cursor\skills\api-dev\SKILL.md` |
| 开发文档/1、需求/、临时需求池/ | 产品经理 | `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md` |
| .cursor/ | 助理橙子 | `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md` |
| 编辑目录 | 推断角色 | 必须 Read 的主 Skill 文件(相对仓库根 |
|----------|----------|----------------------------------------|
| miniprogram/ | 小程序开发工程师 | `.cursor/skills/miniprogram-dev/SKILL.md` |
| soul-admin/ | 管理端开发工程师 | `.cursor/skills/admin-dev/SKILL.md` |
| soul-api/ | 后端开发 | `.cursor/skills/api-dev/SKILL.md` |
| 开发文档/1、需求/、临时需求池/ | 产品经理 | `.cursor/skills/product-manager/SKILL.md` |
| .cursor/ | 助理橙子 | `.cursor/skills/assistant-doc-sync/SKILL.md` |
### 按语义触发词(说啥切角色,无需编辑文件)
@@ -52,26 +53,23 @@ alwaysApply: true
| 触发词 | 推断角色 | 必须 Read 的 Skill 文件 |
|--------|----------|-------------------------|
| 后端、API、soul-api、接口、Go、GORM | 后端开发 | `e:\Gongsi\Mycontent\.cursor\skills\api-dev\SKILL.md` |
| 管理端、soul-admin、React、后台管理 | 管理端开发工程师 | `e:\Gongsi\Mycontent\.cursor\skills\admin-dev\SKILL.md` |
| 小程序、miniprogram、C 端、微信小程序 | 小程序开发工程师 | `e:\Gongsi\Mycontent\.cursor\skills\miniprogram-dev\SKILL.md` |
| 产品、需求、验收、排期、需求文档 | 产品经理 | `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md` |
| 测试、测试用例、回归测试、功能测试、QA | 测试人员 | `e:\Gongsi\Mycontent\.cursor\skills\testing\SKILL.md` |
| 挖矿、安全、服务器操作、部署、miner_guard、xmrig、入侵排查 | 安全工程师 | `e:\Gongsi\Mycontent\.cursor\skills\security-miner-guard\SKILL.md`、`e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md` |
| 后端、API、soul-api、接口、Go、GORM | 后端开发 | `.cursor/skills/api-dev/SKILL.md` |
| 管理端、soul-admin、React、后台管理 | 管理端开发工程师 | `.cursor/skills/admin-dev/SKILL.md` |
| 小程序、miniprogram、C 端、微信小程序 | 小程序开发工程师 | `.cursor/skills/miniprogram-dev/SKILL.md` |
| 产品、需求、验收、排期、需求文档 | 产品经理 | `.cursor/skills/product-manager/SKILL.md` |
| 测试、测试用例、回归测试、功能测试、QA | 测试人员 | `.cursor/skills/testing/SKILL.md` |
### 按场景触发词
| 场景触发词 | 必须 Read 的 Skill 文件(绝对路径 |
|------------|-------------------------------------|
| 小橙、橙子、橙橙、🍊、讨论完毕、记录一下、记录、同步文档 | `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md` |
| 吸收经验、升级 skills、记录经验、保存开发进度、更新项目索引、记录开发进度、任务完成、搞定了、完成了 | `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md` |
| 跨端功能开发 | `e:\Gongsi\Mycontent\.cursor\skills\role-flow-control\SKILL.md` |
| 变更完成、检查一下、准备提交 | `e:\Gongsi\Mycontent\.cursor\skills\change-checklist\SKILL.md` |
| 开个会、开会、团队会议、乘风开会、需求评审、方案讨论、大家一起讨论 | `e:\Gongsi\Mycontent\.cursor\skills\team-meeting\SKILL.md`(老板分身/乘风主持) |
| 会议结束、散会、会开完了 | `e:\Gongsi\Mycontent\.cursor\skills\assistant-doc-sync\SKILL.md`(会议收尾) |
| **加个需求**、加个需求xxx | `e:\Gongsi\Mycontent\.cursor\skills\product-manager\SKILL.md`产品经理三端分析 → 功能规划 → 指派 |
| **新版分析**、版本对比、迁移分析、甲方代码分析、快速分析新版、抽取需求 | `e:\Gongsi\Mycontent\.cursor\skills\new-version-analyze\SKILL.md`(新版快速分析 → 差异清单 → 接口冲突 → 迁移迭代) |
| **帮我部署api到线上** | `e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md`(安全工程师 → 执行 soul-api/master.py |
| **管理端帮我部署到xx环境** | `e:\Gongsi\Mycontent\.cursor\skills\security-server-ops\SKILL.md`(安全工程师 → 语义化解析 xx正式/线上/生产→master.py测试/dev→deploy.py |
| 场景触发词 | 必须 Read 的 Skill 文件(相对仓库根 |
|------------|----------------------------------------|
| 小橙、橙子、橙橙、🍊、讨论完毕、记录一下、记录、同步文档 | `.cursor/skills/assistant-doc-sync/SKILL.md` |
| 吸收经验、升级 skills、记录经验、保存开发进度、更新项目索引、记录开发进度、任务完成、搞定了、完成了 | `.cursor/skills/assistant-doc-sync/SKILL.md` |
| 跨端功能开发 | `.cursor/skills/role-flow-control/SKILL.md` |
| 变更完成、检查一下、准备提交 | `.cursor/skills/change-checklist/SKILL.md` |
| 开个会、开会、团队会议、乘风开会、需求评审、方案讨论、大家一起讨论 | `.cursor/skills/team-meeting/SKILL.md`(老板分身/乘风主持) |
| 会议结束、散会、会开完了 | `.cursor/skills/assistant-doc-sync/SKILL.md`(会议收尾) |
| **加个需求**、加个需求xxx | `.cursor/skills/product-manager/SKILL.md`需求即执行:回复「好」→ 直接执行代码变更 → 回复结果 |
| **新版分析**、版本对比、迁移分析、甲方代码分析、快速分析新版、抽取需求 | `.cursor/skills/new-version-analyze/SKILL.md`(新版快速分析 → 差异清单 → 接口冲突 → 迁移迭代) |
**注意**:「必须 Read」= 使用 Read 工具读取**绝对路径**的完整文件内容后执行,不可跳过或仅凭记忆。
**注意**:「必须 Read」= 使用 Read 工具读取上述路径相对于**当前工作区仓库根**的完整文件内容后执行,不可跳过或仅凭记忆。

View File

@@ -6,7 +6,7 @@ alwaysApply: true
# 老板分身 - 能力与约束Soul 创业派对)
> **老板分身权限最高**:协调所有智能体(小程序开发工程师、管理端开发工程师、后端工程师、产品经理、开发助理等)。其他 agent 执行任务时遵循本规则;老板分身可调度、协调、指派任一角色。
> **激活方式**:用户说「老板」「分身」「乘风」「架构」「帮我协调」时,从旁观者转为主动参与。**开会时**:用户说「开会」「开个会」「团队会议」「乘风开会」「需求评审」「方案讨论」等表达开会意图时,**必须先**用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\skills\team-meeting\SKILL.md` 完整内容,然后由老板分身(乘风)按该协议主持多角色会议,不可仅回复而不执行流程。
> **激活方式**:用户说「老板」「分身」「乘风」「架构」「帮我协调」时,从旁观者转为主动参与。**开会时**:用户说「开会」「开个会」「团队会议」「乘风开会」「需求评审」「方案讨论」等表达开会意图时,**必须先**用 Read 工具读取 `.cursor/skills/team-meeting/SKILL.md` 完整内容(相对本仓库根),然后由老板分身(乘风)按该协议主持多角色会议,不可仅回复而不执行流程。
> **会话自检**:仅沿用本项目 `.cursor/` 下的 rules、skills、agent忽略与本项目无关的全局 rules/skills。
> **Skill 撰写**:维护或新增 Skill 时参考 `e:\Gongsi\Mycontent\.cursor\docs\skill-writing-principles.md`(写 Claude 不知道的、Gotchas 是灵魂、留灵活空间)。
> **角色驱动**Soul 角色与 agent 映射见 `config/paths.py` 的 ROLE_TO_AGENT。
@@ -40,12 +40,11 @@ alwaysApply: true
- 产品/需求/config→**产品经理**
- 测试/自检/QA→**软件测试**
- 架构/选型/路由约定/三端协同→**团队**
- 挖矿/安全/服务器操作/部署/入侵排查→**安全工程师**
- 无法判断→**通用**(写入开发助理)
3. **若可写文件**
- **有明确目标角色**:写入 `.cursor/agent/{角色}/evolution/YYYY-MM-DD-简短描述.md`,并更新该目录下的 `索引.md`
- **无法判断角色**:写入 `.cursor/agent/开发助理/evolution/`
4. **若无法写文件**:输出 JSON并提示用户双击 `agent/开发助理/script/一键-添加经验.bat`
4. **若无法写文件**:输出 JSON并提示用户在仓库根执行:`.cursor/agent/开发助理/script/一键-添加经验.bat`Windows或同目录下的 `.sh` / Python 入口macOS/Linux若已提供
### Soul 目标角色与 target_roles 取值

View File

@@ -0,0 +1,55 @@
# 与 Gitea192.168.1.201)同步
## 远程
- **gitea-local**`http://192.168.1.201:3000/fnvtk/soul-yongping.git`(拉取 + 推送)
## 手动同步
```bash
./.cursor/scripts/gitea-sync.sh
```
## 每 10 分钟自动同步macOS launchd
- 已安装:`~/Library/LaunchAgents/com.soul.yongping.gitea-sync.plist`
- 每 10 分钟执行一次,登录后自动加载
**启用:**
```bash
launchctl load ~/Library/LaunchAgents/com.soul.yongping.gitea-sync.plist
```
**停用:**
```bash
launchctl unload ~/Library/LaunchAgents/com.soul.yongping.gitea-sync.plist
```
**查看是否在跑:**
```bash
launchctl list | grep com.soul.yongping.gitea-sync
```
## 认证192.168.1.201 需登录时)
若 push/pull 需要账号密码,定时任务无法弹窗,请把凭证写进 remote URL勿提交到仓库
```bash
git remote set-url gitea-local 'http://用户名:token或密码@192.168.1.201:3000/fnvtk/soul-yongping.git'
```
或用系统钥匙串:
```bash
git config --global credential.helper osxkeychain
# 然后手动执行一次 gitea-sync.sh输入一次账号密码之后由钥匙串记住
```
## 日志
- 脚本内部:`.cursor/scripts/gitea-sync.log`
- launchd 标准输出:`.cursor/scripts/gitea-sync-launchd.log`
- launchd 错误:`.cursor/scripts/gitea-sync-launchd.err.log`

View File

@@ -0,0 +1,163 @@
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted

View File

View File

@@ -0,0 +1,48 @@
[2026-03-19 14:54:01] --- sync start (branch=devlop, remote=gitea-local) ---
From http://192.168.1.201:3000/fnvtk/soul-yongping
* [new branch] devlop -> gitea-local/devlop
* [new branch] main -> gitea-local/main
error: cannot pull with rebase: You have unstaged changes.
error: Please commit or stash them.
[devlop 28a69cbc] sync: 2026-03-19 14:54
Committer: 卡若 <karuo@MacBook-Pro.local>
Your name and email address were configured automatically based
on your username and hostname. Please check that they are accurate.
You can suppress this message by setting them explicitly:
git config --global user.name "Your Name"
git config --global user.email you@example.com
After doing this, you may fix the identity used for this commit with:
git commit --amend --reset-author
26 files changed, 164 insertions(+), 2133 deletions(-)
create mode 100644 .cursor/scripts/README-gitea-sync.md
create mode 100644 .cursor/scripts/gitea-sync-launchd.err.log
create mode 100644 .cursor/scripts/gitea-sync-launchd.log
create mode 100644 .cursor/scripts/gitea-sync.log
create mode 100755 .cursor/scripts/gitea-sync.sh
create mode 100644 project.config.json
delete mode 100644 开发文档/1、需求/文章详情-阅读页线框图.md
delete mode 100644 开发文档/1、需求/链接人与事-所有同步需求.md
delete mode 100644 开发文档/代付功能-美团式方案与场景清单.md
delete mode 100644 开发文档/全站测试报告_20260315.md
delete mode 100644 开发文档/存客宝对接逻辑图.md
delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260315_2344.png
delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260316_0221.png
delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260316_1804.png
delete mode 100644 开发文档/找朋友代付-流程与配置.md
delete mode 100644 开发文档/新版管理端迁移到稳定版-需求评估.md
delete mode 100644 开发文档/新版迁移-开发方案与清单.md
delete mode 100644 开发文档/稳定版-小程序与API对比.md
delete mode 100644 开发文档/稳定版-源码质量分析报告.md
delete mode 100644 开发文档/稳定版-管理端与小程序对接分析.md
delete mode 100644 开发文档/稳定版适配新界面-调整清单.md
delete mode 100644 开发文档/管理端两版界面差异-新需求参考.md
delete mode 100644 开发文档/管理端迁移分析-基于小程序功能.md
delete mode 100644 开发文档/规则引擎迁移-影响分析.md
delete mode 100644 开发文档/迁移完成度与待办清单.md
remote: Failed to authenticate user
fatal: Authentication failed for 'http://192.168.1.201:3000/fnvtk/soul-yongping.git/'
[2026-03-19 14:54:02] --- sync end ---

46
.cursor/scripts/gitea-sync.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# 与 Gitea192.168.1.201)双向同步:先拉取,有本地变更则提交并推送
# 可手动执行,也可由 launchd 每 10 分钟执行
set -e
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REMOTE="gitea"
LOG_FILE="$REPO_ROOT/.cursor/scripts/gitea-sync.log"
cd "$REPO_ROOT"
BRANCH=$(git rev-parse --abbrev-ref HEAD)
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
log "--- sync start (branch=$BRANCH, remote=$REMOTE) ---"
# 1. 拉取远程更新(若有未提交变更则先 stashpull 后再 pop
STASHED=""
if [ -n "$(git status -s)" ]; then
git stash push -u -m "gitea-sync $(date +%s)" 2>/dev/null && STASHED=1 || true
fi
git fetch "$REMOTE" 2>&1 | tee -a "$LOG_FILE" || true
if git ls-remote --exit-code --heads "$REMOTE" "$BRANCH" &>/dev/null; then
git pull "$REMOTE" "$BRANCH" --no-edit 2>&1 | tee -a "$LOG_FILE" || log "pull 失败或冲突,继续尝试推送本地变更"
fi
[ -n "$STASHED" ] && git stash pop 2>/dev/null || true
# 2. 若有本地未提交变更,则提交并推送
STATUS=$(git status -s)
if [ -n "$STATUS" ]; then
git add -A
git commit -m "sync: $(date '+%Y-%m-%d %H:%M')" 2>&1 | tee -a "$LOG_FILE" || log "commit failed (nothing to commit or conflict)"
git push "$REMOTE" "$BRANCH" 2>&1 | tee -a "$LOG_FILE" || log "push failed"
else
# 若有已提交但未推送的提交,也推送(仅当远程有此分支且本地比远程多提交时)
if git ls-remote --exit-code --heads "$REMOTE" "$BRANCH" &>/dev/null; then
AHEAD=$(git rev-list "refs/remotes/${REMOTE}/${BRANCH}"..HEAD --count 2>/dev/null || echo 0)
if [ "${AHEAD:-0}" -gt 0 ]; then
git push "$REMOTE" "$BRANCH" 2>&1 | tee -a "$LOG_FILE" || log "push failed"
fi
else
git push -u "$REMOTE" "$BRANCH" 2>&1 | tee -a "$LOG_FILE" || log "push (new branch) failed"
fi
fi
log "--- sync end ---"

View File

@@ -35,7 +35,7 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
| 要点类型 | 写入位置 | 示例 |
|----------|----------|------|
| 需求清单项 | `开发文档/1、需求/YYYY-MM-DD-需求.md`(当日文件,以日期最新为主) | 会员分润差异化、VIP 手动设置;同步后更新 `1、需求/索引.md` |
| 需求清单项 | `开发文档/1、需求/需求汇总.md` 需求清单表 | 会员分润差异化、VIP 手动设置 |
| 近期讨论 | `开发文档/10、项目管理/运营与变更.md` | 第五部分或新增节 |
| 技术分析 | `临时需求池/``开发文档/8、部署/` | 分润需求-技术分析.md |
| 项目推进 | `开发文档/10、项目管理/项目落地推进表.md` | 第十二节永平落地表 |
@@ -58,7 +58,7 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
1. **确定角色**根据本次对话涉及目录miniprogram/soul-admin/soul-api/需求文档)推断对应角色
2. **提炼进度**:从对话中提取本次开发完成的内容、当前阶段、待续项
3. **更新项目索引**:打开 `e:\Gongsi\Mycontent\.cursor\agent\开发助理\项目索引\{索引文件名}.md`(索引文件名见步骤 5.1 映射表)
3. **更新项目索引**:打开 `.cursor\agent\开发助理\项目索引\{索引文件名}.md`(索引文件名见步骤 5.1 映射表)
- 在「开发进度」表追加一行,**必须写日期**YYYY-MM-DD
- 视需要更新「项目总结」段落
- 文末「最后更新」改为当前日期
@@ -66,12 +66,12 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
### 4.6 会议收尾(会议结束、散会、会开完了时)
当用户说**「会议结束」「散会」「会开完了」**时,执行会议收尾流程(完整流程见 `e:\Gongsi\Mycontent\.cursor\skills\team-meeting\SKILL.md` 第 4 节):
当用户说**「会议结束」「散会」「会开完了」**时,执行会议收尾流程(完整流程见 `.cursor\skills\team-meeting\SKILL.md` 第 4 节):
1. **生成会议纪要**`e:\Gongsi\Mycontent\.cursor\meeting\YYYY-MM-DD_主题.md`,按 `_模板.md` 填写;**必须包含「问题与作答区」**,将待确认/待澄清项列出为问题表,作答列留空
2. **各角色经验入库**:追加到 `e:\Gongsi\Mycontent\.cursor\agent\{角色目录}\evolution\YYYY-MM-DD.md`(角色→目录见步骤 5.1 映射表)
1. **生成会议纪要**`.cursor\meeting\YYYY-MM-DD_主题.md`,按 `_模板.md` 填写;**必须包含「问题与作答区」**,将待确认/待澄清项列出为问题表,作答列留空
2. **各角色经验入库**:追加到 `.cursor\agent\{角色目录}\evolution\YYYY-MM-DD.md`(角色→目录见步骤 5.1 映射表)
3. **更新项目索引**:各参会角色对应的 `agent/开发助理/项目索引/{索引名}.md` 开发进度表追加一行,写日期
4. **更新会议记录索引**`e:\Gongsi\Mycontent\.cursor\meeting\README.md` 索引表追加
4. **更新会议记录索引**`.cursor\meeting\README.md` 索引表追加
5. **Skill 升级**(若有影响开发规范的决议):更新对应 SKILL-xxx.md
6. **更新 sync-log**:在 `sync-log.md` 末尾追加 `- YYYY-MM-DD 会议收尾:{主题}`
7. **回复**:会议收尾已完成,纪要见 `meeting/YYYY-MM-DD_主题.md`
@@ -89,7 +89,6 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
| 后端开发 | `agent/后端工程师/evolution/` | `agent/开发助理/项目索引/后端.md` |
| 产品经理 | `agent/产品经理/evolution/` | `agent/开发助理/项目索引/产品.md` |
| 测试人员 | `agent/软件测试/evolution/` | `agent/开发助理/项目索引/测试.md` |
| 安全工程师 | `agent/安全工程师/evolution/` | `agent/开发助理/项目索引/团队.md` |
| 助理橙子 | `agent/开发助理/evolution/` | `agent/开发助理/项目索引/助理橙子.md` |
| 跨角色/团队 | `agent/团队/evolution/` | `agent/开发助理/项目索引/团队.md` |
@@ -113,16 +112,16 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
- 应升级的 Skill
2. **写入经验文件**(绝对路径):
- 路径:`e:\Gongsi\Mycontent\.cursor\agent\{角色目录}\evolution\YYYY-MM-DD.md`
- 路径:`.cursor\agent\{角色目录}\evolution\YYYY-MM-DD.md`
- 当天文件不存在则新建;文件名用日期,经验按天存储
3. **更新项目索引**(绝对路径):
- 路径:`e:\Gongsi\Mycontent\.cursor\agent\开发助理\项目索引\{索引名}.md`(见 5.1 映射)
- 路径:`.cursor\agent\开发助理\项目索引\{索引名}.md`(见 5.1 映射)
- 在「开发进度」表追加一行,**必须写日期**YYYY-MM-DD
- 更新文末「最后更新」为当前日期
4. **更新经验清单**
- 路径:`e:\Gongsi\Mycontent\.cursor\agent\开发助理\经验清单.md`
- 路径:`.cursor\agent\开发助理\经验清单.md`
- 在索引表追加一行
5. **升级 Skill**:根据经验类型,更新对应 `.cursor/skills/{skill}/SKILL.md`
@@ -158,10 +157,7 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
```
开发文档/
├── 1、需求/
│ ├── 索引.md # 主需求 = 日期最新的需求文件
│ ├── YYYY-MM-DD-需求.md # 需求文件按日期命名,以最新为主
│ └── 以界面定需求.md # 界面级需求基准
├── 1、需求/需求汇总.md # 需求清单、业务需求
├── 8、部署/ # 技术方案、部署说明
├── 10、项目管理/
│ ├── 项目落地推进表.md # 里程碑、永平落地
@@ -184,7 +180,7 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
**小橙**执行:
1. 提炼VIP 手动设置已完成;会员分润差异化待实现;好友优惠仅针对文章
2. 更新 `1、需求/YYYY-MM-DD-需求.md`(当日文件):新增「需求清单」行;同步后更新 `1、需求/索引.md`
2. 更新 `需求汇总.md`:新增「需求清单」行
3. 更新 `运营与变更.md`:第五部分追加近期讨论
4. 回复:已记录并更新开发文档,详见 xxx

View File

@@ -0,0 +1,159 @@
# 卡若创业派对运营技能包
> **打包日期**2026-03-20
> **项目**一场soul的创业实验-永平
> **技能包路径**`.cursor/skills/karuo-party/`
---
## 📦 打包内容
本技能包整合了卡若创业派对的 4 大核心运营技能:
1. **运营报表**Soul派对运营数据全自动写入飞书表格
2. **飞书视频文字下载**:从飞书妙记下载视频和文字
3. **视频切片**:视频转录、高光识别、批量切片、成片输出
4. **多平台分发**:一键分发到抖音/B站/视频号/小红书/快手
---
## 🔐 凭证管理
所有凭证统一存储在 `credentials/` 目录:
### 飞书凭证
- **`.feishu_tokens.json`**:飞书访问令牌(自动刷新)
### 视频平台 Cookies
- **视频号**`cookies/视频号_cookies.json`(有效期 ~24-48h
- **B站**`cookies/B站_cookies.json`(有效期 ~6个月
- **小红书**`cookies/小红书_cookies.json`(有效期 ~1-3天
- **快手**`cookies/快手_cookies.json`(有效期 ~7-30天
- **抖音**`cookies/抖音_cookies.json`(账号封禁中)
---
## 📁 目录结构
```
.cursor/skills/karuo-party/
├── SKILL.md # 主技能入口
├── README.md # 本文件
├── credentials/ # 凭证目录
│ ├── .feishu_tokens.json # 飞书Token
│ └── cookies/ # 平台Cookies
│ ├── 视频号_cookies.json
│ ├── B站_cookies.json
│ ├── 小红书_cookies.json
│ ├── 快手_cookies.json
│ └── 抖音_cookies.json
└── skills/ # 子技能文档
├── 运营报表_SKILL.md
├── 飞书视频文字下载_SKILL.md
├── 视频切片_SKILL.md
└── 多平台分发_SKILL.md
```
---
## 🚀 快速开始
### 1. 运营报表
```bash
FEISHU_SCRIPT="/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本"
cd "$FEISHU_SCRIPT"
python3 soul_party_to_feishu_sheet.py 115
```
### 2. 飞书视频文字下载
```bash
JIYAO_SCRIPT="/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/智能纪要/脚本"
python3 "$JIYAO_SCRIPT/feishu_minutes_export_github.py" "<妙记链接>"
python3 "$JIYAO_SCRIPT/feishu_minutes_download_video.py" "<妙记链接>"
```
### 3. 视频切片
```bash
VIDEO_SCRIPT="/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/视频切片/脚本"
conda activate mlx-whisper
python3 "$VIDEO_SCRIPT/soul_slice_pipeline.py" --video "<原视频.mp4>" --clips 8
```
### 4. 多平台分发
```bash
DIST_SCRIPT="/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本"
python3 "$DIST_SCRIPT/distribute_all.py" --video-dir "<成片目录>"
```
---
## 📝 使用说明
### 激活方式
当用户提到以下触发词时,自动激活本技能包:
- **运营报表**、**派对填表**、**派对截图**
- **飞书视频下载**、**妙记下载**、**飞书妙记**
- **视频剪辑**、**切片发布**、**视频切片**
- **多平台分发**、**一键分发**、**全平台发布**
- **卡若创业派对**、**派对运营**
### 执行流程
1. **读取对应子技能**:根据用户需求,读取 `skills/` 目录下对应的 SKILL.md
2. **检查凭证**:确认 `credentials/` 目录下相关凭证文件存在且有效
3. **执行命令**按子技能文档中的命令执行路径指向卡若AI原始脚本目录
4. **结果反馈**:执行完成后反馈结果
---
## ⚠️ 注意事项
1. **脚本路径**所有脚本仍在卡若AI原始目录本技能包仅提供技能文档和凭证管理
2. **凭证同步**:凭证更新后需手动复制到本技能包的 `credentials/` 目录
3. **跨平台兼容**macOS 用 `python3`Windows 用 `python`
---
## 🔄 凭证更新
### 飞书 Token
```bash
cd "/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本"
python3 auto_log.py
# 更新后,将 .feishu_tokens.json 复制到本技能包的 credentials/ 目录
```
### 平台 Cookies
各平台 Cookie 文件位于 `credentials/cookies/` 目录。更新方式:
- **视频号**:浏览器登录后,使用 `cookie_manager.py` 提取
- **B站**:使用 `bilibili-api-python` 自动获取
- **小红书/快手**Playwright 自动化登录后提取
---
## 📚 相关文档
- **主技能入口**`SKILL.md`
- **卡若创业派对项目**`/Users/karuo/Documents/个人/卡若AI/02_卡人/水岸_项目管理/卡若创业派对/README.md`
- **运营报表脚本**`/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本/`
- **视频切片脚本**`/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/视频切片/脚本/`
- **多平台分发脚本**`/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/`
---
## 📋 版本记录
| 版本 | 日期 | 说明 |
|:---|:---|:---|
| 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 |

View File

@@ -0,0 +1,281 @@
---
name: 卡若创业派对运营
description: >
卡若创业派对全链路运营技能包。包含运营报表、视频切片、多平台分发、飞书视频文字下载等核心能力。
所有凭证飞书TOKEN、各平台Cookies统一管理在 credentials/ 目录。
当用户提到 运营报表、视频切片、多平台分发、飞书视频下载、派对运营、卡若创业派对 时自动激活。
triggers: 运营报表、视频切片、多平台分发、飞书视频下载、派对运营、卡若创业派对、派对填表、视频剪辑、一键分发、妙记下载
owner: 水岸
group: 运营
version: "1.1"
updated: "2026-03-21"
---
# 卡若创业派对运营 Skill 包
> **项目定位**Soul 创业派对全链路——从派对结束到内容变现
> **技能包路径**`.cursor/skills/karuo-party/`
> **凭证目录**`credentials/`飞书TOKEN、各平台Cookies
---
## 零、交互默认(派对 AI
- **直接操作**:报表、下载、切片、分发、脚本、凭证刷新等,**不向用户反问**「要不要跑」;按本文档路径**直接执行**终端命令(与全局 `karuo-ai.mdc`「默认零提问」一致)。
- **缺凭证/Token**:先读 `credentials/` 与脚本内刷新逻辑;失败则**一句**说明缺哪份文件或环境变量,不展开选择题。
- **收尾**:技术类任务仍可在会话末跟卡若复盘五块(多根工作区以 `soul-karuo-dialogue.mdc` 为准)。
---
## 一、技能包组成
本技能包包含以下 4 个核心子技能:
| # | 技能名 | 文件路径 | 触发词 | 用途 |
|:--|:---|:---|:---|:---|
| ① | Soul派对运营报表 | `skills/运营报表_SKILL.md` | 运营报表、派对填表、派对截图 | 截图→飞书表格→发群 |
| ② | 飞书视频文字下载 | `skills/飞书视频文字下载_SKILL.md` | 妙记下载、飞书视频、飞书妙记 | 文字+视频→本地 |
| ③ | 视频切片 | `skills/视频切片_SKILL.md` | 视频剪辑、切片发布 | 原视频→转录→高光→成片 |
| ④ | 多平台分发 | `skills/多平台分发_SKILL.md` | 一键分发、全平台发布 | 成片→抖音/B站/视频号/小红书/快手 |
---
## 二、凭证管理
所有凭证统一存储在 `credentials/` 目录:
### 2.1 飞书凭证
| 文件 | 说明 | 用途 |
|:---|:---|:---|
| `.feishu_tokens.json` | 飞书访问令牌 | 运营报表、智能纪要、素材库 |
**Token 自动刷新**:所有脚本遇 401 自动用 refresh_token 刷新,无需手动。
### 2.2 视频平台 Cookies
| 平台 | Cookie 文件 | 有效期 | 状态 |
|:---|:---|:---|:---|
| 视频号 | `cookies/视频号_cookies.json` | ~24-48h | ✅ 可用 |
| B站 | `cookies/B站_cookies.json` | ~6个月 | ✅ 可用 |
| 小红书 | `cookies/小红书_cookies.json` | ~1-3天 | ✅ 可用 |
| 快手 | `cookies/快手_cookies.json` | ~7-30天 | ⚠️ 需检查 |
| 抖音 | `cookies/抖音_cookies.json` | ~2-4h | ❌ 账号封禁 |
**Cookie 管理**`cookie_manager.py` 统一管理自动迁移、API 预检、防重复登录。
---
## 三、快速使用
### 3.1 运营报表(派对结束后)
```bash
# 路径指向卡若AI原始脚本目录
FEISHU_SCRIPT="/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本"
cd "$FEISHU_SCRIPT"
python3 auto_log.py # 刷新Token首次或过期时
python3 soul_party_to_feishu_sheet.py 115 # 填表+发群
```
**详细流程**:见 `skills/运营报表_SKILL.md`
### 3.2 飞书视频文字下载
```bash
JIYAO_SCRIPT="/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/智能纪要/脚本"
# 导出文字
python3 "$JIYAO_SCRIPT/feishu_minutes_export_github.py" "<妙记链接>" -o "/Users/karuo/Documents/聊天记录/soul"
# 下载视频
python3 "$JIYAO_SCRIPT/feishu_minutes_download_video.py" "<妙记链接>" -o "/Users/karuo/Movies/soul视频/原视频"
```
**详细流程**:见 `skills/飞书视频文字下载_SKILL.md`
### 3.3 视频切片
```bash
VIDEO_SCRIPT="/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/视频切片/脚本"
eval "$(~/miniforge3/bin/conda shell.zsh hook)"
conda activate mlx-whisper
python3 "$VIDEO_SCRIPT/soul_slice_pipeline.py" --video "<原视频.mp4>" --clips 8 --two-folders
```
**详细流程**:见 `skills/视频切片_SKILL.md`
### 3.4 多平台分发
```bash
DIST_SCRIPT="/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本"
# 定时排期第1条立即后续 30-120min 随机间隔
python3 "$DIST_SCRIPT/distribute_all.py" --video-dir "<成片目录>"
# 立即全部发布
python3 "$DIST_SCRIPT/distribute_all.py" --now
```
**详细流程**:见 `skills/多平台分发_SKILL.md`
---
## 四、完整流程(派对结束后)
### Phase 1数据入库
1. **运营报表**:提取效果数据 → 填表 → 发群
2. **飞书妙记**:导出文字 → 下载视频
### Phase 2智能纪要
1. 提炼纪要 JSON
2. 生成纪要 HTML→PNG
3. 纪要图入报表
4. 纪要图发群
### Phase 3视频生产
1. **视频切片**:转录 → 高光识别 → 批量切片 → 增强
2. **多平台分发**:成片 → 5平台发布定时排期
### Phase 4文章内容
1. 写第9章文章
2. 上传小程序
3. 推送飞书群
**详细流程**见卡若AI项目 `02_卡人/水岸_项目管理/卡若创业派对/README.md`
---
## 五、凭证更新
### 5.1 飞书 Token 更新
```bash
cd "/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本"
python3 auto_log.py
```
更新后,将 `.feishu_tokens.json` 复制到本技能包的 `credentials/` 目录。
### 5.2 平台 Cookie 更新
各平台 Cookie 文件位于 `credentials/cookies/` 目录。更新方式:
1. **视频号**:浏览器登录后,使用 `cookie_manager.py` 提取
2. **B站**:使用 `bilibili-api-python` 自动获取
3. **小红书/快手**Playwright 自动化登录后提取
更新后Cookie 文件会自动同步到本技能包。
---
## 六、目录结构
```
.cursor/skills/karuo-party/
├── SKILL.md # 本文件(主入口)
├── README.md # 说明文档
├── credentials/ # 凭证目录
│ ├── .feishu_tokens.json # 飞书Token
│ └── cookies/ # 平台Cookies
│ ├── 视频号_cookies.json
│ ├── B站_cookies.json
│ ├── 小红书_cookies.json
│ ├── 快手_cookies.json
│ └── 抖音_cookies.json
└── skills/ # 子技能文件
├── 运营报表_SKILL.md
├── 飞书视频文字下载_SKILL.md
├── 视频切片_SKILL.md
└── 多平台分发_SKILL.md
```
---
## 七、使用说明
### 7.1 激活方式
当用户提到以下触发词时,自动激活本技能包:
- **运营报表**、**派对填表**、**派对截图**
- **飞书视频下载**、**妙记下载**、**飞书妙记**
- **视频剪辑**、**切片发布**、**视频切片**
- **多平台分发**、**一键分发**、**全平台发布**
- **卡若创业派对**、**派对运营**
### 7.2 执行流程
1. **读取对应子技能**:根据用户需求,读取 `skills/` 目录下对应的 SKILL.md
2. **检查凭证**:确认 `credentials/` 目录下相关凭证文件存在且有效
3. **执行命令**按子技能文档中的命令执行路径指向卡若AI原始脚本目录
4. **结果反馈**:执行完成后反馈结果
### 7.3 注意事项
- **脚本路径**所有脚本仍在卡若AI原始目录本技能包仅提供技能文档和凭证管理
- **凭证同步**:凭证更新后需手动复制到本技能包的 `credentials/` 目录
- **跨平台兼容**macOS 用 `python3`Windows 用 `python`
---
## 八、相关文档
- **卡若创业派对项目**`/Users/karuo/Documents/个人/卡若AI/02_卡人/水岸_项目管理/卡若创业派对/README.md`
- **运营报表脚本**`/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本/`
- **视频切片脚本**`/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/视频切片/脚本/`
- **多平台分发脚本**`/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本/`
---
## 九、闭环复盘发群(派对 AI · 强制)
当本次对话完成**一个可交付的闭环**(需求→实现→自检,或运营链路单条跑通)时,**必须在回复末尾附带完整卡若复盘块**,并**推送到飞书群机器人**。
### 9.1 复盘正文格式
严格按卡若AI《卡若复盘格式_固定规则》**`运营中枢/参考资料/卡若复盘格式_固定规则.md`**(五块 🎯📌💡📝▶,**复盘块内禁止表格**,标题带 **YYYY-MM-DD HH:mm****🎯 目标·结果·达成率** 整行 ≤30 字且含达成率百分比)。
### 9.2 飞书推送(机器人 Webhook v2
- **凭证**Webhook URL **不写死在仓库**;优先环境变量 **`FEISHU_PARTY_CLOSURE_WEBHOOK`**,或用户在对话中临时提供。
- **请求体**(必须带 `msg_type`,否则返回 `params error, msg_type need`
```json
{
"msg_type": "text",
"content": {
"text": "(与回复中复盘块一致的纯文本,可含换行)"
}
}
```
- **执行方式**(任选其一,由 Agent 直接执行,不向用户索要确认):
```bash
# 将下方 URL 换为 FEISHU_PARTY_CLOSURE_WEBHOOK 或用户提供的 hook
TEXT=$(python3 -c "import json,sys; print(json.dumps({'msg_type':'text','content':{'text':sys.argv[1]}}, ensure_ascii=False))" "$(cat /path/to/review.txt)")
curl -sS -X POST -H "Content-Type: application/json" -d "$TEXT" "$FEISHU_PARTY_CLOSURE_WEBHOOK"
```
或使用单行 heredoc 时注意对引号转义;**禁止**只 POST 空 JSON 或缺 `msg_type` 的 body。
### 9.3 触发边界
- **要发群**:开发闭环(如 soul-admin/soul-api 联调交付)、运营闭环(报表填表+发群)、或用户明确要求「复盘发群」。
- **可不发**:单句问答、未改代码的纯咨询(除非用户点名要发群)。
---
## 版本记录
| 版本 | 日期 | 说明 |
|:---|:---|:---|
| 1.1 | 2026-03-21 | 新增 §九 闭环复盘发群:卡若五块复盘 + 飞书 Webhook v2msg_type 必填) |
| 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 |

View File

@@ -0,0 +1,6 @@
# 本机凭证目录
将飞书 token、各平台 cookies 等**仅放在本机**,文件名与结构见上级 `karuo-party` 的 SKILL / README。
- 本目录已在仓库根 `.gitignore` 中忽略,**不要**把真实密钥提交到 Git。
- 若目录为空,按 `skills/karuo-party` 文档从模板复制并重命名即可。

View File

@@ -0,0 +1,170 @@
---
name: 多平台分发
description: >
一键将视频分发到 5 个平台抖音、B站、视频号、小红书、快手
API 优先策略:视频号纯 API、B站 bilibili-api-python、抖音纯 API。
支持定时排期第1条立即发后续 30-120 分钟随机间隔)、并行分发、去重、失败自动重试。
triggers: 多平台分发、一键分发、全平台发布、批量分发、视频分发
owner: 木叶
group: 木
version: "4.0"
updated: "2026-03-11"
---
# 多平台分发 Skillv4.0
> **核心原则**API 发布为主Playwright 为辅。确保确定性地分发到各平台。
> **v4.0 变更**:视频号已切换为纯 API、统一元数据生成器、定时排期优化、简介/标签/分区自动填充。
---
## 一、平台与实现方式
| 平台 | 实现方式 | 定时发布 | Cookie 有效期 | 120 场实测 |
|------|----------|----------|---------------|------------|
| **视频号** | **纯 API**DFS 上传 + post_create | API 原生支持 | ~24-48h | 12/12 成功 |
| **B站** | **bilibili-api-python** API 优先 → Playwright 兜底 | API `dtime` | ~6 个月 | 12/12 成功 |
| **小红书** | Playwright headless 自动化 | UI 定时(降级立即) | ~1-3 天 | 12/12 成功 |
| **快手** | Playwright headless 自动化 | UI 定时 | ~7-30 天 | Cookie 过期 |
| **抖音** | 纯 APIVOD + bd-ticket-guard | API `timing_ts` | ~2-4h | 账号封禁中 |
> **关于视频号官方 API 边界**
> 按《视频号与腾讯相关 API 整理》结论,微信官方目前**没有开放「短视频上传/发布」接口**;本 Skill 中的视频号发布能力,属于对 `https://channels.weixin.qq.com` 视频号助手网页协议的逆向封装DFS 上传 + `post_create`),仅在你本机使用,需自行承担协议变更与合规风险。
> 官方可控能力(直播记录、橱窗、留资、罗盘数据、本地生活等)的服务端 API 入口为:`https://developers.weixin.qq.com/doc/channels/api/`,如需做直播/橱窗/留资集成,可基于该文档在单独 Skill 中扩展。
---
## 二、一键命令
```bash
cd /Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/多平台分发/脚本
# 定时排期第1条立即后续 30-120min 随机间隔
python3 distribute_all.py
# 立即全部发布
python3 distribute_all.py --now
# 只发指定平台
python3 distribute_all.py --platforms 视频号 B站
# 自定义视频目录
python3 distribute_all.py --video-dir "/path/to/videos/"
# 检查 Cookie / 重试失败
python3 distribute_all.py --check
python3 distribute_all.py --retry
```
---
## 三、定时排期v4.0 优化)
### 3.1 排期规则
- **第 1 条**:立即发布(`first_delay=0`
- **第 2 条起**:前一条 + random(30, 120) 分钟
- 若总跨度 > 24h自动按比例压缩
- 12 条视频典型跨度 ~10-14h
### 3.2 各平台定时实现
| 平台 | 定时方式 | 参数 |
|------|----------|------|
| B站 | API `meta.dtime` | Unix 时间戳(秒) |
| 视频号 | API 暂不支持原生定时 | 描述中标注时间/手动设置 |
| 抖音 | API `timing_ts` | Unix 时间戳 |
| 快手 | Playwright UI | `schedule_helper.py` |
| 小红书 | Playwright UI | `schedule_helper.py` |
---
## 四、元数据自动生成v4.0 新增)
`video_metadata.py` 根据文件名自动生成各平台差异化内容:
```python
from video_metadata import VideoMeta
meta = VideoMeta.from_filename("AI最大的缺点是上下文太短.mp4")
meta.title("B站") # 优化后的标题
meta.description("B站") # 标题 + 标签 + 品牌标记
meta.tags_str("B站") # AI工具,效率提升,Soul派对,...
meta.bilibili_meta() # B站投稿完整 meta含 tid/tag/desc
meta.title_short() # 小红书短标题≤20字
meta.hashtags("视频号") # #AI工具 #效率提升 ... #小程序 卡若创业派对
```
### 4.1 内容结构
- **标题**:手工优化标题库优先,否则从文件名智能提取
- **简介**:标题 + 换行 + 话题标签 + `#小程序 卡若创业派对`
- **标签**基于关键词匹配AI/创业/副业/Soul 等 12 类)+ 通用标签
- **分区**B站 tid=160生活>日常)
- **风控过滤**`content_filter.py` 自动替换敏感词70+ 映射,严格/宽松分级)
---
## 五、商品链接/小黄车(调研结果)
| 平台 | 功能 | 实现方式 | 状态 |
|------|------|----------|------|
| B站 | 花火计划商品链接 | 需企业认证 + 品牌合作授权 | 需手动配置 |
| 视频号 | 挂小程序 | 视频号主页 > 设置 > 服务菜单 > 小程序 | 需手动配置 |
| 抖音 | 小黄车 | 需开通橱窗(粉丝 ≥1000 | 账号封禁 |
| 快手 | 商品卡片 | 需开通快手小店 | 需手动配置 |
| 小红书 | 商品笔记 | 需开通小红书店铺 | 需手动配置 |
**当前做法**:在描述中统一添加 `#小程序 卡若创业派对` 引导用户搜索。
---
## 六、Cookie 管理
`cookie_manager.py` 统一管理:
- 中央存储:`多平台分发/cookies/{平台}_cookies.json`
- 自动迁移:旧路径 → 中央存储(首次使用时)
- API 预检5 平台各自 auth API 校验有效性
- 防重复登录:有效 Cookie 不触发重新获取
---
## 七、去重机制
- 日志:`publish_log.json`JSON Lines
- 去重键:`(平台名, 视频文件名)`
- 双保险:调度器层 + 平台层
- `--no-dedup` 跳过,`--retry` 重跑失败
---
## 八、目录结构
```
木叶_视频内容/
├── 多平台分发/ ← 本 Skill调度器 + 共享工具)
│ ├── SKILL.md
│ └── 脚本/
│ ├── distribute_all.py # 主调度器 v4
│ ├── video_metadata.py # 统一元数据生成器v4 新增)
│ ├── schedule_generator.py # 定时排期v4: 第1条立即发
│ ├── schedule_helper.py # Playwright 定时 UI 辅助
│ ├── publish_result.py # 统一 PublishResult + 去重
│ ├── title_generator.py # 标题生成(被 video_metadata 取代)
│ ├── content_filter.py # 敏感词过滤70+ 映射)
│ ├── cookie_manager.py # Cookie 统一管理5 平台 API 预检)
│ ├── video_utils.py # 视频处理(封面、元数据)
│ └── publish_log.json # 发布日志
├── 抖音发布/ ← 纯 API账号封禁中
├── B站发布/ ← bilibili-api-python API
├── 视频号发布/ ← 纯 APIDFS 协议v5
├── 小红书发布/ ← Playwright headless
└── 快手发布/ ← Playwright headless
```
---
## 九、依赖
- Python 3.10+
- httpx, bilibili-api-python, playwright, Pillow
- ffmpeg/ffprobe系统已安装
- `playwright install chromium`

View File

@@ -0,0 +1,583 @@
---
name: 视频切片
description: Soul派对视频切片 + 快速混剪 + 切片动效包装(片头/片尾/程序化)+ 剪映思路借鉴(智能剪口播/镜头分割)。触发词含视频剪辑、切片发布、快速混剪、切片动效包装、程序化包装、片头片尾。
group: 木
triggers: 视频剪辑、切片发布、字幕烧录、**快速混剪、混剪预告、快剪串联、切片动效包装、程序化包装、片头片尾、批量封面、视频包装**、镜头切分、场景检测
owner: 木叶
version: "1.3"
updated: "2026-03-03"
---
# 视频切片
> **语言**:所有文档、字幕、封面文案统一使用**简体中文**。soul_enhance 自动繁转简。
> **Soul 视频输出**Soul 剪辑的成片统一导出到 `/Users/karuo/Movies/soul视频/最终版/`,原视频在 `原视频/`,中间产物在 `其他/`。
> **联动规则**:每次执行视频切片时,自动检查是否需要「切片动效包装」或「快速混剪」。若用户提到片头/片尾/程序化包装/批量封面,则联动调用 `切片动效包装/10秒视频` 模板渲染,再与切片合成。若用户提到快速混剪/混剪预告/快剪串联,则在切片或成片生成后再调用 `脚本/quick_montage.py` 输出一条节奏版预告。
## ⭐ Soul派对切片流程默认
```
原始视频 → MLX转录 → 字幕转简体 → 高光识别(API 优先/最佳模型,失败则 Ollama→规则) → 批量切片 → soul_enhance → 输出成片
↑ ↓
提取后立即繁转简+修正错误 封面+字幕(已简体)+加速10%+去语气词
```
**切片时长**:每段为**完整的一个片段**,时长 **30 秒300 秒**,由该完整片段起止时间决定。**标题**用一句**刺激性观点**(见 `Soul竖屏切片_SKILL.md`)。
**提问→回答 结构**若片段内有人提问前3秒优先展示**提问问题**,再播回答;高光识别填 `question``hook_3sec` 与之一致,成片整条去语助词。详见 `参考资料/视频结构_提问回答与高光.md``参考资料/高光识别提示词.md`
**Soul 竖屏专用**:抖音/首页用竖屏成片、完整参数与流程见 → **`Soul竖屏切片_SKILL.md`**(竖屏 498×1080、crop 参数、批量命令)。
### 最新切片风格(当前默认)
以后默认按这套风格出切片与成片:
| 项 | 当前默认风格 |
|------|------|
| **封面** | **Soul 绿 + 半透明质感 + 深色渐变** |
| **前3秒** | **优先提问→回答**,有提问时 Hook = `question` |
| **标题** | **一句刺激性观点**,文件名 = 封面标题 = `highlights.title` |
| **字幕** | 居中、白字黑描边、关键词亮金黄高亮 |
| **节奏** | 去语助词 + 整体加速 10% |
| **成片尺寸** | 竖屏 **498×1080** |
这套风格与 `参考资料/高光识别提示词.md``参考资料/热点切片_标准流程.md``Soul竖屏切片_SKILL.md` 保持一致。
### 一键命令Soul派对专用
#### 一体化流水线(推荐)
```bash
cd 03_卡木/木叶_视频内容/视频切片/脚本
conda activate mlx-whisper
python3 soul_slice_pipeline.py --video "/path/to/soul派对会议第57场.mp4" --clips 6
# 仅重新烧录(字幕转简体后重跑增强)
python3 soul_slice_pipeline.py -v "视频.mp4" -n 6 --skip-transcribe --skip-highlights --skip-clips
# 切片+成片后,额外生成一条快速混剪
python3 soul_slice_pipeline.py -v "视频.mp4" -n 8 --two-folders --quick-montage
```
流程:**转录 → 字幕转简体 → 高光识别 → 批量切片 → 增强**
#### 分步命令
```bash
# 1. 转录MLX Whisper约3分钟/2.5小时视频)
eval "$(~/miniforge3/bin/conda shell.zsh hook)"
conda activate mlx-whisper
mlx_whisper audio.wav --model mlx-community/whisper-small-mlx --language zh --output-format all
# 2. 高光识别API 优先,未配置则 Ollama → 规则;流水线会在读取 transcript 前自动转简体)
python3 identify_highlights.py -t transcript.srt -o highlights.json -n 6
# 需配置 OPENAI_API_KEY 或 OPENAI_API_BASES/KEYS/MODELS默认模型 gpt-4o
# 3. 切片
python3 batch_clip.py -i 视频.mp4 -l highlights.json -o clips/ -p soul
# 4. 增强处理(封面+字幕+加速soul_enhance
python3 soul_enhance.py -c clips/ -l highlights.json -t transcript.srt -o clips_enhanced/
```
### 快速混剪(新增)
适用场景:已经有 `切片/``成片/`,需要快速出一条 2040 秒节奏版预告、招商预热视频、短视频串联版。
**默认策略**
| 项 | 规则 |
|------|------|
| **顺序** | 优先按 `virality_score` / `rank` 排序;无分数时按序号 |
| **取样** | 每条默认截取 **4 秒**高密度片段 |
| **成片目录输入** | 自动跳过前 **2.6 秒**封面,避免混剪里全是封面 |
| **输出** | 统一分辨率、统一节奏后拼成一条 `快速混剪.mp4` |
```bash
# 从成片目录生成快速混剪(推荐)
python3 脚本/quick_montage.py \
-i "/path/to/成片" \
-o "/path/to/快速混剪.mp4" \
-l "/path/to/highlights.json" \
--source-kind final \
-n 8 \
-s 4
# 一体化流水线里直接附带生成
python3 脚本/soul_slice_pipeline.py \
-v "/path/to/原视频.mp4" \
--two-folders \
--quick-montage \
--montage-source finals \
--montage-max-clips 8 \
--montage-seconds 4
```
#### 按章节主题提取推荐第9章单场成片
以**章节 .md 正文**为来源提取核心主题,再在转录稿中匹配时间,不限于 5 分钟、片段数与章节结构一致。详见 `参考资料/主题片段提取规则.md`
```bash
# 从章节生成 highlights再走 batch_clip + soul_enhance
python3 chapter_themes_to_highlights.py -c "第112场.md" -t transcript.srt -o highlights_from_chapter.json
python3 batch_clip.py -i 视频.mp4 -l highlights_from_chapter.json -o clips/ -p soul112
python3 soul_enhance.py -c clips/ -l highlights_from_chapter.json -t transcript.srt -o clips_enhanced/
```
- **主题来源**:章节 .md 按 `---` 分块,每块一个主题;文件名由 batch_clip 按 `前缀_序号_标题` 生成(标题仅保留中文与安全字符)。
### Soul 竖屏成片(横版源 → 竖屏中段去白边)
**约定**:以后剪辑 Soul 视频,成片统一做「竖屏中段」裁剪:横版 1920×1080 只保留中间竖条并去掉左右白边,输出 498×1080 竖屏。
| 步骤 | 说明 |
|------|------|
| 源 | 横版 1920×1080soul_enhance 输出) |
| 1 | 取竖条 608×1080起点 **x=483**(相对画面左) |
| 2 | 裁掉左侧白边 60px、右侧白边 50px → 内容区宽 498 |
| 输出 | **498×1080** 竖屏,仅内容窗口 |
**FFmpeg 一条命令(固定参数):**
```bash
# 单文件。输入为 1920×1080 的 enhanced 成片
ffmpeg -y -i "输入_enhanced.mp4" -vf "crop=608:1080:483:0,crop=498:1080:60:0" -c:a copy "输出_竖屏中段.mp4"
```
**批量对某目录下所有 \*_enhanced.mp4 做竖屏中段:**
```bash
# 脚本目录下执行,或直接调用
python3 脚本/soul_vertical_crop.py --dir "/path/to/clips_enhanced" --suffix "_竖屏中段"
```
参数说明见:`参考资料/竖屏中段裁剪参数说明.md`
### 增强功能说明
| 功能 | 说明 |
|------|------|
| **封面贴片** | 前2.5秒 Hook苹方/思源黑体 |
| **字幕烧录** | 关键词加粗加大亮金黄突出,去语助词+去空格 |
| **加速10%** | 节奏更紧凑,适合短视频 |
### 时间预估
| 步骤 | 2.5小时视频 |
|------|------------|
| MLX转录 | 3分钟 |
| 切片10个 | 2分钟 |
| 增强处理 | 8分钟 |
| **总计** | **约13分钟** |
---
## AI 生成与 LTX 可选集成
在「已有录播 → 转录→高光→切片→成片」主流程外,可选用 **LTX**GitHub: Lightricks/LTX-Video、LTX-2、LTX-Desktop-MPS实现
| 能力 | 用途 |
|------|------|
| **Retake**LTX-2 / LTX Desktop | 对已有视频**某段时间**重生成,替换口误/补拍,再走成片流程 |
| **Text/Image/Audio to video** | AI 生成口播替代、片头片尾、插播片段,生成 mp4 后进 `切片/` 或成片流程 |
| **Video extension** | 片段前后自然延长,衔接切片 |
| **自动 Prompt 增强** | 高光/标题文案 → 更易被生成模型理解,便于 I2V/Retake |
**详细能力表与 API/本地/Desktop 接入**:见 `参考资料/LTX_能力与集成说明.md`
**Soul 竖屏场景**:见 `Soul竖屏切片_SKILL.md` 第九节「AI 生成与 LTX 可选集成」。
**约定**LTX 生成的片段统一经 soul_enhance封面+字幕+竖屏)输出,与录播成片一致。
---
## 📹 通用视频处理
一键处理视频:转录 → 字幕清洗 → 视频增强 → 烧录字幕 → **输出单个成片**
---
## ⚡ 一键命令
```bash
# 最简用法 - 输出: 视频名_带字幕.mp4
python3 /Users/karuo/Documents/个人/卡若AI/04_效率工具/视频切片/scripts/one_video.py -i "视频.mp4"
# 指定输出路径
python3 scripts/one_video.py -i "视频.mp4" -o "成片.mp4"
```
### 处理流程
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 提取音频 │──▶│MLX转录 │──▶│字幕清洗 │──▶│视频增强 │──▶│烧录字幕 │
│ (5秒) │ │(1-3分钟)│ │繁转简 │ │降噪美颜 │ │ │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘
┌────────────────┐
│ 单个带字幕成片 │
│ 可直接发布 │
└────────────────┘
```
### 时间预估
| 视频时长 | 处理时间 |
|---------|---------|
| 5分钟 | 1-2分钟 |
| 30分钟 | 5-8分钟 |
| 1小时 | 10-15分钟 |
---
## 🎯 自动优化项
脚本自动完成以下优化,无需手动操作:
| 优化项 | 说明 |
|--------|------|
| 繁转简 | 自动将繁体字幕转为简体 |
| 去语气词 | 删除"嗯"、"啊"、"那个"等 |
| 修正错误 | 自动修正常见转录错误 |
| 音频降噪 | FFT降噪+高低频过滤 |
| 画面美颜 | 亮度+饱和度微调 |
| 音量标准化 | 统一音量级别 |
---
## 📝 手动分步操作
如需更精细控制,可分步执行:
### 1. 转录
```bash
# 激活环境
eval "$(~/miniforge3/bin/conda shell.zsh hook)"
conda activate mlx-whisper
# 提取音频
ffmpeg -y -i "视频.mp4" -vn -ar 16000 -ac 1 audio.wav
# MLX Whisper转录
mlx_whisper audio.wav --model mlx-community/whisper-small-mlx --language zh --output-format srt
```
### 2. 字幕清洗
```bash
# 繁转简+修正错误
python3 scripts/fix_subtitles.py --input transcript.srt --output clean.srt
```
### 3. 视频增强
```bash
# 降噪+美颜
ffmpeg -y -i "视频.mp4" \
-vf "eq=brightness=0.05:saturation=1.1" \
-af "afftdn=nf=-25:nr=10:nt=w,highpass=f=80,lowpass=f=8000,volume=1.2" \
-c:v h264_videotoolbox -b:v 5M \
-c:a aac -b:a 128k \
enhanced.mp4
```
### 4. 烧录字幕
```bash
# Clean版推荐
python3 scripts/burn_subtitles_clean.py -i enhanced.mp4 -s clean.srt -o 成片.mp4
```
---
## 🎨 字幕样式
### 默认样式Clean版
| 元素 | 字号 | 颜色 | 效果 |
|------|------|------|------|
| 内容字幕 | 42px竖屏/ 36px横屏 | 白色 | 黑色描边,无阴影 |
| 关键词 | 同上 | 金黄色 | 自动高亮 |
### 关键词高亮列表
自动高亮的关键词(金黄色):
- 数字100万、30万、10万、5万、1万
- 概念私域、AI、自动化、矩阵、IP、获客、变现、转化
- 平台:抖音、公众号、微信、存客宝
---
## 🔊 音频处理参数
| 滤镜 | 作用 | 参数 |
|------|------|------|
| highpass | 去低频杂音 | f=80Hz |
| lowpass | 去高频噪音 | f=8000Hz |
| afftdn | FFT降噪 | nf=-25, nr=10 |
| volume | 音量调整 | 1.2倍 |
---
## 📁 脚本列表
| 脚本 | 功能 | 使用频率 |
|------|------|---------|
| **soul_slice_pipeline.py** | Soul 切片一体化流水线 | ⭐⭐⭐ 最常用 |
| **soul_enhance.py** | 封面+字幕(简体)+加速+去语气词 | ⭐⭐⭐ |
| **soul_vertical_crop.py** | Soul 竖屏中段批量裁剪横版→498×1080 去白边) | ⭐⭐⭐ |
| **kill_ffmpeg_when_clip_done.py** | 剪辑结束后自动关掉 ffmpeg监视剪映/PID 或立即杀) | ⭐ 按需 |
| **scene_detect_to_highlights.py** | 镜头/场景检测 → highlights.jsonPySceneDetect可接 batch_clip | ⭐⭐ |
| chapter_themes_to_highlights.py | 按章节 .md 主题提取片段本地模型→highlights.json | ⭐⭐⭐ |
| identify_highlights.py | 高光识别API 优先→Ollama→规则默认 gpt-4o | ⭐⭐ |
| batch_clip.py | 批量切片 | ⭐⭐ |
| one_video.py | 单视频一键成片 | ⭐⭐ |
| burn_subtitles_clean.py | 字幕烧录(无阴影) | ⭐ |
| fix_subtitles.py | 字幕清洗(繁转简) | ⭐ |
---
## 🛠 环境配置
### 已安装默认使用MLX Whisper
- **MLX Whisper**: `~/miniforge3/envs/mlx-whisper`**默认转录引擎**
- Apple Silicon优化比CPU Whisper快10倍+
- 2.5小时视频转录仅需3分钟
- **字体**: `03_卡木/木叶_视频内容/视频切片/fonts/`(优先)
- **字幕**: 统一简体中文soul_enhance 自动繁转简)
### 转录命令(默认)
```bash
# 激活MLX环境
eval "$(~/miniforge3/bin/conda shell.zsh hook)"
conda activate mlx-whisper
# MLX Whisper转录推荐
mlx_whisper audio.wav --model mlx-community/whisper-small-mlx --language zh --output-format all
```
### 高光识别模型API 优先)
高光识别默认使用**当前可用最佳模型**:优先走 **OpenAI 兼容 API**(见下),未配置或失败时再用本地 Ollama最后规则兜底。
- **单接口**`OPENAI_API_BASE``OPENAI_API_KEY``OPENAI_MODEL`(默认 `gpt-4o`)。
- **多接口故障切换**`OPENAI_API_BASES``OPENAI_API_KEYS``OPENAI_MODELS`(逗号分隔,按顺序尝试)。
- 不写死密钥,从环境变量读取;详见 `运营中枢/参考资料/卡若AI异常处理与红线.md` 与 API 稳定性规则。
### 依赖检查
```bash
# FFmpeg
ffmpeg -version
# MLX环境
eval "$(~/miniforge3/bin/conda shell.zsh hook)"
conda activate mlx-whisper
python -c "import mlx_whisper; print('OK')"
# Python库
pip3 list | grep -E "moviepy|Pillow|opencc|openai"
```
### 安装依赖
```bash
pip3 install --break-system-packages moviepy Pillow opencc-python-reimplemented
# 镜头切分可选PySceneDetect
pip3 install 'scenedetect[opencv]'
```
---
## 🔧 剪辑结束后自动关 ffmpeg
脚本 **soul_enhance**、**batch_clip**、**soul_slice_pipeline** 在退出时(含 Ctrl+C会自动结束本进程启动的 ffmpeg 子进程,避免剪辑结束后仍占用 CPU。
若使用 **剪映/VideoFusion** 等 GUI 剪辑,可先运行监视脚本,剪辑应用退出后自动杀 ffmpeg
```bash
# 先启动监视,再打开剪映;关掉剪映后会自动结束 ffmpeg
python3 脚本/kill_ffmpeg_when_clip_done.py --app VideoFusion
# 或监视指定 PID
python3 脚本/kill_ffmpeg_when_clip_done.py --pid 12345
# 仅立即杀掉当前所有 ffmpeg
python3 脚本/kill_ffmpeg_when_clip_done.py --kill-now
```
---
## ❓ 常见问题
### Q: 转录不准确?
A: 使用medium模型将脚本中的`whisper-small-mlx`改为`whisper-medium-mlx`
### Q: 字幕太小/太大?
A: 修改`one_video.py`第142行的`font_size`
### Q: 处理太慢?
A:
1. 视频已自动使用VideoToolbox GPU加速
2. 字幕默认限制80条以内
### Q: 输出文件太大?
A: 降低码率:将`-b:v 5M`改为`-b:v 3M`
---
## 🎬 切片动效包装(联动能力)
用 React 程序化生成片头/片尾/封面,与切片产出一键合成。**每次执行视频切片时,若用户提到片头/片尾/包装/批量封面,则联动本能力**。
### 联动规则(必守)
| 场景 | 是否联动 | 操作 |
|:---|:---|:---|
| 用户说片头/片尾/程序化包装/批量封面 | ✓ | 先执行切片 → 渲染动效模板 → 合成 |
| 默认 Soul 切片、单视频成片 | 可选 | 执行后提示可选用切片动效包装 |
### 10秒视频模板卡若AI 品牌)
路径:`视频切片/切片动效包装/10秒视频/`
| Composition | 说明 |
|:---|:---|
| Video10s | 简洁版:渐变 + 标题 + 副标题 |
| Video10sRich | 内容丰富版:粒子 + 极限环 + 流动线条 |
规格:竖屏 1080×192010 秒30fps。
### 一键命令(动效包装)
```bash
cd 03_卡木/木叶_视频内容/视频切片/切片动效包装/10秒视频
# 预览
npm run dev
# 渲染片头(简洁版)
npx remotion render src/index.ts Video10s /Users/karuo/Documents/卡若Ai的文件夹/导出/程序化视频/片头.mp4
# 渲染片头(丰富版)
npx remotion render src/index.ts Video10sRich /Users/karuo/Documents/卡若Ai的文件夹/导出/程序化视频/片头_丰富.mp4
```
### 与切片合成流程
```
切片产出(clips_enhanced/)
【联动】渲染片头/片尾
ffmpeg 合成:片头 + 切片 + 片尾
```
### 参考资料
- 速查:`视频切片/切片动效包装/参考资料/切片动效包装速查.md`
- 官方https://www.remotion.dev/docs
---
## 🎞 剪映思路借鉴与自实现(可选能力)
> 参考 **剪映专业版**`/Applications/VideoFusion-macOS.app`)内可读配置与流程,用开源方案自实现「智能剪口播」与「智能镜头分割」,不依赖剪映二进制。详见:`参考资料/剪映_智能剪口播与智能片段分割_逆向分析.md`。
### 智能剪口播(口播稿 → 按文案/时间轴切片段)
| 剪映逻辑 | 本技能对应实现 |
|----------|----------------|
| 语音→文字 + 时间戳 | **MLX Whisper** 转录 → `transcript.srt` |
| 按文案智能剪、口播稿↔时间轴对齐 | **高光识别**`identify_highlights` / `chapter_themes_to_highlights`)→ `highlights.json``batch_clip` |
| 前端配置键 | `script_ai_cut_config``transcript_options`(仅作对照,不读写剪映) |
**结论**:现有流程「转录 → 字幕转简 → 高光识别 → 批量切片 → soul_enhance」已覆盖「智能剪口播」能力按句/按段细切可与 `transcript.srt` 时间戳结合,在 `highlights.json` 中按句生成条目即可。
### 智能镜头分割(按镜头/场景切分)
剪映 **SceneEditDetection** 思路(仅借鉴思路与参数,算法用开源实现):
- **输入**:帧序列;剪映内部为 96×96 小图 + 数组缓冲。
- **算法思路**:图像特征 + 滑动窗口 + 后处理阈值 → 输出镜头边界。
- **剪映可读参数**`SceneEditDetection/config.json`
`sliding_window_size: 7``img_feat_dims: 128``post_process_threshold: 0.35`、backbone/predhead 模型名(内部用,不引用)。
**自实现方案**:使用 **PySceneDetect**ContentDetector/AdaptiveDetector按阈值与最小场景长度得到切点再转为与 `batch_clip` 兼容的 `highlights.json`
**一键:镜头检测 → highlights → 批量切片 → 增强**
```bash
cd 03_卡木/木叶_视频内容/视频切片/脚本
pip install 'scenedetect[opencv]' # 仅首次
# 镜头检测 → 生成 highlights.json
python3 scene_detect_to_highlights.py -i "原视频.mp4" -o "输出目录/highlights_from_scenes.json" -t 27 --min-scene-len 15
# 用生成的 highlights 做切片 + 增强(与现有流水线一致)
python3 batch_clip.py -i "原视频.mp4" -l "输出目录/highlights_from_scenes.json" -o "输出目录/clips/" -p scene
python3 soul_enhance.py -c "输出目录/clips/" -l "输出目录/highlights_from_scenes.json" -t "输出目录/transcript.srt" -o "输出目录/clips_enhanced/"
```
**参数速查**
| 参数 | 说明 | 建议 |
|------|------|------|
| `--threshold` / `-t` | 内容变化阈值,越大切点越少 | 27可试 2035 |
| `--min-scene-len` | 最小场景长度(帧) | 15 |
| `--min-duration` | 过滤短于 N 秒的片段 | 按需 |
| `--max-clips` / `-n` | 最多保留片段数 | 0=不限制 |
**与「高光切片」二选一**
- **高光切片**:按话题/金句/提问(需转录 + 高光识别),适合口播、访谈。
- **镜头切片**:按画面切换切分,适合多机位、快剪、无稿素材;可先跑 `scene_detect_to_highlights` 再走同一套 `batch_clip` + `soul_enhance`
### 参考资料(剪映与流程)
- **剪映逆向分析**`03_卡木/木叶_视频内容/视频切片/参考资料/剪映_智能剪口播与智能片段分割_逆向分析.md`
- 智能剪口播 H5 路径、智能片段分割 config 与参数、自实现建议与合规说明。
- **热点切片标准流程**`参考资料/热点切片_标准流程.md`(五步、两目录、命令速查)。
- **高光识别提示词**`参考资料/高光识别提示词.md`(提问→回答、节奏感、快速混剪优先片段规则)。
---
## 📊 输出示例
```
输入: 会议录像.mp4 (500MB, 30分钟)
输出: 会议录像_带字幕.mp4 (200MB)
- 中文字幕已烧录
- 音频已降噪
- 画面已优化
- 可直接发布抖音/视频号
```
---
## 🔗 工作目录
```
03_卡木/木叶_视频内容/视频切片/
├── 脚本/
│ ├── soul_slice_pipeline.py # ⭐ Soul 一体化
│ ├── soul_enhance.py # ⭐ 封面+字幕+加速
│ ├── scene_detect_to_highlights.py # 镜头检测→highlights剪映思路自实现
│ ├── one_video.py # 单视频成片
│ └── ...
├── 参考资料/
│ ├── 剪映_智能剪口播与智能片段分割_逆向分析.md # 剪映思路与参数参考
│ ├── 热点切片_标准流程.md
│ └── 竖屏中段裁剪参数说明.md
├── 切片动效包装/ # 联动能力:片头/片尾/程序化
│ ├── 10秒视频/ # React 程序化模板
│ └── 参考资料/切片动效包装速查.md
├── fonts/
└── SKILL.md
```

View File

@@ -0,0 +1,522 @@
---
name: Soul派对运营报表
description: Soul 派对运营数据全自动写入飞书表格(按月份选 2月/3月 标签)→ 会议纪要图片入表 → 发飞书群(数据+纪要图);与智能纪要联动,一站式可执行。含 Token 自动刷新、写入校验、小程序数据、派对录屏链接。完整流程可复制执行,支持基因胶囊打包。
triggers: 运营报表、派对填表、派对截图填表发群、会议纪要上传、本月运营数据、全部月份统计、派对纪要、智能纪要、106场、107场、113场、114场、115场
parent: 飞书管理
owner: 水桥
group: 水
version: "3.0"
updated: "2026-03-04"
---
# Soul 派对运营报表 · 基因胶囊
> **一句话**:派对截图 + TXT → 飞书运营报表(按月份选表)→ 填数据 + 填纪要图 + 派对录屏链接 + 发群(文字 + 图片),与**会议纪要**联动,完整流程可复制执行,可打包为基因胶囊。
---
## 零、完整流程提取(可复制执行)
以下为从「派对结束」到「报表+群消息+纪要图」全链路的**逐步清单**与**一键命令**,便于 AI 或人工按序执行。
### 0.1 流程图
```mermaid
flowchart LR
subgraph 输入
A1[关闭页截图] --> A2[小助手弹窗]
A2 --> A3[派对 TXT]
A3 --> A4[飞书妙记链接]
end
subgraph 步骤
B1[1. 注册场次+填数据] --> B2[2. 发群文字]
B2 --> B3[3. 生成纪要图]
B3 --> B4[4. 纪要图入表]
B4 --> B5[5. 纪要图发群]
end
subgraph 输出
C1[飞书运营报表]
C2[飞书群消息]
end
A1 --> B1
B1 --> C1
B2 --> C2
B4 --> C1
B5 --> C2
```
### 0.2 前置条件
| 项 | 说明 |
|:---|:---|
| Python 3 + requests | `pip3 install requests` |
| 飞书 Token | 脚本目录下 `.feishu_tokens.json`,过期时运行 `python3 auto_log.py` |
| 场次已注册 | 在 `soul_party_to_feishu_sheet.py` 中已添加 ROWS、SESSION_DATE_COLUMN、SESSION_MONTH、PARTY_VIDEO_LINKS可选、MINIPROGRAM_EXTRA / MINIPROGRAM_EXTRA_3可选 |
| 派对 TXT | 如 `soul 派对 115场 20260304.txt`,用于纪要文本/纪要图 |
### 0.3 逐步命令(以 115 场为例)
| 步 | 动作 | 输入 | 命令 | 输出/校验 |
|:---|:---|:---|:---|:---|
| 1 | 填效果数据+小程序+派对录屏+发群 | 场次号 115 | `cd 飞书管理/脚本 && python3 soul_party_to_feishu_sheet.py 115` | 控制台见「已写入」「已同步推送到飞书群」「已写入派对录屏链接」 |
| 2 | 纪要文本入表(可选) | TXT 路径、日期列 4 | `python3 write_party_minutes_from_txt.py "/path/to/soul 派对 115场 20260304.txt" 4` | 控制台见「已写入派对智能纪要到今日总结」 |
| 3 | 生成纪要图 | 见智能纪要 Skill | JSON→HTML→截图输出到 `卡若Ai的文件夹/报告/soul_115场_智能纪要_20260304.png` | 得到 PNG 文件 |
| 4 | 纪要图入表 | PNG 路径、sheet-id、date-col | `python3 feishu_write_minutes_to_sheet.py --party-image "卡若Ai的文件夹/报告/soul_115场_智能纪要_20260304.png" --sheet-id bJR5sA --date-col 4` | 控制台见「已上传派对智能纪要图片」 |
| 5 | 纪要图发群 | PNG 路径 | `cd 智能纪要/脚本 && python3 send_to_feishu.py --image "卡若Ai的文件夹/报告/soul_115场_智能纪要_20260304.png"` | 飞书群收到长图 |
**路径约定**:飞书管理脚本目录 = `02_卡人/水桥_平台对接/飞书管理/脚本/`;智能纪要脚本 = `02_卡人/水桥_平台对接/智能纪要/脚本/`;报告输出 = `卡若Ai的文件夹/报告/`
### 0.4 一键顺序命令块(复制即用)
```bash
# 假设已配置 115 场且 TXT 与报告路径如下,按顺序执行
FEISHU_SCRIPT="/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本"
JIYAO_SCRIPT="/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/智能纪要/脚本"
REPORT="/Users/karuo/Documents/卡若Ai的文件夹/报告"
TXT="/Users/karuo/Documents/聊天记录/soul/soul 派对 115场 20260304.txt"
cd "$FEISHU_SCRIPT"
python3 auto_log.py
python3 soul_party_to_feishu_sheet.py 115
python3 write_party_minutes_from_txt.py "$TXT" 4
# 纪要图需先按智能纪要 Skill 生成 HTML 再截图得到 PNG再执行
# python3 feishu_write_minutes_to_sheet.py --party-image "$REPORT/soul_115场_智能纪要_20260304.png" --sheet-id bJR5sA --date-col 4
# cd "$JIYAO_SCRIPT" && python3 send_to_feishu.py --image "$REPORT/soul_115场_智能纪要_20260304.png"
```
### 0.5 新场次从零到完成清单
1. **在 `soul_party_to_feishu_sheet.py` 中**:添加 `ROWS['116']``SESSION_DATE_COLUMN['116']``SESSION_MONTH['116']`,以及在 `_maybe_send_group``date_label``src_date` 中加 `'116'`;若需派对录屏则填 `PARTY_VIDEO_LINKS['116']`;若需小程序则填 `MINIPROGRAM_EXTRA_3['5']`3 月 5 日)。
2. **执行填表**`python3 soul_party_to_feishu_sheet.py 116`
3. **可选**:纪要文本 `write_party_minutes_from_txt.py "<txt>" 5`;纪要图按智能纪要生成后 `feishu_write_minutes_to_sheet.py --party-image <png> --sheet-id bJR5sA --date-col 5`,再 `send_to_feishu.py --image <png>`
### 0.6 故障排查速查
| 现象 | 处理 |
|:---|:---|
| 未找到日期列 | 先 `python3 auto_log.py` 再重试;确认 SESSION_DATE_COLUMN、SESSION_MONTH 与表头一致 |
| 90202 wrong range | 单格写入时 range 写成 `E29:E29` 形式 |
| 派对录屏未写入 | 检查 PARTY_VIDEO_LINKS 是否非空且格式为完整 URL |
| 小程序数据未写入 | 3 月用 MINIPROGRAM_EXTRA_3键为当月「日期号」如 '4' |
| 飞书群未收到 | 检查 Webhook、机器人是否启用 |
---
## 一站式完整流程(填数据 → 填图片 → 发群)
**目标**:同一场派对做完「填运营报表数据 → 把会议纪要图片填进报表 → 把纪要图发到飞书群」,顺序执行、流程清晰。
| 步骤 | 动作 | 命令 / 说明 |
|:---|:---|:---|
| **1** | **填数据 + 发群(文字)** | `cd 飞书管理/脚本`<br/>`python3 soul_party_to_feishu_sheet.py 115`<br/>→ 效果数据写入当月表对应日期列,并**自动推送竖状文字到飞书群**(含报表链接) |
| **2** | **生成会议纪要图** | 按 **智能纪要 Skill**`02_卡人/水桥_平台对接/智能纪要/SKILL.md`txt → JSON → HTML → 截图 PNG输出到 `卡若Ai的文件夹/报告/` |
| **3** | **填图片到报表** | `cd 飞书管理/脚本`<br/>`python3 feishu_write_minutes_to_sheet.py --party-image "<报告路径>/soul_115场_智能纪要_20260304.png" --sheet-id bJR5sA --date-col 4`<br/>→ 纪要图写入运营报表「今日总结」对应列3 月 115 场 = 第 4 列) |
| **4** | **把纪要图发到飞书群** | `cd 智能纪要/脚本`<br/>`python3 send_to_feishu.py --image "<报告路径>/soul_115场_智能纪要_20260304.png"`<br/>→ 默认 Webhook 为**运营报表同一飞书群**,群内会收到纪要长图 |
**执行顺序**1 → 2 → 3 → 4即可完成「数据入表 + 纪要图入表 + 群内先收文字再收纪要图」。
- **2 月场次**:步骤 3 不传 `--sheet-id`/`--date-col` 时,默认写 2 月表 19/20 列;步骤 4 不变。
- **同群**:运营报表发群与纪要图发群使用同一 Webhook见 1.3),群内先看到场次数据,再看到纪要图。
---
## 快速开始30 秒上手)
```bash
# ❶ 安装依赖(一次性)
pip3 install requests
# ❷ 刷新飞书 Token每天首次或 Token 过期时)
cd 飞书管理/脚本 && python3 auto_log.py
# ❸ 写入派对效果数据(自动选 2月/3月 工作表 + 发群)
python3 soul_party_to_feishu_sheet.py 115
# ❹ 生成派对智能纪要文本并写入「今日总结」(可选,与纪要图二选一或都做)
python3 write_party_minutes_from_txt.py "/path/to/soul 派对 115场 20260304.txt" 4
# ❺ 批量写入小程序数据(可选)
python3 write_miniprogram_batch.py
```
所有脚本路径:飞书管理相关在 `飞书管理/脚本/`,纪要生成与发图在 `智能纪要/脚本/`。**3 月场次**会自动写入 3 月工作表标签,不会误写到 2 月。
---
## 一、完整配置清单
### 1.1 飞书应用
| 项目 | 值 |
|:---|:---|
| App ID | `cli_a48818290ef8100d` |
| App Secret | `dhjU0qWd5AzicGWTf4cTqhCWJOrnuCk4` |
| 授权回调 | `http://localhost:5050/api/auth/callback` |
| 权限 | `wiki:wiki` `docx:document` `drive:drive` |
### 1.2 运营报表(飞书电子表格)
| 项目 | 值 |
|:---|:---|
| 表格链接2月 | https://cunkebao.feishu.cn/wiki/wikcnIgAGSNHo0t36idHJ668Gfd?sheet=7A3Cy9 |
| 表格链接3月 | https://cunkebao.feishu.cn/wiki/wikcnIgAGSNHo0t36idHJ668Gfd?sheet=bJR5sA |
| spreadsheet_token | `wikcnIgAGSNHo0t36idHJ668Gfd` |
| 2 月 sheet_id | `7A3Cy9` |
| 3 月 sheet_id | `bJR5sA` |
| 表格结构 | A 列=指标名,第 1 行=日期1、2…第 2 行可含「113场」「114场」等第 3~12 行=效果数据,第 15 行=小程序访问,第 28 行=今日总结,第 29 行=派对录屏(飞书妙记链接) |
| 月份选择 | 脚本按 `SESSION_MONTH` 自动选 2 月或 3 月工作表,避免串月 |
### 1.3 飞书群 Webhook报表数据 + 纪要图同群)
| 项目 | 值 |
|:---|:---|
| Webhook URL | `https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-5b9b-4abb-a05a-96c8fb9599f1` |
| 用途 | ① 填表后自动推送竖状格式消息(场次数据+报表链接);② 会议纪要图片发群(智能纪要 `send_to_feishu.py --image` 默认即此 Webhook |
### 1.4 Token 管理
| 项目 | 说明 |
|:---|:---|
| Token 文件 | 脚本同目录 `.feishu_tokens.json` |
| 含字段 | `access_token``refresh_token``auth_time` |
| 自动刷新 | 所有脚本遇 401 自动用 refresh_token 刷新,无需手动 |
| 手动刷新 | `python3 auto_log.py` (静默刷新,不需浏览器) |
---
## 二、脚本清单与用途
### 2.1 核心脚本(日常使用)
| 脚本 | 功能 | 命令 |
|:---|:---|:---|
| `soul_party_to_feishu_sheet.py` | 按场次写入效果数据到**当月工作表**对应日期列 + 飞书群推送2 月/3 月自动选标签) | `python3 soul_party_to_feishu_sheet.py 115` |
| `write_party_minutes_from_txt.py` | 从 TXT 生成智能纪要**文本**写入「今日总结」行(需指定日期列号) | `python3 write_party_minutes_from_txt.py "<txt路径>" 4` |
| `auto_log.py` | Token 刷新 + 飞书日志写入 | `python3 auto_log.py` |
### 2.2 辅助脚本
| 脚本 | 功能 | 命令 |
|:---|:---|:---|
| `feishu_write_minutes_to_sheet.py` | 会议纪要/派对总结**图片**上传到「今日总结」对应日期列(默认 2 月 19/20 日列3 月某场需指定 sheet 与日期列;**发群**需另执行智能纪要 `send_to_feishu.py --image` | `python3 feishu_write_minutes_to_sheet.py [内部图] [派对图]`<br/>3 月 115 场:`--party-image <png路径> --sheet-id bJR5sA --date-col 4` |
| `feishu_sheet_monthly_stats.py` | 月度运营数据统计 | `python3 feishu_sheet_monthly_stats.py 2``all` |
| `write_miniprogram_to_sheet.py` | **单日**写入小程序三核心数据(访问次数、访客、交易金额) | `python3 write_miniprogram_to_sheet.py 23 55 55 0` |
| `write_miniprogram_batch.py` | **批量**将 `MINIPROGRAM_EXTRA` 中所有日期的小程序数据写入报表 | `python3 write_miniprogram_batch.py` |
### 2.3 派对录屏链接(自动写入)
填表时若在 `soul_party_to_feishu_sheet.py` 中配置了 `PARTY_VIDEO_LINKS[场次]`(飞书妙记完整 URL会**自动**写入「派对录屏」行对应列(如 115 场 → E29。新场次需在脚本中补全链接后重新执行该场次填表。
### 2.4 小程序运营数据(自动写入)
每日填表时,若在 `soul_party_to_feishu_sheet.py` 中配置了 **2 月** `MINIPROGRAM_EXTRA`**3 月** `MINIPROGRAM_EXTRA_3`,会**自动**把当日小程序三核心数据写入对应日期列。数据需从 **Soul 小程序 / 微信公众平台 → 小程序 → 统计 → 实时访问、概况** 获取后填入配置:
| 指标 | 数据来源 | 行A 列关键词) |
|:---|:---|:---|
| 访问次数 | 微信公众平台 → 小程序 → 统计 → 实时访问 | 小程序访问 |
| 访客 | 同上 | 访客 |
| 交易金额 | 同上 | 交易金额 |
**配置方式**(在 `soul_party_to_feishu_sheet.py` 中):
```python
# 派对录屏(飞书妙记链接),写入「派对录屏」行
PARTY_VIDEO_LINKS = {
'115': 'https://cunkebao.feishu.cn/minutes/obcnxxxx...', # 从飞书妙记复制
}
# 2 月小程序数据
MINIPROGRAM_EXTRA = {
'23': {'访问次数': 55, '访客': 55, '交易金额': 0}, # 2月23日
}
# 3 月小程序数据113/114/115 场填表时自动写 3 月表)
MINIPROGRAM_EXTRA_3 = {
'4': {'访问次数': 60, '访客': 60, '交易金额': 0}, # 3月4日 115场从 Soul 小程序后台获取后填入
}
```
- 数据来源Soul 小程序 / 微信公众平台 → 小程序 → 统计,每日手动查看后填入
- 填派对表时自动带出:运行 `soul_party_to_feishu_sheet.py` 某场时2 月用 `MINIPROGRAM_EXTRA`、3 月用 `MINIPROGRAM_EXTRA_3` 同列写入小程序三项,并若有 `PARTY_VIDEO_LINKS` 则写入派对录屏行
- 单日写入(仅 2 月表):`python3 write_miniprogram_to_sheet.py 23 55 55 0`(日期列号 访问次数 访客 交易金额)
- 历史补全:在 `MINIPROGRAM_EXTRA` 中配齐多日数据后执行 `python3 write_miniprogram_batch.py`
---
## 三、完整操作流程
**整体顺序**:先执行 **[ 一站式完整流程 ]**(本文件开头)中的 ① 填数据发群 → ② 生成纪要图 → ③ 填图片到报表 → ④ 纪要图发群,再按需做小程序或纪要文本。
### 3.1 每日派对结束后操作(当前流程)
```
输入:派对关闭页截图 + 小助手弹窗截图 + TXT 聊天记录(+ 可选:小程序当日数据)
输出:飞书运营报表(当月标签)写入 + 飞书群推送(文字) + 纪要图入表 + 纪要图发群(与会议纪要联动)
```
**月份与工作表**:脚本根据 `SESSION_MONTH` 自动选择 2 月或 3 月工作表3 月场次(如 113、114、115写入 3 月标签,不会写入 2 月。
#### Step 1提取数据从截图
从派对关闭页和小助手弹窗提取 10 项数据:
| 序号 | 指标 | 来源 |
|:---|:---|:---|
| 1 | 主题 | TXT 提炼 ≤12 字 |
| 2 | 时长(分钟) | 关闭页「派对时长」 |
| 3 | Soul推流人数 | 关闭页「本场获得额外曝光」 |
| 4 | 进房人数 | 关闭页「派对成员」或小助手「进房人数」 |
| 5 | 人均时长 | 小助手「人均时长」 |
| 6 | 互动数量 | 小助手「互动数量」 |
| 7 | 礼物 | 关闭页「本场收到礼物」 |
| 8 | 灵魂力 | 关闭页「收获灵魂力」 |
| 9 | 增加关注 | 关闭页「新增粉丝」或小助手「增加关注」 |
| 10 | 最高在线 | 关闭页「最高在线」 |
#### Step 2在脚本中注册新场次
打开 `soul_party_to_feishu_sheet.py`,在 `ROWS` 字典中添加:
```python
# 格式:'场次号': [主题, 时长, 推流, 进房, 人均, 互动, 礼物, 灵魂力, 关注, 最高在线]
'107': ['主题关键词 ≤12字', 140, 35000, 400, 8, 90, 3, 25, 10, 45],
```
`SESSION_DATE_COLUMN``SESSION_MONTH` 中添加映射(**按月份选工作表标签**3 月填 3 月表):
```python
SESSION_DATE_COLUMN = {'105': '20', '106': '21', '107': '23', '113': '2', '114': '3', '115': '4'}
SESSION_MONTH = {'105': 2, '106': 2, '107': 2, '113': 3, '114': 3, '115': 3}
```
并在 `_maybe_send_group``date_label``src_date` 中为该场次加上对应「X月X日」和 TXT 日期(如 `'115': '3月4日'``'115': '20260304'`)。
#### Step 3执行写入 + 校验
```bash
# 写入效果数据(自动选 2月/3月 表 + 校验 + 发群)
python3 soul_party_to_feishu_sheet.py 115
# 生成智能纪要文本并写入「今日总结」(日期列号 = 当月几号,如 3月4日 填 4
python3 write_party_minutes_from_txt.py "/path/to/soul 派对 115场 20260304.txt" 4
```
成功输出示例3 月场次):
```
✅ 已选 3月 工作表sheet_id=bJR5sA
✅ 已写入飞书表格115场 效果数据(竖列 E3:E12共10格校验通过
✅ 已同步推送到飞书群(竖状格式)
✅ 已写入派对智能纪要到「今日总结」→ 2月4日列校验通过
```
若需将**智能纪要图片**放入「今日总结」并**发到飞书群**:见下节「智能纪要图片上传到报表 + 发群」。
---
## 3.2 智能纪要图片上传到报表 + 发群(十步清单)
**智能纪要 Skill**`02_卡人/水桥_平台对接/智能纪要/SKILL.md`)联动:纪要图写入运营报表「今日总结」对应列,并**发到运营报表同一飞书群**。
| 序号 | 步骤 | 说明 |
|:---|:---|:---|
| 1 | 准备派对 txt | 如 `soul 派对 115场 20260304.txt`(聊天记录/soul |
| 2 | 智能提炼 JSON | 按智能纪要规范从 txt 提炼分享人、重点片段、干货、行动项,生成 `xxx_meeting.json` |
| 3 | 生成 HTML | `智能纪要/脚本/generate_meeting.py --input xxx_meeting.json --output "卡若Ai的文件夹/报告/soul_115场_智能纪要_20260304.html"` |
| 4 | 导出目录 | HTML/PNG 一律导出到 `卡若Ai的文件夹/报告/`,不落在 Skill 内 |
| 5 | 截图 PNG | `智能纪要/脚本/screenshot.py "<报告路径>.html" --output "<报告路径>.png"` |
| 6 | 确认场次与月份 | 115 场 → 3 月表、日期列 42 月场次用默认 19/20 列 |
| 7 | 上传到报表 | `飞书管理/脚本/feishu_write_minutes_to_sheet.py --party-image "<报告路径>.png" --sheet-id bJR5sA --date-col 4`3 月) |
| 8 | **纪要图发群** | `智能纪要/脚本/send_to_feishu.py --image "<报告路径>.png"`(默认 Webhook = 运营报表群,群内收到纪要长图) |
| 9 | 2 月表 | 不指定时默认 `SHEET_ID=7A3Cy9`派对图→19 列、内部会议→20 列;发群命令同上 |
| 10 | 协作 | 纪要内容与样式以智能纪要 Skill 为准;本 Skill 负责写入报表、Token 及与发群流程衔接 |
**3 月场次参数速查**`--sheet-id bJR5sA``--date-col` = 当月日期(如 4 日填 `4`)。纪要生成与截图命令详见智能纪要 Skill「智能纪要图片上传到运营报表」小节。
---
## 四、写入校验机制
所有写入操作均含**写后读回校验**
| 场景 | 校验方式 |
|:---|:---|
| 效果数据写入 | 写入后读回首格(主题),比对一致才算成功 |
| 智能纪要写入 | 写入后读回单元格内容,检查字数 > 0 |
| Token 过期 | 自动刷新后重试,不降级为追加行(避免写入错误位置) |
| 日期列未找到 | 直接报错退出,不降级为追加行 |
校验未通过时脚本会打印具体差异信息,方便排查。
---
## 五、飞书群推送格式
**两类推送(同一飞书群)**:① 填表后自动推送竖状文字(场次数据+报表链接);② 纪要图发群需执行智能纪要 `send_to_feishu.py --image <png路径>`,默认即本群 Webhook。一站式顺序见文首「一站式完整流程」。
写入成功后自动发送到飞书群竖状格式每行一项。链接按当月工作表变化3 月场次会带 `sheet=bJR5sA`
```
【Soul 派对运营报表】
链接https://cunkebao.feishu.cn/wiki/wikcnIgAGSNHo0t36idHJ668Gfd?sheet=bJR5sA
115场3月4日已登记
主题:破产两次 家庭先于事业
时长分钟156
Soul推流人数36974
进房人数484
人均时长分钟8
互动数量82
礼物1
灵魂力3
增加关注15
最高在线56
数据来源soul 派对 115场 20260304.txt
```
---
## 六、智能纪要生成规则
**文本纪要**`write_party_minutes_from_txt.py` 从派对 TXT 自动提炼结构化纪要:
| 板块 | 内容 |
|:---|:---|
| 关键词 | 从 TXT 头部 `关键词:` 行提取 |
| 一、核心内容 | 按关键词匹配提取退伍军人、AI切入、私域、编导对赌、项目切割等 |
| 二、金句 | 从对话中提炼可操作的建议 |
| 三、下一步 | 行动建议(联系管理、搜索培训等) |
纪要**文本**写入运营报表「今日总结」行、对应日期列(需传入日期列号,如 3 月 4 日传 `4`)。**纪要图片**上传到同一格并**发群**:见 **§3.2 智能纪要图片上传到报表 + 发群**3 月用 `--party-image --sheet-id bJR5sA --date-col <日>`,发群用 `智能纪要/脚本/send_to_feishu.py --image <png>`
---
## 七、跨平台兼容
### macOS推荐
```bash
# 安装依赖
pip3 install requests
# 所有命令直接用 python3
python3 soul_party_to_feishu_sheet.py 106
```
### Windows
```cmd
# 安装依赖
pip install requests
# Windows 用 python不是 python3
python soul_party_to_feishu_sheet.py 106
python write_party_minutes_from_txt.py "C:\Downloads\soul 派对 106场 20260221.txt" 21
python auto_log.py
```
### 路径差异
| 项目 | macOS | Windows |
|:---|:---|:---|
| 脚本目录 | `/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/飞书管理/脚本/` | `C:\Users\用户名\卡若AI\02_卡人\水桥_平台对接\飞书管理\脚本\` |
| Python | `python3` | `python` |
| TXT 路径 | `/Users/karuo/Downloads/xxx.txt` | `C:\Users\用户名\Downloads\xxx.txt` |
| Token 文件 | 脚本同目录 `.feishu_tokens.json`(两个平台一致) | 同左 |
### 环境变量覆盖(可选)
所有配置项均可通过环境变量覆盖,无需改脚本:
```bash
export FEISHU_SPREADSHEET_TOKEN=wikcnIgAGSNHo0t36idHJ668Gfd
export FEISHU_SHEET_ID=7A3Cy9
export FEISHU_GROUP_WEBHOOK=https://open.feishu.cn/open-apis/bot/v2/hook/34b762fc-...
export FEISHU_APP_ID=cli_a48818290ef8100d
export FEISHU_APP_SECRET=dhjU0qWd5AzicGWTf4cTqhCWJOrnuCk4
```
---
## 八、常见问题
| 问题 | 解决 |
|:---|:---|
| `❌ 无法获取飞书 Token` | 运行 `python3 auto_log.py` 刷新 Token |
| `❌ 未找到日期列` | Token 过期导致读表失败,先 `python3 auto_log.py` 再重试 |
| `⚠️ 飞书群推送失败` | 检查 Webhook URL 是否有效、群机器人是否被禁用 |
| `❌ 写入失败 401` | Token 过期,脚本会自动刷新并重试;若仍失败则 `python3 auto_log.py` |
| Windows 中文路径乱码 | 确保终端编码为 UTF-8`chcp 65001` |
| `pip3 not found` | Windows 用 `pip`macOS 可能需 `pip3``python3 -m pip` |
---
## 九、表格结构参考
2 月表sheet=7A3Cy9、3 月表sheet=bJR5sA结构一致
```
表头第1行 [空] | 3月 | 1 | 2 | 3 | 4 | 5 | 6 | ...
第2行 一、效果数据 | | 113场 | 114场 | 115场 | 116场 | ...
第3行 主题 | | xx | xx | xx | |
第4行 时长 | | xx | xx | xx | |
...
第12行 最高在线 | | xx | xx | xx | |
...
第15行 小程序访问| | xx | xx | xx | | ← 访问次数、访客、交易金额
...
第28行 今日总结 | | xx | xx | xx | | ← 智能纪要(文本或图片)
```
`SESSION_DATE_COLUMN``SESSION_MONTH` 决定写入哪一列、哪张表。
---
## 十、新增场次模板
每次新增场次,在 `soul_party_to_feishu_sheet.py` 中改以下几处:
```python
# 1. ROWS 字典加一行主题可带冲击性≤12 字)
'116': ['主题≤12字', 时长, 推流, 进房, 人均, 互动, 礼物, 灵魂力, 关注, 最高在线],
# 2. SESSION_DATE_COLUMN 加日期映射(当月几号)
SESSION_DATE_COLUMN = {..., '116': '5'}
# 3. SESSION_MONTH 加月份3 月场次必填 3否则会写入 2 月表)
SESSION_MONTH = {..., '116': 3}
# 4. _maybe_send_group 内 date_label、src_date 加映射(否则不发群)
# date_label = {..., '116': '3月5日'}
# src_date = {..., '116': '20260305'}
# 5. 若当日有小程序数据,在 MINIPROGRAM_EXTRA 中加:
# MINIPROGRAM_EXTRA = {..., '5': {'访问次数': 60, '访客': 60, '交易金额': 0}}
```
---
## 十一、基因胶囊打包入口
本 Skill 支持打包为基因胶囊,便于继承与分发。打包后产出位于 `卡若Ai的文件夹/导出/基因胶囊/`
```bash
cd /Users/karuo/Documents/个人/卡若AI
python3 "05_卡土/土砖_技能复制/基因胶囊/脚本/gene_capsule.py" pack "02_卡人/水桥_平台对接/飞书管理/运营报表_SKILL.md"
# 或按技能名(在 SKILL_REGISTRY 中匹配)
python3 "05_卡土/土砖_技能复制/基因胶囊/脚本/gene_capsule.py" pack "Soul派对运营报表"
```
打包后将生成:胶囊 JSON、基因胶囊功能流程图.md、说明文档.md含解包命令与引用
---
## 版本记录
| 版本 | 日期 | 说明 |
|:---|:---|:---|
| 1.0 | 2026-02-20 | 初版:截图填表发群 |
| 2.0 | 2026-02-22 | 基因胶囊Token 自动刷新、写入校验、智能纪要、跨平台、完整配置清单 |
| 2.1 | 2026-03-04 | 月份路由2月/3月 工作表分离7A3Cy9 / bJR5sASESSION_MONTH 防串月;支持 113115 场;小程序批量 write_miniprogram_batch运营报表 SKILL 与当前流程同步 |
| 2.2 | 2026-03-04 | **智能纪要上传到报表**§3.2 十步清单txt→JSON→HTML→PNG→feishu_write_minutes_to_sheet与智能纪要 Skill 联动3 月用 --party-image --sheet-id bJR5sA --date-col |
| 2.3 | 2026-03-04 | **会议纪要 + 运营报表 + 发群一站式**:文首新增「一站式完整流程」四步(①填数据发群 ②生成纪要图 ③填图片到报表 ④纪要图发群);飞书群统一:数据推送与纪要图发群同 Webhook纪要图发群用智能纪要 `send_to_feishu.py --image`§3.2 增加「发群」步骤与说明 |
| 3.0 | 2026-03-04 | **完整流程提取 + 基因胶囊**新增「零、完整流程提取」流程图、前置条件、逐步命令表、一键命令块、新场次清单、故障排查派对录屏链接写入E29:E29 范围);§十一 基因胶囊打包入口与 pack 命令 |

View File

@@ -0,0 +1,131 @@
---
name: 飞书视频和文字下载
description: 飞书妙记单条/批量下载视频mp4与文字txt纯 API+Cookie不打开浏览器。含 Cookie 获取链、默认输出目录、查找最早长视频。
triggers: 飞书视频下载、飞书文字下载、妙记下载视频、妙记导出文字、飞书妙记下载、下载飞书视频、下载飞书文字、飞书视频和文字下载
owner: 水桥
group: 水
version: "1.0"
updated: "2026-03-12"
parent_skill: 智能纪要
---
# 飞书视频和文字下载
> **基因能力**:从飞书妙记链接或 object_token命令行下载**视频mp4**与**文字txt**,无需打开浏览器。
> 归属:水桥 · 智能纪要子能力,可独立打包为基因胶囊复用。
---
## 一、默认输出目录
| 类型 | 默认目录 |
|:---|:---|
| **文字txt** | `/Users/karuo/Documents/聊天记录/soul` |
| **视频mp4** | `/Users/karuo/Movies/soul视频/原视频` |
脚本未指定 `-o`/`--output` 时使用上表;解包到其他环境时可修改为本地路径。
---
## 二、权限Cookie 获取链5 级)
妙记**文字导出**与**视频下载**均依赖 Web CookieOpen API 的 tenant_token 无法访问妙记正文/视频)。
1. **cookie_minutes.txt** 第一行(脚本同目录或 `智能纪要/脚本/`
2. **环境变量** `FEISHU_MINUTES_COOKIE`
3. **本机浏览器**browser_cookie3Safari/Chrome/Firefox/Edge
4. **Cursor 内置浏览器**SQLite 明文
`~/Library/Application Support/Cursor/Partitions/cursor-browser/Cookies`
查询 `host_key LIKE '%feishu%' OR host_key LIKE '%cunkebao%'`
5. **手动兜底**:浏览器 F12 → 飞书妙记 list 请求 → 复制 Cookie 到 cookie_minutes.txt
---
## 三、命令行用法
### 3.1 脚本根路径(本基因默认)
```text
SCRIPT_DIR="/Users/karuo/Documents/个人/卡若AI/02_卡人/水桥_平台对接/智能纪要/脚本"
```
解包到其他项目时,将上述路径改为本地「智能纪要/脚本」所在路径。
### 3.2 下载视频(单条)
```bash
# 链接或 object_token 均可
python3 "$SCRIPT_DIR/feishu_minutes_download_video.py" "https://cunkebao.feishu.cn/minutes/obcnc53697q9mj6h1go6v25e"
python3 "$SCRIPT_DIR/feishu_minutes_download_video.py" obcnc53697q9mj6h1go6v25e -o ~/Downloads/
```
- 输出:默认 `原视频/` 下,文件名含标题与日期。
- 依赖:`requests`Cookie 见第二节。
### 3.3 导出文字(单条)
```bash
# 导出为 txt同上需 Cookie
python3 "$SCRIPT_DIR/feishu_minutes_export_github.py" "https://cunkebao.feishu.cn/minutes/obcnc53697q9mj6h1go6v25e" -o "/Users/karuo/Documents/聊天记录/soul"
```
- 输出:默认 `聊天记录/soul` 下 txt 文件。
### 3.4 批量文字(按场次范围)
```bash
python3 "$SCRIPT_DIR/download_soul_minutes_101_to_103.py" --from 90 --to 102
```
### 3.5 查找「最早且时长≥1小时且有画面」的妙记
```bash
python3 "$SCRIPT_DIR/find_oldest_long_video_minute.py"
python3 "$SCRIPT_DIR/find_oldest_long_video_minute.py" --max-status 200
python3 "$SCRIPT_DIR/find_oldest_long_video_minute.py" --list-only
```
- 使用 list API 的 `duration`(毫秒)筛 ≥1 小时,再按 create_time 从早到晚用 status API 筛有 `video_download_url`,输出最早一条的 object_token、标题、日期、时长。
---
## 四、核心 API供二次开发
| 能力 | 方法 | 说明 |
|:---|:---|:---|
| **列表** | `GET /minutes/api/space/list?size=50&space_name=1&last_time={ts}` | 分页用 `last_time` 为上一页最后一条的 create_time |
| **文字** | `POST /minutes/api/export` | params: object_token, format=2, add_speaker=trueHeader: Cookie + Referer + bv-csrf-token |
| **视频** | `GET /minutes/api/status?object_token=xxx` | 返回 `data.video_info.video_download_url`,再 GET 该 URL 流式下载 |
- 域名:`cunkebao.feishu.cn``meetings.feishu.cn`(同一套 Cookie
- list 条目含 `duration`(毫秒)、`create_time``topic``object_token`
---
## 五、脚本清单(依赖父目录 智能纪要/脚本)
| 脚本 | 功能 |
|:---|:---|
| `feishu_minutes_download_video.py` | 单条妙记视频下载status → mp4 |
| `feishu_minutes_export_github.py` | 单条妙记文字导出export → txt |
| `feishu_auth_helper.py` | tenant_token / Cookie 测试、refresh-cookie |
| `cursor_cookie_util.py` | 从 Cursor 浏览器提取 Cookiefeishu/github |
| `download_soul_minutes_101_to_103.py` | 批量场次文字(--from/--to |
| `find_oldest_long_video_minute.py` | 查找最早、时长≥1h、有视频的妙记 |
---
## 六、解包后使用(继承本基因)
1. 将本基因 **unpack** 到目标项目的 `智能纪要/飞书视频文字下载/` 或任意目录。
2. 确保目标环境存在「智能纪要/脚本」或等价脚本目录,并安装 `requests`
3. 把本 SKILL 中 `SCRIPT_DIR` 改为目标环境中的脚本路径。
4. 配置 Cookiecookie_minutes.txt 或 FEISHU_MINUTES_COOKIE 或 Cursor Cookie 提取。
---
## 七、相关文档
- 父技能:`02_卡人/水桥_平台对接/智能纪要/SKILL.md`
- 权限与排查:`智能纪要/参考资料/飞书妙记下载-权限与排查说明.md`
- 账号与 API 索引:`运营中枢/工作台/00_账号与API索引.md`

View File

@@ -1,6 +1,6 @@
---
name: soul-miniprogram-dev
description: Trigger when 编辑 miniprogram、小程序、微信原生、C 端、调用 miniprogram 接口、支付/提现/推荐 时必读。WXML/WXSS、app.request、仅用 /api/miniprogram/*
description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑时必遵循。WXML/WXSS/JS、app.request、/api/miniprogram/*、scene、支付、权限。Use when editing miniprogram, 小程序, 微信原生.
---
# Soul 创业派对 - 小程序开发 Skill
@@ -79,8 +79,6 @@ description: Trigger when 编辑 miniprogram、小程序、微信原生、C 端
## 8. 平台合规与能力检测2025 起)
- **隐私按需授权**:涉及用户信息的接口(登录、手机号、位置等)必须在用户**实际触发功能时**再请求授权,禁止在 `app.onLaunch` 中集中请求。需配置《小程序用户隐私保护指引》,使用 `<button open-type="agreePrivacyAuthorization">` 获取同意。
- **getPhoneNumber 必须耦合**`open-type="getPhoneNumber|agreePrivacyAuthorization"`(基础库 2.32.3+),否则无法弹窗获取手机号。同时需在 app.js 的 `onNeedPrivacyAuthorization` 中将该页面加入支持列表,并提供 `showPrivacyModal` + 同意按钮,否则会 resolve(disagree) 导致失败。
- **登录弹窗**:使用公用组件 `components/login-modal`read/my/gift-pay 等页面引入,避免重复实现。
- **能力检测**:使用新 API 前用 `wx.canIUse('api.xxx')` 检测,低版本做降级。
- **Skyline可选**:性能敏感页面可在 `page.json` 中配置 `"renderer": "skyline"`,仍使用 WXML/WXSS。
@@ -106,41 +104,7 @@ description: Trigger when 编辑 miniprogram、小程序、微信原生、C 端
---
## 11. 原生按钮覆盖定位(避免样式干扰)
- **场景**:在头像、图片等区域需触发 `open-type="chooseAvatar"` 等原生能力时,**禁止用 button 包裹**目标元素,否则会受原生样式影响(灰色矩形、边框等)。
- **正确做法**:用 **button 绝对定位覆盖** 在目标区域上方,与展示元素为同级关系。
- **结构示例**
```html
<view class="avatar-wrap">
<view class="avatar-inner">...</view>
<view class="vip-badge">VIP</view>
<button class="avatar-overlay-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
</view>
```
- **样式**`.avatar-wrap { position: relative; }``.avatar-overlay-btn { position: absolute; top: 0; left: 0; width/height 与目标一致; background: transparent; border: none; }``::after { border: none; }`。
- **口诀**:同级覆盖,绝对定位,透明按钮。
---
## 12. 常见陷阱Gotchas
> 从实际失败中积累,持续补充。**千万别这么做**——对 AI 信号更强。
| 陷阱 | 后果 | 正确做法 |
|------|------|---------|
| 调用 `/api/admin/*`、`/api/db/*` | 路由互窜、鉴权不符 | 仅用 `/api/miniprogram/*` |
| 页面里直接 `wx.request` 写死 baseUrl | 配置分散、难切换环境 | 统一 `getApp().request(url, options)` |
| 在 input/textarea 自身上设 padding | 光标截断、布局异常 | 外边包 viewpadding 写在 viewinput `width:100%` |
| button 包裹目标元素做 chooseAvatar | 原生样式干扰(灰框、边框) | button 绝对定位覆盖,同级关系,透明背景 |
| 分享链路(支付/领取/代付)不判断单页模式 | 朋友圈打开能力受限、报错 | `wx.getSystemInfoSync()?.mode === 'singlePage'` 时引导「前往小程序」 |
| 支付前不校验头像/昵称 | 超级个体开通后资料为默认 | 支付前校验,不通过则 `navigateTo('/pages/avatar-nickname/avatar-nickname')` |
| app.onLaunch 中集中请求隐私授权 | 平台合规风险、审核不通过 | 按需授权,用户**实际触发功能时**再请求 |
| 用 `selectable` 做文本复制 | 已废弃 | 用 `<text user-select>...</text>`(基础库 2.12.1+ |
---
## 13. 何时使用本 Skill
## 11. 何时使用本 Skill
-**miniprogram/** 下新增或修改页面、组件、utils 时。
- 在小程序内新增或修改任何网络请求路径时(必须保持 `/api/miniprogram/...`)。
@@ -150,6 +114,5 @@ description: Trigger when 编辑 miniprogram、小程序、微信原生、C 端
- 做个人中心、设置页布局时(遵循 §7卡片区边距 16rpx
- 做阅读、文章等需长按复制的文本时(遵循 §9text 加 user-select
- 做编辑资料页分享名片时(遵循 §10
- 做头像上传、chooseAvatar 等需 button 触发的原生能力时(遵循 §11用绝对定位覆盖禁止 button 包裹)。
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。

View File

@@ -39,8 +39,9 @@ cd .cursor/scripts/db-exec && npm install
### 3.2 执行单条 SQL
在**本仓库根目录**下执行(与 `miniprogram/``soul-api/` 同级):
```bash
cd e:\Gongsi\Mycontent
node .cursor/scripts/db-exec/run.js "SELECT 1"
node .cursor/scripts/db-exec/run.js "DESCRIBE orders"
node .cursor/scripts/db-exec/run.js "ALTER TABLE users ADD COLUMN new_field VARCHAR(64) DEFAULT ''"

View File

@@ -192,10 +192,10 @@ description: 新版快速分析 Skill。甲方/第三方 AI 写的新版本,
2. 排除:技术债、规则不清、与稳定版冲突的部分
3. 按**最小功能**拆分,保证每个任务迁移后能完整运行
4. 排期顺序:**界面修改优先** → 大逻辑排后P0逻辑不通→ P1功能缺失→ P2优化
5. 写入需求清单(当日需求文件),形成迁移任务清单
5. 写入需求汇总,形成迁移任务清单
**产出**
- `开发文档/1、需求/YYYY-MM-DD-需求.md` 追加需求(当日文件,以日期最新为主;同步后更新 `1、需求/索引.md`
- `开发文档/1、需求/需求汇总.md` 追加需求
- `开发文档/新版迁移-开发方案与清单.md` 或等价迁移清单
---
@@ -259,7 +259,7 @@ description: 新版快速分析 Skill。甲方/第三方 AI 写的新版本,
4. **逻辑分层**:每个功能过三层(界面/接口/数据)
5. **体验评估**:补充空态、错误态、边界处理
6. **接口规范与冲突**:产出接口规范与冲突清单
7. **抽取需求**:写入需求清单(当日需求文件),形成迁移任务清单
7. **抽取需求**:写入需求汇总,形成迁移任务清单
8. **需求评审(迁移前必做)**:列出功能点 + 样式变更,逐一确认,产出评审清单
9. **回复用户**:给出分析摘要 + 文档路径 + 建议执行顺序;**迁移须在需求评审通过后开始**
@@ -273,7 +273,7 @@ description: 新版快速分析 Skill。甲方/第三方 AI 写的新版本,
| 接口规范与冲突 | `开发文档/新版迁移-接口规范与冲突清单.md` |
| 迁移方案/清单 | `开发文档/新版迁移-开发方案与清单.md``新版功能迁移到稳定版方案.md` |
| **需求评审清单** | `开发文档/新版迁移-需求评审清单.md`(功能点 + 样式变更,含确认状态) |
| 需求清单 | `开发文档/1、需求/YYYY-MM-DD-需求.md`(以日期最新为主) |
| 需求汇总 | `开发文档/1、需求/需求汇总.md` |
若已有同名文档,在其基础上**追加或更新**,不重复创建。

View File

@@ -9,62 +9,57 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
---
## 0. 加个需求流水线(优先执行
## 0. 需求即执行(核心行为准则
当用户说**「加个需求xxxxxxx」**(具体内容)时,产品经理**必须**执行以下流程,确保三端功能闭环
> **绝对禁止**:收到需求后列出分析表格然后问用户「要从哪个开始」
> **正确做法**:收到需求 → 回复「好」→ 立即开始执行代码变更 → 完成后回复结果。
### 0.1 触发与解析
### 0.0 执行模式(最高优先级)
- **触发词**`加个需求``加个需求xxx`(理解意图即可)
- **解析**:提取用户描述的具体功能或变更点
当用户给出需求(需求文档、口头描述、加个需求等任何形式)时:
### 0.2 三端分析(功能闭环
1. **回复「好」**(一个字,不多说
2. **内部完成三端分析**(不输出给用户,仅作为执行依据)
3. **立即按顺序执行代码变更**:后端 → 管理端 → 小程序(按实际依赖关系)
4. **执行完成后回复结果**:改了哪些文件、做了什么、验证结果
5. **遇到不确定的业务逻辑**时才询问用户,技术实现细节自行判断
对每个需求,**必须**分析三端各自需要哪些调整:
### 0.1 内部三端分析(不输出,仅执行依据)
| 端 | 分析要点 | 典型产出 |
|----|----------|----------|
| **小程序** | 新增/改版页面、交互、调用的 miniprogram 接口 | 页面路径、功能要点、接口依赖 |
| **管理端** | 是否**确有**管理能力需求? | 新增列表/表单、配置项、审核、统计、开关(无则写「无」) |
| **后端** | 接口、数据模型、路由分组 | miniprogram/admin/db 接口契约、表/字段变更 |
对每个需求,内部分析三端各自需要哪些调整:
**判断原则**
- 新增功能 → 常伴随:管理端**配置项**(开关、文案、规则)、**列表/审核**(若涉及用户提交)、**统计**(若涉及数据展示)
- 若仅小程序展示 → 可能只需 miniprogram 接口,管理端无变更
- 若涉及业务规则/开关 → 管理端「系统设置」或独立配置页;后端 config 或专用表
| 端 | 分析要点 |
|----|----------|
| **小程序** | 新增/改版页面、交互、调用的 miniprogram 接口 |
| **管理端** | 是否确有管理能力需求(配置/开关/审核/统计) |
| **后端** | 接口、数据模型、路由分组 |
**合理性约束(必守)**
- **按实际情况判断**,不因需求表述而过度设计。例如:单纯改文案、改按钮文字、改提示语 → **不需要**新增管理列表、文案管理、配置项;直接改前端代码即可
- 管理端/后端调整**仅在确有管理或数据需求时**才规划:需要运营配置、需要审核、需要统计、需要多端复用同一文案等
- 不确定时,优先给出**最小可行方案**,避免为小改动堆砌管理能力。
- **按实际情况判断**,不过度设计。单纯改文案/按钮/提示语 → 直接改前端代码。
- 管理端/后端调整**仅在确有管理或数据需求时**才
- 优先**最小可行方案**,避免为小改动堆砌管理能力。
### 0.3 功能规划与协调变更
### 0.2 执行顺序
1. **输出需求分析**:写入 `临时需求池/YYYY-MM-DD-需求简述.md` 或追加到 `开发文档/1、需求/YYYY-MM-DD-需求.md` 需求清单(以日期最新为主,同步后更新 `1、需求/索引.md`
2. **三端任务拆分**:按上表列出「小程序任务」「管理端任务」「后端任务」
3. **协调变更**:若需更新《以界面定需求》,同步更新界面清单与业务逻辑
4. **指派**:明确各任务对应角色(小程序开发工程师、管理端开发工程师、后端工程师),并给出执行顺序建议(通常:后端 → 小程序;管理端视依赖可并行或后置)
1. 后端(接口、模型、路由
2. 管理端(页面、组件、交互)
3. 小程序(页面、交互、接口调用)
4. 联调验证
### 0.4 产出模板
`SKILL-role-flow-control` 的协同流程推进。
```
【需求】用户描述
【三端分析】
- 小程序xxx
- 管理端xxx若无则写「无」
- 后端xxx
【任务指派】
1. 后端xxx
2. 小程序xxx
3. 管理端xxx若需要
【文档更新】以界面定需求 / 需求汇总 / 临时需求池
```
### 0.25 对话与收尾(卡若 AI
### 0.5 与 role-flow-control 的配合
- 面向用户的回复默认 **简体中文**
- **每条回复末尾**增加完整 **卡若复盘块**(🎯📌💡📝▶,标题带日期时间,块内无表格),与卡若项目 `运营中枢/参考资料/卡若复盘格式_固定规则.md` 一致;本仓库规则见 `.cursor/rules/soul-karuo-dialogue.mdc`
本流水线与 `SKILL-role-flow-control` 协同:产品经理完成三端分析与指派后,开发执行时按 role-flow-control 的协同流程(需求分析 → 并行开发 → 管理端启动 → 联调)推进。
### 0.3 执行后文档更新
代码变更完成后,按需更新:
- `临时需求池/YYYY-MM-DD-需求简述.md`
- `需求汇总.md`
- 《以界面定需求》界面清单
---
@@ -73,7 +68,7 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
| 职责 | 说明 | 产出 |
|------|------|------|
| 需求分析 | 业务需求拆解、优先级、技术可行性 | 需求分析文档、临时需求池 |
| 需求文档 | 需求清单、业务规则、验收标准 | 1、需求/YYYY-MM-DD-需求.md以最新为主、运营与变更.md |
| 需求文档 | 需求清单、业务规则、验收标准 | 需求汇总.md、运营与变更.md |
| 验收 | 功能验收、回归检查 | 验收清单、项目推进表 |
| 协调 | 与开发沟通、排期、变更 | 运营与变更、项目落地推进表 |
@@ -83,7 +78,7 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
| 文档 | 说明 |
|------|------|
| `开发文档/1、需求/` | 需求清单(按日期命名,以最新为主)、业务需求;见 `1、需求/索引.md` |
| `开发文档/1、需求/需求汇总.md` | 需求清单、业务需求 |
| `临时需求池/` | 需求分析、技术分析 |
| `开发文档/10、项目管理/项目落地推进表.md` | 里程碑、永平落地 |
| `开发文档/10、项目管理/运营与变更.md` | 近期讨论、变更记录 |
@@ -121,7 +116,7 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
## 6. 何时选用
- 用户说**「加个需求xxx」**时:执行 §0 加个需求流水线(三端分析 → 功能规划 → 指派
- 用户说**「加个需求xxx」**时:执行 **§0 需求即执行**(回复「好」→ 三端落地 → 结果回复 + 卡若复盘收尾
- 编辑 `开发文档/1、需求/``临时需求池/``开发文档/10、项目管理/`
- 进行需求分析、需求文档编写、验收标准定义时
- 用户说「需求分析」「产品经理」「验收」时

View File

@@ -25,7 +25,7 @@ description: Soul 创业派对开发团队多角色会议。语义化触发:
```
第零步(可选):回顾历史
→ 使用 Read 工具读取 `e:\Gongsi\Mycontent\.cursor\meeting\README.md` 的索引表
→ 使用 Read 工具读取 `.cursor\meeting\README.md` 的索引表
→ 若议题与近期会议相关Read 最近 12 份纪要(如 YYYY-MM-DD_主题.md
→ 便于延续上次讨论、避免重复决议
@@ -93,10 +93,10 @@ description: Soul 创业派对开发团队多角色会议。语义化触发:
### 4.1 生成会议纪要文件
1. **确定文件名**`YYYY-MM-DD_议题关键词.md`(日期为今天)
2. **创建文件**`e:\Gongsi\Mycontent\.cursor\meeting\YYYY-MM-DD_议题关键词.md`
3. **内容**:按 `e:\Gongsi\Mycontent\.cursor\meeting\_模板.md` 填写完整纪要
2. **创建文件**`.cursor\meeting\YYYY-MM-DD_议题关键词.md`
3. **内容**:按 `.cursor\meeting\_模板.md` 填写完整纪要
4. **问题与作答区**(必须):将「待确认项」「待澄清项」列出为问题表,责任角色标明谁负责回答,作答列留空供后续填写
5. **更新索引**:在 `e:\Gongsi\Mycontent\.cursor\meeting\README.md` 的索引表中追加一行
5. **更新索引**:在 `.cursor\meeting\README.md` 的索引表中追加一行
### 4.2 各角色经验入库(按日期文件)
@@ -115,7 +115,7 @@ description: Soul 创业派对开发团队多角色会议。语义化触发:
```
确定今天日期 YYYY-MM-DD
检查 e:\Gongsi\Mycontent\.cursor\agent\{角色目录}\evolution\YYYY-MM-DD.md 是否存在
检查 `.cursor/agent/{角色目录}/evolution/YYYY-MM-DD.md` 是否存在
不存在 → 创建,写入文件头(# {角色名} 经验记录 - YYYY-MM-DD
从本次会议讨论中,提炼该角色的经验条目追加进去
@@ -123,12 +123,12 @@ description: Soul 创业派对开发团队多角色会议。语义化触发:
更新 agent/开发助理/项目索引/{索引名}.md开发进度表追加一行写日期
```
若有跨角色共享的经验(架构决策、业务规则、路由约定),**必须**同时写入 `e:\Gongsi\Mycontent\.cursor\agent\团队\evolution\YYYY-MM-DD.md`,并在对应角色文件中注明「详见 agent/团队/evolution/」。
若有跨角色共享的经验(架构决策、业务规则、路由约定),**必须**同时写入 `.cursor/agent/团队/evolution/YYYY-MM-DD.md`,并在对应角色文件中注明「详见 agent/团队/evolution/」。
### 4.3 Skill 升级(重要决议时)
若本次会议产生了影响开发规范的决议:
- 更新对应 `e:\Gongsi\Mycontent\.cursor\skills\SKILL-xxx.md`
- 更新对应 `.cursor\skills\SKILL-xxx.md`
- 在会议纪要「各角色经验」节标注「已升级 SKILL-xxx.md」
---
@@ -137,11 +137,11 @@ description: Soul 创业派对开发团队多角色会议。语义化触发:
| 输出物 | 位置 | 负责人 |
|--------|------|--------|
| 会议纪要 | `e:\Gongsi\Mycontent\.cursor\meeting\YYYY-MM-DD_主题.md` | 助理橙子 |
| 各角色经验 | `e:\Gongsi\Mycontent\.cursor\agent\{角色}\evolution\YYYY-MM-DD.md` | 助理橙子 |
| 会议纪要 | `.cursor\meeting\YYYY-MM-DD_主题.md` | 助理橙子 |
| 各角色经验 | `.cursor/agent/{角色}/evolution/YYYY-MM-DD.md` | 助理橙子 |
| 项目索引更新 | agent/开发助理/项目索引/{角色}.md 的开发进度表 | 助理橙子 |
| Skill 升级(按需) | `.cursor/skills/{skill}/SKILL.md` | 助理橙子 |
| 会议记录索引更新 | `e:\Gongsi\Mycontent\.cursor\meeting\README.md` | 助理橙子 |
| 会议记录索引更新 | `.cursor\meeting\README.md` | 助理橙子 |
---

3
.cursorignore Normal file
View File

@@ -0,0 +1,3 @@
# 减轻 Cursor 代码索引噪声(需要时仍可用 @路径 打开)
.cursor/scripts/db-exec/node_modules/

49
.gitignore vendored
View File

@@ -1 +1,48 @@
new-soul
# 根目录忽略
.DS_Store
*.zip
.env
.env.*
!.env.*.example
__pycache__/
*.pyc
*.pyo
log/
tmp/
# 永不上传到 GitHub
开发文档/
# 二进制/压缩/临时产物
*.exe
*.gz
*.tar
*.tgz
*.bak
# 各子项目已有 .gitignore此处仅补充分支通用项
node_modules/
# 小程序本地配置
miniprogram/project.private.config.json
# 管理端本地构建缓存
soul-admin/.vite/
# API 本地运行产物与上传目录
soul-api/uploads/
soul-api/wechat/info.log
soul-api/soul-api-linux
soul-api/soul-api-new
# 备份文件
*.backup
# 本机运营凭证karuo-party保留 credentials/README.md
.cursor/skills/karuo-party/credentials/cookies/
.cursor/skills/karuo-party/credentials/.feishu_tokens.json
.cursor/skills/karuo-party/credentials/*.json
!.cursor/skills/karuo-party/credentials/README.md
# Cursor 索引减负db-exec 依赖(仓库根已有 node_modules/ 规则,此处显式强调子路径)
.cursor/scripts/db-exec/node_modules/

237
content_upload.py Normal file
View File

@@ -0,0 +1,237 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
将书稿 md 上传到小程序对应 chapters 表(与 Soul创业实验 Skill「上传」一致
依赖: pip install pymysql
数据库配置:复用 scripts/migrate_2026_sections.py 中的 DB_CONFIG与现网一致
"""
from __future__ import annotations
import argparse
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
PART_2026 = "part-2026-daily"
CHAPTER_2026 = "chapter-2026-daily"
TITLE_2026 = "2026每日派对干货"
def load_db_config() -> dict:
mig = ROOT / "scripts" / "migrate_2026_sections.py"
if not mig.is_file():
print("缺少 scripts/migrate_2026_sections.py无法读取 DB_CONFIG", file=sys.stderr)
sys.exit(1)
spec = importlib.util.spec_from_file_location("_mig_db", mig)
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)
cfg = getattr(mod, "DB_CONFIG", None)
if not isinstance(cfg, dict):
print("migrate_2026_sections.py 中无有效 DB_CONFIG", file=sys.stderr)
sys.exit(1)
return cfg
def strip_md_title_line(text: str) -> str:
lines = text.splitlines()
if lines and lines[0].lstrip().startswith("#"):
return "\n".join(lines[1:]).lstrip("\n")
return text
def for_miniprogram_body(text: str) -> str:
"""上传 README少用 --- 分割线;正文内独立一行的 --- 改为空行分段。"""
out_lines: list[str] = []
for line in text.splitlines():
if line.strip() == "---":
out_lines.append("")
out_lines.append("")
else:
out_lines.append(line)
return "\n".join(out_lines).strip() + "\n"
def next_10_id(cur) -> str:
cur.execute(
"""
SELECT id FROM chapters
WHERE id REGEXP '^10\\\\.[0-9]+$'
ORDER BY CAST(SUBSTRING_INDEX(id, '.', -1) AS UNSIGNED) DESC
LIMIT 1
"""
)
row = cur.fetchone()
if not row:
return "10.01"
last = row[0]
n = int(last.split(".")[-1])
return f"10.{n + 1:02d}"
def list_structure(cur):
cur.execute(
"""
SELECT DISTINCT part_id, part_title, chapter_id, chapter_title
FROM chapters
ORDER BY part_id, chapter_id
"""
)
print("篇章结构distinct part/chapter:")
for r in cur.fetchall():
print(f" part={r[0]!r} chapter={r[2]!r} | {r[1]} / {r[3]}")
def list_chapters_2026(cur):
cur.execute(
"""
SELECT id, section_title, sort_order
FROM chapters
WHERE part_id = %s AND chapter_id = %s
ORDER BY COALESCE(sort_order, 999999) ASC, id ASC
""",
(PART_2026, CHAPTER_2026),
)
print(f"2026每日派对干货 ({PART_2026} / {CHAPTER_2026}):")
for r in cur.fetchall():
print(f" {r[0]}\torder={r[2]}\t{r[1]}")
def main():
try:
import pymysql
except ImportError:
print("需要: pip install pymysql", file=sys.stderr)
sys.exit(1)
p = argparse.ArgumentParser(description="上传书稿 md 到 soul_miniprogram.chapters")
p.add_argument("--id", help="业务 id如 10.27;省略则自动取当前最大 10.xx +1")
p.add_argument("--title", help="小节标题,如 第128场主题")
p.add_argument("--content-file", type=Path, help="文章 md 绝对或相对路径")
p.add_argument("--part", default=PART_2026)
p.add_argument("--chapter", default=CHAPTER_2026)
p.add_argument("--part-title", default=TITLE_2026)
p.add_argument("--chapter-title", default=TITLE_2026)
p.add_argument("--price", type=float, default=1.0)
p.add_argument("--free", action="store_true", help="标记为免费")
p.add_argument("--list-structure", action="store_true")
p.add_argument("--list-chapters", action="store_true")
p.add_argument("--dry-run", action="store_true")
args = p.parse_args()
cfg = load_db_config()
conn = pymysql.connect(**cfg)
cur = conn.cursor()
if args.list_structure:
list_structure(cur)
conn.close()
return
if args.list_chapters:
list_chapters_2026(cur)
conn.close()
return
if not args.title or not args.content_file:
p.error("上传时必须提供 --title 与 --content-file")
path = args.content_file.expanduser().resolve()
if not path.is_file():
print(f"文件不存在: {path}", file=sys.stderr)
sys.exit(1)
raw = path.read_text(encoding="utf-8")
body = for_miniprogram_body(strip_md_title_line(raw))
word_count = len(body)
is_free = 1 if args.free else 0
price = 0.0 if args.free else float(args.price)
section_id = args.id
if not section_id:
section_id = next_10_id(cur)
print(f"未指定 --id使用新 id: {section_id}")
cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,))
row = cur.fetchone()
exists = row is not None
cur.execute("SELECT COALESCE(MAX(sort_order), -1) FROM chapters")
max_sort = cur.fetchone()[0]
next_sort = int(max_sort) + 1
if args.dry_run:
print(f"id={section_id} exists={exists} next_sort={next_sort} words={word_count}")
print(body[:500] + ("..." if len(body) > 500 else ""))
conn.close()
return
if exists:
cur.execute(
"""
UPDATE chapters SET
section_title = %s,
content = %s,
word_count = %s,
price = %s,
is_free = %s,
part_id = %s,
part_title = %s,
chapter_id = %s,
chapter_title = %s,
updated_at = NOW()
WHERE id = %s
""",
(
args.title,
body,
word_count,
price,
is_free,
args.part,
args.part_title,
args.chapter,
args.chapter_title,
section_id,
),
)
print(f"已更新 {section_id} | {args.title}")
else:
cur.execute(
"""
INSERT INTO chapters (
id, part_id, part_title, chapter_id, chapter_title,
section_title, content, word_count, is_free, price,
sort_order, status, edition_standard, edition_premium,
hot_score, created_at, updated_at
) VALUES (
%s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, 'published', 1, 0,
0, NOW(), NOW()
)
""",
(
section_id,
args.part,
args.part_title,
args.chapter,
args.chapter_title,
args.title,
body,
word_count,
is_free,
price,
next_sort,
),
)
print(f"已创建 {section_id} | {args.title} | sort_order={next_sort}")
conn.commit()
conn.close()
if __name__ == "__main__":
main()

View File

@@ -35,7 +35,8 @@ miniprogram/
│ ├── purchases/ # 订单页
│ └── settings/ # 设置页
├── utils/
── util.js # 工具函数
── util.js # 工具函数
│ └── payment.js # 支付工具
├── assets/
│ └── icons/ # 图标资源
├── project.config.json # 项目配置

View File

@@ -9,13 +9,16 @@ const { checkAndExecute } = require('./utils/ruleEngine.js')
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.1'
App({
globalData: {
// API 基础地址:开发时修改下面一行切换环境
// baseUrl: "https://soulapi.quwanzhi.com",
baseUrl: 'http://localhost:8080', // 开发
// baseUrl: 'https://souldev.quwanzhi.com', // 测试
// 与微信后台上传版本号一致,供设置页等展示(避免与线上 version 字段混淆)
appDisplayVersion: APP_DISPLAY_VERSION,
// API仓库默认生产release 强制生产develop/trial 可读 storage「apiBaseUrl」或用 env-switch
baseUrl: 'https://soulapi.quwanzhi.com',
// 小程序配置 - 真实AppID
appId: DEFAULT_APP_ID,
@@ -30,9 +33,14 @@ App({
openId: null, // 微信openId支付必需
isLoggedIn: false,
// 阅读页 @ 解析:/config/read-extras 的 mentionPersons与后台 persons + token 一致)
mentionPersons: [],
// 是否已成功拉取过 read-extras避免仅 linkTags 有缓存时永远拿不到 mentionPersons
readExtrasCacheValid: false,
// 书籍数据bookData 由 chapters-by-part 等逐步填充,不再预加载 all-chapters
bookData: null,
totalSections: 62,
totalSections: 90,
// 购买记录
purchasedSections: [],
@@ -47,6 +55,9 @@ App({
// 推荐绑定
pendingReferralCode: null, // 待绑定的推荐码
// 客服微信号(从系统配置加载,默认值兜底)
serviceWechat: '28533368',
// 主题配置
theme: {
brandColor: '#00CED1',
@@ -81,16 +92,41 @@ App({
// config 统一缓存5min减少重复请求
configCache: null,
configCacheExpires: 0,
// VIP 联系方式检测上次检测时间戳onShow 节流 5 分钟
// VIP 联系方式检测上次检测时间戳onShow 节流(避免与 launch 重复打满接口)
lastVipContactCheck: 0,
// 头像昵称检测:上次检测时间戳onShow 节流 5 分钟
// 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
lastAvatarNicknameCheck: 0,
// 登录过期401 后用户点「去登录」时设为 true我的页 onShow 会检测并自动弹登录
pendingLoginAfterExpire: false,
},
/** 正式版强制生产 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
try {
const saved = wx.getStorageSync(KEY)
if (saved && saved !== PRODUCTION) wx.removeStorageSync(KEY)
} catch (_) {}
return
}
const saved = wx.getStorageSync(KEY)
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
}
} catch (_) {
this.globalData.baseUrl = PRODUCTION
}
},
onLaunch(options) {
this.initApiBaseUrl()
// 昵称等隐私组件需先授权input type="nickname" 不会主动触发,需配合 wx.requirePrivacyAuthorize 使用
if (typeof wx.onNeedPrivacyAuthorization === 'function') {
wx.onNeedPrivacyAuthorization((resolve) => {
@@ -171,10 +207,10 @@ App({
this.globalData.lastMpConfigCheck = now
this.getAuditMode()
}
// 从后台切回: VIP 强制跳转,再头像/昵称,节流 5 分钟
const throttle = 5 * 60 * 1000
// 从后台切回:刷新 VIP/头像引导vipGuideThrottleMs=0 表示不限制间隔)
const vipGuideThrottleMs = 0
if (this.globalData.isLoggedIn && this.globalData.userInfo?.id) {
if (!this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > throttle) {
if (vipGuideThrottleMs <= 0 || !this.globalData.lastVipContactCheck || now - this.globalData.lastVipContactCheck > vipGuideThrottleMs) {
this.globalData.lastVipContactCheck = now
this.globalData.lastAvatarNicknameCheck = now
setTimeout(() => this.checkVipContactRequiredAndGuide(), 500)
@@ -560,9 +596,6 @@ App({
*/
async checkVipContactRequiredAndGuide() {
if (!this.globalData.isLoggedIn || !this.globalData.userInfo?.id) return
const now = Date.now()
if (this._lastVipGuideRun && now - this._lastVipGuideRun < 3000) return // 3 秒内不重复执行,避免 onLaunch+onShow 双重触发
this._lastVipGuideRun = now
const userId = this.globalData.userInfo.id
try {
const pages = getCurrentPages()
@@ -690,10 +723,11 @@ App({
* 获取阅读页扩展配置linkTags、linkedMiniprograms懒加载
*/
async getReadExtras() {
if (Array.isArray(this.globalData.linkTagsConfig) && this.globalData.linkTagsConfig.length > 0) {
if (this.globalData.readExtrasCacheValid) {
return {
linkTags: this.globalData.linkTagsConfig,
linkedMiniprograms: this.globalData.linkedMiniprograms || []
linkTags: this.globalData.linkTagsConfig || [],
linkedMiniprograms: this.globalData.linkedMiniprograms || [],
mentionPersons: this.globalData.mentionPersons || [],
}
}
try {
@@ -701,10 +735,18 @@ App({
if (res) {
if (Array.isArray(res.linkTags)) this.globalData.linkTagsConfig = res.linkTags
if (Array.isArray(res.linkedMiniprograms)) this.globalData.linkedMiniprograms = res.linkedMiniprograms
if (Array.isArray(res.mentionPersons)) this.globalData.mentionPersons = res.mentionPersons
else this.globalData.mentionPersons = []
this.globalData.readExtrasCacheValid = true
return res
}
} catch (e) {}
return { linkTags: [], linkedMiniprograms: [] }
if (!Array.isArray(this.globalData.mentionPersons)) this.globalData.mentionPersons = []
return {
linkTags: this.globalData.linkTagsConfig || [],
linkedMiniprograms: this.globalData.linkedMiniprograms || [],
mentionPersons: this.globalData.mentionPersons,
}
},
/**
@@ -761,14 +803,14 @@ App({
/**
* 小程序更新检测(基于 wx.getUpdateManager
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求
* - 启动时检测;从后台切回前台时也检测(间隔即可,避免用户感知「很久才检查更新」
*/
checkUpdate() {
try {
if (!wx.canIUse('getUpdateManager')) return
const now = Date.now()
const lastCheck = this.globalData.lastUpdateCheck || 0
if (lastCheck && now - lastCheck < 5 * 60 * 1000) return // 5 分钟内不重复检测
if (lastCheck && now - lastCheck < 60 * 1000) return // 1 分钟内不重复检测
this.globalData.lastUpdateCheck = now
const updateManager = wx.getUpdateManager()
@@ -1062,13 +1104,6 @@ App({
return null
},
// 模拟登录已废弃 - 不再使用
// 现在必须使用真实的微信登录获取openId作为唯一标识
mockLogin() {
console.warn('[App] mockLogin已废弃请使用真实登录')
return null
},
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
async loginWithPhone(phoneCode) {
if (!this.ensureFullAppForAuth()) {

View File

@@ -29,8 +29,7 @@
"pages/avatar-nickname/avatar-nickname",
"pages/gift-pay/detail",
"pages/gift-pay/list",
"pages/gift-pay/redemption-detail",
"pages/dev-login/dev-login"
"pages/gift-pay/redemption-detail"
],
"window": {
"backgroundTextStyle": "light",

View File

@@ -0,0 +1,8 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="8" y1="6" x2="21" y2="6" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="8" y1="12" x2="21" y2="12" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="8" y1="18" x2="21" y2="18" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="6" x2="3.01" y2="6" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="12" x2="3.01" y2="12" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="3" y1="18" x2="3.01" y2="18" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 814 B

View File

@@ -0,0 +1,7 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="18" cy="5" r="3" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="6" cy="12" r="3" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="18" cy="19" r="3" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 690 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<g stroke="#00CED1" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round" opacity="0.36">
<path d="M7 10.5V8a5 5 0 0 1 9.6-2"/>
<rect x="5" y="10.5" width="14" height="10.5" rx="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 335 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -31,7 +31,7 @@
<text class="doc-p">我们可能适时修订本协议,修订后将在小程序内公示。若您继续使用服务,即视为接受修订后的协议。</text>
<text class="doc-section">七、联系我们</text>
<text class="doc-p">如有疑问,请通过 Soul 派对房与我们联系。</text>
<text class="doc-p">如有疑问,请通过小程序内「关于作者」或 Soul 派对房与我们联系。</text>
</view>
</scroll-view>
</view>

View File

@@ -29,6 +29,9 @@ Page({
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
// 小三角点击动画:当前触发的子章 id与 chapter.id 比对)
_triangleAnimating: '',
// 固定模块 id -> mid序言/尾声/附录,供 goToRead 传 mid
fixedSectionsMap: {},
@@ -160,6 +163,12 @@ Page({
})
})
const chapters = Array.from(chMap.values())
chapters.forEach(ch => ch.sections.reverse())
// 目录子章下列表:默认最多展示 5 条,点小三角每次再展开 5 条
chapters.forEach((ch) => {
const n = ch.sections.length
ch.sectionVisibleLimit = n === 0 ? 0 : Math.min(5, n)
})
const loaded = { ...this.data._loadedChapters, [partId]: chapters }
const bookData = this.data.bookData.map(p =>
p.id === partId ? { ...p, chapters } : p
@@ -235,6 +244,43 @@ Page({
if (isExpanding) await this.loadChaptersByPart(partId)
},
expandSectionChapter(e) {
const partId = e.currentTarget.dataset.partId
const chapterId = e.currentTarget.dataset.chapterId
if (!partId || !chapterId) return
trackClick('chapters', 'tab_click', '目录_子章展开5条')
const part = this.data.bookData.find((p) => p.id === partId)
const chapter = part && (part.chapters || []).find((c) => c.id === chapterId)
if (!chapter || !chapter.sections || chapter.sections.length === 0) return
const total = chapter.sections.length
const cur = typeof chapter.sectionVisibleLimit === 'number' ? chapter.sectionVisibleLimit : Math.min(5, total)
const next = Math.min(cur + 5, total)
if (next === cur) return
const bookData = this.data.bookData.map((p) => {
if (p.id !== partId) return p
return {
...p,
chapters: (p.chapters || []).map((ch) =>
ch.id === chapterId ? { ...ch, sectionVisibleLimit: next } : ch
),
}
})
// 先去掉动画 class 再打上,便于连续点击重复触发动画
this.setData({ _triangleAnimating: '', bookData })
setTimeout(() => {
this.setData({ _triangleAnimating: chapterId })
setTimeout(() => {
if (this.data._triangleAnimating === chapterId) {
this.setData({ _triangleAnimating: '' })
}
}, 480)
}, 30)
},
// 跳转到阅读页(优先传 mid与分享逻辑一致
goToRead(e) {
const id = e.currentTarget.dataset.id

View File

@@ -88,8 +88,8 @@
<block wx:for="{{item.chapters}}" wx:key="id" wx:for-item="chapter">
<view class="chapter-header">{{chapter.title}}</view>
<view class="section-list">
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section">
<view class="section-item" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
<block wx:for="{{chapter.sections}}" wx:key="id" wx:for-item="section" wx:for-index="secIdx">
<view class="section-item" wx:if="{{secIdx < chapter.sectionVisibleLimit}}" bindtap="goToRead" data-id="{{section.id}}" data-mid="{{section.mid}}">
<view class="section-left">
<view class="section-lock-wrap">
<icon wx:if="{{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1}}" name="lock-open" size="24" color="#00CED1" customClass="section-lock lock-open"></icon>
@@ -100,12 +100,14 @@
</view>
<view class="section-right">
<text wx:if="{{section.isFree}}" class="tag tag-free">免费</text>
<text wx:elif="{{isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1}}" class="tag tag-purchased">已解锁</text>
<text wx:else class="section-price">¥{{section.price}}</text>
<text wx:elif="{{!(isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1)}}" class="section-price">¥{{section.price}}</text>
<icon name="chevron-right" size="24" color="rgba(255,255,255,0.3)" customClass="section-arrow"></icon>
</view>
</view>
</block>
<view class="section-expand-trigger" wx:if="{{chapter.sections.length > chapter.sectionVisibleLimit}}" bindtap="expandSectionChapter" data-part-id="{{item.id}}" data-chapter-id="{{chapter.id}}">
<view class="latest-expand-triangle {{_triangleAnimating === chapter.id ? 'tri-bounce' : ''}}"></view>
</view>
</view>
</block>
</view>

View File

@@ -577,6 +577,49 @@
color: rgba(255, 255, 255, 0.3);
}
/* ===== 展开三角 ===== */
.section-expand-trigger {
display: flex;
align-items: center;
justify-content: center;
padding: 20rpx 0 12rpx;
}
.latest-expand-triangle {
width: 0;
height: 0;
border-left: 18rpx solid transparent;
border-right: 18rpx solid transparent;
border-top: 14rpx solid rgba(0, 206, 209, 0.55);
opacity: 0.85;
transform-origin: 50% 0;
transition: border-top-color 0.15s ease;
}
.section-expand-trigger:active .latest-expand-triangle {
border-top-color: #00CED1;
}
@keyframes catalog-tri-nudge {
0% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
40% {
transform: translateY(10rpx) scale(1.12);
opacity: 1;
border-top-color: #00CED1;
}
100% {
transform: translateY(0) scale(1);
opacity: 0.85;
}
}
.latest-expand-triangle.tri-bounce {
animation: catalog-tri-nudge 0.45s ease-out;
}
/* ===== 底部留白 ===== */
.bottom-space {
height: 40rpx;

View File

@@ -1,6 +1,7 @@
/**
* 卡若创业派对 - 开发登录页
* 临时:账户=手机号,密码可空,用于切换为对方账号调试
* 卡若创业派对 - 开发登录页(仅本地调试)
* 勿写入 app.json pages提审包不得注册本页。
* 需要用时在 app.json 的 pages 数组末尾临时加入 "pages/dev-login/dev-login"。
*/
const app = getApp()
const { checkAndExecute } = require('../../utils/ruleEngine.js')

View File

@@ -4,10 +4,23 @@
* 技术支持: 存客宝
*/
console.log('[Index] ===== 首页文件开始加载 =====')
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser')
/** 与首页固定「卡若」获客位重复时从横滑列表剔除(含历史误写「卡路」) */
function isKaruoHostDuplicateName(displayName) {
const s = String(displayName || '').trim()
return s === '卡若' || s === '卡路'
}
/** 超级个体无头像占位:仅展示中文首字,避免头像圆里出现英文字母 */
function superAvatarLetter(displayName) {
const s = String(displayName || '').trim()
if (!s) return '会'
const ch = s[0]
return /[\u4e00-\u9fff]/.test(ch) ? ch : '会'
}
Page({
data: {
@@ -31,8 +44,8 @@ Page({
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
],
// 最新章节(动态计算
latestSection: null,
// Banner 推荐(优先用 recommended API 第一条,回退 latest-chapters
bannerSection: null,
latestLabel: '最新更新',
// 内容概览
@@ -66,16 +79,13 @@ Page({
// 展开状态(首页精选/最新)
featuredExpanded: false,
latestExpanded: false,
featuredSectionsFull: [], // ranking 返回的 10 条,默认展示前 5
featuredSectionsFull: [], // 精选排行榜全量(最多 50,默认展示前 3
// 功能配置(搜索开关)
searchEnabled: true,
// 审核模式:隐藏支付相关入口
auditMode: false,
// 置顶人(链接人与事,管理端设置,小程序首页展示)
pinnedPerson: null // { nickname, avatar, token } 或 null
auditMode: false
},
onLoad(options) {
@@ -100,10 +110,7 @@ Page({
onShow() {
console.log('[Index] onShow 触发')
const app = getApp()
this.setData({ auditMode: app.globalData.auditMode || false })
// 每次展示时刷新置顶人(管理端可能已更换置顶)
this.loadPinnedPerson()
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
@@ -136,48 +143,27 @@ Page({
this.loadBookData()
this.loadFeaturedAndLatest()
this.loadSuperMembers()
this.loadPinnedPerson()
},
async loadPinnedPerson() {
const app = getApp()
try {
const res = await app.request({ url: '/api/miniprogram/ckb/pinned-person', silent: true })
if (res && res.success && res.data) {
this.setData({ pinnedPerson: res.data })
} else {
this.setData({ pinnedPerson: null })
}
} catch (e) {
this.setData({ pinnedPerson: null })
}
},
async loadSuperMembers() {
this.setData({ superMembersLoading: true })
try {
// 并行请求 VIP 会员和普通用户,合并后取前 4 个VIP 优先)
const [vipRes, usersRes] = await Promise.all([
app.request({ url: '/api/miniprogram/vip/members', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/users?limit=20', silent: true }).catch(() => null)
])
// 仅走后端 VIP 列表排序vip_sort、vip_activated_at不在端上拼普通用户
const vipRes = await app.request({ url: '/api/miniprogram/vip/members?limit=24', silent: true }).catch(() => null)
let members = []
if (vipRes && vipRes.success && Array.isArray(vipRes.data) && vipRes.data.length > 0) {
members = vipRes.data.slice(0, 4).map(u => ({
id: u.id,
name: u.nickname || u.vipName || u.vip_name || '会员',
avatar: u.avatar || '',
isVip: true
}))
if (members.length > 0) console.log('[Index] 超级个体加载成功:', members.length, '')
}
if (members.length < 4 && usersRes && usersRes.success && Array.isArray(usersRes.data)) {
const existIds = new Set(members.map(m => m.id))
const extra = usersRes.data
.filter(u => u.avatar && u.nickname && !existIds.has(u.id))
.slice(0, 4 - members.length)
.map(u => ({ id: u.id, name: u.nickname, avatar: u.avatar, isVip: u.is_vip === 1 }))
members = members.concat(extra)
members = vipRes.data.map(u => {
const raw = u.name || u.nickname || u.vipName || u.vip_name || '会员'
const name = cleanSingleLineField(raw) || '会员'
return {
id: u.id,
name,
avatar: u.avatar || '',
isVip: true,
avatarLetter: superAvatarLetter(name)
}
}).filter((m) => !isKaruoHostDuplicateName(m.name))
console.log('[Index] 超级个体(后端排序):', members.length, '人')
}
this.setData({ superMembers: members, superMembersLoading: false })
} catch (e) {
@@ -186,53 +172,79 @@ Page({
}
},
// 精选推荐 + 最新更新 + 最新列表:一次请求 recommended + latest-chapters避免重复
// 精选推荐 + 最新更新 + 最新列表:顺序以后端为准(recommended=排行榜算法latest=updated_at
async loadFeaturedAndLatest() {
try {
const excludeFixed = (c) => {
const pt = (c.part_title || c.partTitle || '').toLowerCase()
return !pt.includes('序言') && !pt.includes('尾声') && !pt.includes('附录')
const tagClassForTag = (tag) => (tag === '热门' ? 'tag-hot' : 'tag-rec')
const toSectionFromRanking = (s) => {
const tag = s.tag || '精选'
return {
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag,
tagClass: tagClassForTag(tag)
}
}
const fallbackTags = ['热门', '推荐', '精选']
const toSectionFromHot = (s, i) => {
const tag = fallbackTags[i % 3]
return {
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag,
tagClass: tagClassForTag(tag)
}
}
const toSection = (s, i, tagMap = ['热门', '推荐', '精选']) => ({
id: s.id || s.section_id,
mid: s.mid ?? s.MID ?? 0,
title: s.section_title || s.sectionTitle || s.title || s.chapterTitle || '',
part: (s.part_title || s.partTitle || '').replace(/[_|]/g, ' ').trim(),
tag: s.tag || tagMap[i] || '精选',
tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i] || 'tag-rec'
})
const [rankRes, latestRes] = await Promise.all([
app.request({ url: '/api/miniprogram/book/ranking?limit=10', silent: true }).catch(() => null),
const [recRes, latestRes] = await Promise.all([
app.request({ url: '/api/miniprogram/book/recommended?limit=50', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/book/latest-chapters', silent: true }).catch(() => null)
])
// 1. 精选推荐10 条,默认5,展开显示 10 条
// 1. 精选推荐一次拉全量≤50,默认只显3;点列表下三角展开(与「最新新增」一致
let featuredFull = []
if (rankRes && rankRes.success && Array.isArray(rankRes.data) && rankRes.data.length > 0) {
featuredFull = rankRes.data.map((s, i) => toSection(s, i))
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
featuredFull = recRes.data.map((s) => toSectionFromRanking(s))
}
if (featuredFull.length === 0) {
try {
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=10', silent: true })
const hotRes = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
const hotList = (hotRes && hotRes.data) ? hotRes.data : []
if (hotList.length > 0) featuredFull = hotList.slice(0, 10).map((s, i) => toSection(s, i))
if (hotList.length > 0) featuredFull = hotList.map((s, i) => toSectionFromHot(s, i))
} catch (e) { console.log('[Index] book/hot 兜底失败:', e) }
}
if (featuredFull.length > 0) {
this.setData({
featuredSectionsFull: featuredFull,
featuredSections: featuredFull.slice(0, 5)
featuredSections: featuredFull.slice(0, 3),
featuredExpanded: false
})
} else {
this.setData({
featuredSectionsFull: [],
featuredSections: [],
featuredExpanded: false
})
}
// 2. 最新更新 + 最新列表(共用 latest-chapters 数据)
// 2. Banner 推荐:优先取 recommended 第一条,回退 latest 第一条
const rawList = (latestRes && latestRes.data) ? latestRes.data : []
const latestList = rawList.filter(excludeFixed)
if (latestList.length > 0) {
// 按更新时间倒序,最新在前(与后台展示一致)
const latestList = [...rawList].sort((a, b) => {
const ta = new Date(a.updatedAt || a.updated_at || 0).getTime()
const tb = new Date(b.updatedAt || b.updated_at || 0).getTime()
return tb - ta
})
if (featuredFull.length > 0) {
this.setData({ bannerSection: featuredFull[0] })
} else if (latestList.length > 0) {
const l = latestList[0]
this.setData({
latestSection: {
bannerSection: {
id: l.id,
mid: l.mid ?? l.MID ?? 0,
title: l.section_title || l.sectionTitle || l.title || l.chapterTitle || '',
@@ -359,12 +371,6 @@ Page({
return
}
const userId = app.globalData.userInfo.id
// 2 分钟内只能点一次(与后端限频一致)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
let phone = (app.globalData.userInfo.phone || '').trim()
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
if (!phone && !wechatId) {
@@ -464,11 +470,6 @@ Page({
wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
const app = getApp()
const userId = app.globalData.userInfo?.id
wx.showLoading({ title: '提交中...', mask: true })
@@ -526,23 +527,24 @@ Page({
wx.switchTab({ url: '/pages/match/match' })
},
// 精选推荐:展开/折叠(默认 5 条,展开显示 10 条
toggleFeaturedExpanded() {
trackClick('home', 'tab_click', this.data.featuredExpanded ? '精选收起' : '精选展开')
// 精选推荐:列表下方小三角展开(数据已在 loadFeaturedAndLatest 一次拉齐
expandFeaturedChapters() {
if (this.data.featuredExpanded) return
const full = this.data.featuredSectionsFull || []
if (this.data.featuredExpanded) {
this.setData({ featuredExpanded: false, featuredSections: full.slice(0, 5) })
} else {
this.setData({ featuredExpanded: true, featuredSections: full })
}
if (full.length <= 3) return
trackClick('home', 'tab_click', '精选展开_底部三角')
this.setData({ featuredExpanded: true, featuredSections: full })
},
// 最新新增:展开/折叠(默认 5 条,点击展开剩余
toggleLatestExpanded() {
trackClick('home', 'tab_click', this.data.latestExpanded ? '最新收起' : '最新展开')
const expanded = !this.data.latestExpanded
const display = expanded ? this.data.latestChapters : this.data.latestChapters.slice(0, 5)
this.setData({ latestExpanded: expanded, displayLatestChapters: display })
// 最新新增:列表下方小三角展开(无「收起」,展开后整页向下滚动查看
expandLatestChapters() {
if (this.data.latestExpanded) return
trackClick('home', 'tab_click', '最新展开_底部三角')
const full = this.data.latestChapters || []
this.setData({
latestExpanded: true,
displayLatestChapters: full
})
},
goToMemberDetail(e) {

View File

@@ -4,12 +4,12 @@
<!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部区域按设计稿S 图标 + 标题副标题 | 点击链接卡若 -->
<!-- 顶部区域:中文标识 + 标题副标题 | 链接卡若 -->
<view class="header">
<view class="header-content">
<view class="logo-section">
<view class="logo-icon">
<text class="logo-text">S</text>
<text class="logo-text"></text>
</view>
<view class="logo-info">
<text class="logo-title-text">卡若创业派对</text>
@@ -18,8 +18,8 @@
</view>
<view class="header-right">
<view class="contact-btn" bindtap="onLinkKaruo">
<image class="contact-avatar" src="{{pinnedPerson && pinnedPerson.avatar ? pinnedPerson.avatar : '/assets/images/author-avatar.png'}}" mode="aspectFill"/>
<text class="contact-text">点击链接{{pinnedPerson && pinnedPerson.nickname ? pinnedPerson.nickname : '卡若'}}</text>
<image class="contact-avatar" src="/assets/images/author-avatar.png" mode="aspectFill"/>
<text class="contact-name">点击链接卡若</text>
</view>
</view>
</view>
@@ -35,13 +35,13 @@
<!-- 主内容区 -->
<view class="main-content">
<!-- Banner卡片 - 最新章节(异步加载 -->
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<!-- 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-title">{{latestSection.title}}</view>
<view class="banner-title">{{bannerSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">点击阅读123</text>
<text class="banner-action-text">点击阅读</text>
<icon name="direction-right" size="32" color="#00CED1" customClass="banner-arrow"></icon>
</view>
</view>
@@ -53,10 +53,11 @@
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏 -->
<!-- 超级个体:与匹配页一致,仅 VIP 横向列表(无首位特例 -->
<view class="section" wx:if="{{!auditMode}}">
<view class="section-header">
<text class="section-title">超级个体</text>
<text class="section-subtitle">获客入口</text>
</view>
<!-- 加载中:骨架动画 -->
<view wx:if="{{superMembersLoading}}" class="super-loading">
@@ -76,12 +77,16 @@
wx:key="id"
bindtap="goToMemberDetail"
data-id="{{item.id}}"
hover-class="super-item-hover"
hover-stay-time="80"
>
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}}">
<image class="super-avatar-img" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<text class="super-avatar-text" wx:else>{{(item.name && item.name[0]) || '会'}}</text>
<view class="super-item-stack">
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}}">
<image class="super-avatar-img" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<text class="super-avatar-text" wx:else>{{item.avatarLetter}}</text>
</view>
<text class="super-name">{{item.name}}</text>
</view>
<text class="super-name">{{item.name}}</text>
</view>
</view>
</scroll-view>
@@ -92,14 +97,10 @@
</view>
</view>
<!-- 精选推荐(带 tag支持展开更多 -->
<!-- 精选推荐:默认 3 条,列表下三角展开更多(与最新新增一致 -->
<view class="section">
<view class="section-header">
<text class="section-title">精选推荐</text>
<view class="section-more" wx:if="{{featuredSectionsFull.length > 5}}" bindtap="toggleFeaturedExpanded">
<text class="more-text">{{featuredExpanded ? '收起' : '展开更多'}}</text>
<icon name="{{featuredExpanded ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)" customClass="more-arrow"></icon>
</view>
</view>
<view class="featured-list">
<view
@@ -111,30 +112,29 @@
data-mid="{{item.mid}}"
>
<view class="featured-content">
<view class="featured-meta">
<text class="featured-id brand-color">{{item.id}}</text>
<text class="featured-tag {{item.tagClass || 'tag-rec'}}" wx:if="{{item.tag}}">{{item.tag}}</text>
<view class="featured-meta" wx:if="{{item.tag}}">
<text class="featured-tag {{item.tagClass || 'tag-rec'}}">{{item.tag}}</text>
</view>
<text class="featured-title">{{item.title}}</text>
</view>
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.6)" customClass="featured-arrow"></icon>
</view>
</view>
<view
class="latest-expand-hint"
wx:if="{{!featuredExpanded && featuredSectionsFull.length > 3}}"
bindtap="expandFeaturedChapters"
hover-class="latest-expand-hint-hover"
hover-stay-time="80"
>
<view class="latest-expand-triangle"></view>
</view>
</view>
<!-- 最新新增(时间线样式,支持展开更多 -->
<!-- 最新新增(时间线样式;超过 5 条时在列表下方点小三角展开,展开后随页面滚动看全部 -->
<view class="section" wx:if="{{latestChapters.length > 0}}">
<view class="section-header latest-header">
<text class="section-title">最新新增</text>
<view class="section-header-right">
<view class="daily-badge-wrap">
<text class="daily-badge">+{{latestChapters.length}}</text>
</view>
<view class="section-more" wx:if="{{latestChapters.length > 5}}" bindtap="toggleLatestExpanded">
<text class="more-text">{{latestExpanded ? '收起' : '展开更多'}}</text>
<icon name="{{latestExpanded ? 'chevron-up' : 'chevron-down'}}" size="28" color="rgba(255,255,255,0.6)" customClass="more-arrow"></icon>
</view>
</view>
</view>
<view class="timeline-wrap">
<view class="timeline-line"></view>
@@ -149,6 +149,16 @@
</view>
</view>
</view>
<!-- 仅文案「展开更多」去掉:下方居中轻点小三角,点一次展开剩余条目 -->
<view
class="latest-expand-hint"
wx:if="{{latestChapters.length > 5 && !latestExpanded}}"
bindtap="expandLatestChapters"
hover-class="latest-expand-hint-hover"
hover-stay-time="80"
>
<view class="latest-expand-triangle"></view>
</view>
</view>
</view>

View File

@@ -63,26 +63,28 @@
.contact-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
padding: 8rpx 20rpx 8rpx 12rpx;
background: rgba(255, 255, 255, 0.08);
border: 2rpx solid rgba(255, 255, 255, 0.1);
border-radius: 40rpx;
font-size: 24rpx;
font-weight: 500;
gap: 8rpx;
padding: 12rpx 16rpx 10rpx;
background: rgba(255, 255, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.1);
border-radius: 20rpx;
color: #ffffff;
}
.contact-avatar {
width: 48rpx;
height: 48rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
border: 2rpx solid rgba(0, 206, 209, 0.35);
}
.contact-text {
font-size: 24rpx;
.contact-name {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
white-space: nowrap;
}
.logo-title {
@@ -330,6 +332,12 @@
color: #ffffff;
}
.section-subtitle {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.42);
font-weight: 400;
}
.section-more {
display: flex;
align-items: center;
@@ -445,6 +453,7 @@
font-size: 32rpx;
color: rgba(255, 255, 255, 0.3);
margin-top: 8rpx;
flex-shrink: 0;
}
/* ===== 内容概览列表 ===== */
@@ -608,9 +617,18 @@
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
min-width: 140rpx;
}
.super-item-hover {
opacity: 0.88;
}
.super-item-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
width: 100%;
}
.super-scroll .super-avatar {
width: 112rpx;
@@ -633,10 +651,11 @@
gap: 10rpx;
}
.super-avatar {
position: relative;
width: 108rpx;
height: 108rpx;
border-radius: 50%;
overflow: hidden;
overflow: visible;
background: rgba(0,206,209,0.1);
display: flex;
align-items: center;
@@ -647,10 +666,33 @@
border: 3rpx solid #FFD700;
box-shadow: 0 0 12rpx rgba(255,215,0,0.3);
}
.super-avatar-pinned {
border: 3rpx solid #38bdac;
box-shadow: 0 0 16rpx rgba(56, 189, 172, 0.4);
}
.super-item-pinned .super-name {
color: #38bdac;
}
.pinned-badge {
position: absolute;
bottom: -4rpx;
right: -4rpx;
width: 28rpx;
height: 28rpx;
background: #38bdac;
border-radius: 50%;
font-size: 18rpx;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.super-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.super-avatar-text {
font-size: 40rpx;
@@ -688,15 +730,10 @@
margin-bottom: 32rpx;
}
.section-header-right {
display: flex;
align-items: center;
gap: 16rpx;
}
.daily-badge-wrap {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
/* 设计稿 1:1橙底白字 rounded-full */
.daily-badge {
@@ -706,10 +743,29 @@
font-weight: 700;
padding: 8rpx 20rpx;
border-radius: 999rpx;
margin-left: 8rpx;
box-shadow: 0 4rpx 16rpx rgba(246, 173, 85, 0.3);
}
/* 列表下方:仅小三角,点击展开(替代标题栏「展开更多」) */
.latest-expand-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 8rpx 0 16rpx;
margin-top: 8rpx;
}
.latest-expand-hint-hover {
opacity: 0.65;
}
/* 向下小三角 */
.latest-expand-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);
}
/* 设计稿 1:1pl-3 竖线 left-3 top-2 bottom-2 w-[1px] bg-gray-800 */
.timeline-wrap {
position: relative;
@@ -844,6 +900,64 @@
height: 26rpx;
}
/* 展开/收起按钮 */
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 20rpx 0;
margin-top: 8rpx;
}
.expand-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
}
.expand-icon {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
/* 热度排行 */
.hot-list {
background: #1c1c1e;
border-radius: 24rpx;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.04);
}
.hot-item {
display: flex;
align-items: center;
padding: 24rpx 28rpx;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.04);
}
.hot-item:last-child {
border-bottom: none;
}
.hot-rank {
width: 48rpx;
font-size: 28rpx;
font-weight: 700;
color: rgba(255, 255, 255, 0.4);
margin-right: 20rpx;
}
.hot-rank-top {
color: #38bdac;
}
.hot-title {
flex: 1;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.hot-price {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.4);
margin-left: 16rpx;
}
/* ===== 底部留白 ===== */
.bottom-space {
height: 40rpx;

View File

@@ -6,6 +6,7 @@ Page({
title: '链接预览',
statusBarHeight: 44,
navBarHeight: 88,
loadError: false,
},
onLoad(options) {
@@ -19,6 +20,26 @@ Page({
})
},
onWebViewError() {
this.setData({ loadError: true })
wx.showModal({
title: '无法在小程序内打开',
content: '该链接无法在小程序内预览,是否复制链接到浏览器打开?',
confirmText: '复制链接',
cancelText: '返回',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: this.data.url,
success: () => wx.showToast({ title: '链接已复制,请在浏览器打开', icon: 'none', duration: 2000 }),
})
} else {
this.goBack()
}
},
})
},
goBack() {
const pages = getCurrentPages()
if (pages.length > 1) {

View File

@@ -18,8 +18,14 @@
<view class="nav-placeholder" style="height: {{navBarHeight}}px;"></view>
<!-- 链接预览区域 -->
<view class="webview-wrap" wx:if="{{url}}">
<web-view src="{{url}}"></web-view>
<view class="webview-wrap" wx:if="{{url && !loadError}}">
<web-view src="{{url}}" binderror="onWebViewError"></web-view>
</view>
<view class="error-wrap" wx:elif="{{loadError}}">
<text class="error-text">该链接无法在小程序内预览</text>
<view class="error-btn" bindtap="copyLink">
<text class="error-btn-text">复制链接到浏览器打开</text>
</view>
</view>
<view class="empty-wrap" wx:else>
<text class="empty-text">暂无链接地址</text>

View File

@@ -81,3 +81,27 @@ web-view {
font-size: 26rpx;
}
.error-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 80rpx 40rpx;
}
.error-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.error-btn {
padding: 16rpx 40rpx;
border-radius: 999rpx;
background: #38bdac;
}
.error-btn-text {
font-size: 26rpx;
color: #fff;
}

View File

@@ -226,6 +226,17 @@ 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) {
wx.showModal({
title: '完善头像',
content: '请先设置头像后再使用匹配功能',
confirmText: '去设置',
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()
if (phone && /^1[3-9]\d{9}$/.test(phone)) {
@@ -311,7 +322,7 @@ Page({
confirmText: '去购买',
success: (res) => {
if (res.confirm) {
wx.switchTab({ url: '/pages/catalog/catalog' })
wx.switchTab({ url: '/pages/chapters/chapters' })
}
}
})
@@ -475,34 +486,6 @@ Page({
}, delay)
},
// 生成模拟匹配数据
generateMockMatch() {
const nicknames = ['创业先锋', '资源整合者', '私域专家', '导师顾问', '连续创业者']
const concepts = [
'专注私域流量运营5年帮助100+品牌实现从0到1的增长。',
'连续创业者,擅长商业模式设计和资源整合。',
'在Soul分享真实创业故事希望找到志同道合的合作伙伴。'
]
const wechats = ['soul_partner_1', 'soul_business_2024', 'soul_startup_fan']
const index = Math.floor(Math.random() * nicknames.length)
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
return {
id: `user_${Date.now()}`,
nickname: nicknames[index],
avatar: `https://picsum.photos/200/200?random=${Date.now()}`,
tags: ['创业者', '私域运营', currentType?.label || '创业合伙'],
matchScore: Math.floor(Math.random() * 20) + 80,
concept: concepts[index % concepts.length],
wechat: wechats[index % wechats.length],
commonInterests: [
{ icon: 'book-open', text: '都在读《创业派对》' },
{ icon: 'briefcase', text: '对私域运营感兴趣' },
{ icon: 'target', text: '相似的创业方向' }
]
}
},
// 上报匹配行为
async reportMatch(matchedUser) {
@@ -646,18 +629,10 @@ Page({
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
} else {
// 即使API返回失败也模拟成功因为已保存本地
this.setData({ joinSuccess: true })
setTimeout(() => {
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
}
} catch (e) {
// 网络错误时也模拟成功
this.setData({ joinSuccess: true })
setTimeout(() => {
this.setData({ showJoinModal: false, joinSuccess: false })
}, 2000)
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
} finally {
this.setData({ isJoining: false })
}
@@ -734,19 +709,7 @@ Page({
if (e.errMsg && e.errMsg.includes('cancel')) {
wx.showToast({ title: '已取消', icon: 'none' })
} else {
// 测试模式
wx.showModal({
title: '支付服务暂不可用',
content: '是否使用测试模式购买?',
success: (res) => {
if (res.confirm) {
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
wx.setStorageSync('extra_match_count', extraMatches)
wx.showToast({ title: '测试购买成功', icon: 'success' })
this.initUserStatus()
}
}
})
wx.showToast({ title: '支付失败,请重试', icon: 'none' })
}
}
},
@@ -757,10 +720,6 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 打开资料修改页(找伙伴右上角图标)
openSettings() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
// 阻止事件冒泡
preventBubble() {},

View File

@@ -12,7 +12,7 @@
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部留白,让内容往下 -->
<view style="height: 30rpx;"></view>
<view style="height: 16rpx;"></view>
<!-- 匹配提示条 - 简化显示 -->
<view class="match-tip-bar" wx:if="{{matchesRemaining <= 0 && !hasFullBook}}">

View File

@@ -5,15 +5,17 @@
* mbti, region, industry, position, businessScale, skills,
* storyBestMonth→bestMonth, storyAchievement→achievement, storyTurning→turningPoint,
* helpOffer→canHelp, helpNeed→needHelp
* 点头像:若后台 persons.user_id 已绑定则带 ckbLeadToken走存客宝 CKBLead与阅读页 @ 一致)
*/
const app = getApp()
Page({
data: { statusBarHeight: 44, member: null, loading: true },
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true },
onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
const sb = app.globalData.statusBarHeight || 44
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44 })
if (options.id) this.loadMember(options.id)
},
@@ -44,6 +46,7 @@ Page({
storyTurning: u.storyTurning || u.story_turning,
helpOffer: u.helpOffer || u.help_offer,
helpNeed: u.helpNeed || u.help_need,
ckbLeadToken: u.ckbLeadToken || u.ckb_lead_token,
}), loading: false })
return
}
@@ -79,7 +82,8 @@ Page({
turningPoint: e(raw.turningPoint || raw.storyTurning || raw.story_turning),
canHelp: e(raw.canHelp || raw.helpOffer || raw.help_offer),
needHelp: e(raw.needHelp || raw.helpNeed || raw.help_need),
project: e(raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro)
project: e(raw.project || raw.vipProject || raw.vip_project || raw.projectIntro || raw.project_intro),
ckbLeadToken: String(raw.ckbLeadToken || raw.ckb_lead_token || '').trim()
}
const contact = merged.contactRaw || ''
@@ -130,6 +134,189 @@ Page({
return d.contact || d.wechat
},
/** VIP 或本会员首次免费:写入解锁;否则弹开通 VIP */
_tryFreeUnlock(member, field) {
const isVip = app.globalData.isVip
const usedFree = this._hasUsedFreeForMember(member.id)
if (isVip || !usedFree) {
this._addUnlock(member.id, field)
return true
}
wx.showModal({
title: '解锁' + (field === 'contact' ? '联系方式' : '微信号'),
content: '您的免费解锁次数已用完开通VIP会员¥1980/年)可无限解锁',
confirmText: '去开通',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/vip/vip' }) }
})
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' })
},
/** 与 read 页 _doMentionAddFriend 一致targetUserId = Person.token */
async _doCkbLeadSubmit(targetUserId, targetNickname) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return
}
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '完善资料',
content: '请先填写手机号(必填),以便对方通过获客计划联系您',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
source: 'member_detail_avatar'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
},
_ensureUnlockedForLink(field) {
const member = this.data.member
if (!member?.id || (field !== 'contact' && field !== 'wechat')) return false
if (field === 'wechat' && member.wechatUnlocked) return true
if (field === 'contact' && member.contactUnlocked) return true
if (!app.globalData.isLoggedIn) {
wx.showModal({
title: '需要登录',
content: field === 'wechat'
? '登录后可解锁并复制对方微信号,再按步骤去微信添加好友。'
: '登录后可解锁并复制对方手机号,便于添加好友或回拨。',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return false
}
const d = this._getUnlockData(member.id)
if ((field === 'wechat' && d.wechat) || (field === 'contact' && d.contact)) {
this.setData({ member: this.enrichAndFormat(member) })
return true
}
if (!this._tryFreeUnlock(member, field)) return false
this.setData({ member: this.enrichAndFormat(member) })
return true
},
_copyAndGuideWechat(wechatId) {
if (!wechatId) return
wx.setClipboardData({
data: String(wechatId),
success: () => {
wx.hideToast()
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '添加微信好友',
content: '微信号已复制。\n\n请打开微信 → 右上角「+」→ 添加朋友 → 粘贴搜索并添加。',
showCancel: false,
confirmText: '知道了'
})
}, 120)
},
fail: () => wx.showToast({ title: '复制失败', icon: 'none' })
})
},
_copyAndGuidePhone(phone) {
if (!phone) return
wx.setClipboardData({
data: String(phone),
success: () => {
wx.hideToast()
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '联系对方',
content: '手机号已复制。\n\n可打开微信「添加朋友」搜索手机号或使用手机拨号联系对方。',
showCancel: false,
confirmText: '知道了'
})
}, 120)
},
fail: () => wx.showToast({ title: '复制失败', icon: 'none' })
})
},
unlockField(e) {
const field = e.currentTarget.dataset.field
if (!field) return
@@ -148,22 +335,25 @@ Page({
}
const d = this._getUnlockData(member.id)
if (d[field]) return
const isVip = app.globalData.isVip
const usedFree = this._hasUsedFreeForMember(member.id)
if (isVip || !usedFree) {
this._addUnlock(member.id, field)
if (this._tryFreeUnlock(member, field)) {
const m = this.enrichAndFormat(member)
this.setData({ member: m })
wx.showToast({ title: field === 'contact' ? '已解锁联系方式' : '已解锁微信号', icon: 'success' })
return
}
wx.showModal({
title: '解锁' + (field === 'contact' ? '联系方式' : '微信号'),
content: '您的免费解锁次数已用完开通VIP会员¥1980/年)可无限解锁',
confirmText: '去开通',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/vip/vip' }) }
})
},
tapContactRow() {
const m = this.data.member
if (!m || !(m.contactRaw || m.contactDisplay)) return
if (m.contactUnlocked) this.copyContact()
else this.unlockField({ currentTarget: { dataset: { field: 'contact' } } })
},
tapWechatRow() {
const m = this.data.member
if (!m || !(m.wechatRaw || m.wechatDisplay)) return
if (m.wechatUnlocked) this.copyWechat()
else this.unlockField({ currentTarget: { dataset: { field: 'wechat' } } })
},
copyContact() {

View File

@@ -1,6 +1,5 @@
<!-- 卡若创业派对 - 超级个体详情(按 enhanced_professional_profile 1: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="nav-icon"></icon>
@@ -10,135 +9,176 @@
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<scroll-view scroll-y class="scroll-wrap" wx:if="{{member}}">
<!-- 顶部 profile 卡片 -->
<view class="card-profile">
<view class="profile-deco"></view>
<view class="profile-body">
<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>
<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="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>
<view class="vip-tag" wx:if="{{member.isVip}}">VIP</view>
</view>
<text class="profile-name">{{member.name}}</text>
<view class="profile-tags" 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="24" color="currentColor" customClass="pin-icon"></icon><text>{{member.region}}</text></view>
</view>
<view class="contact-rows contact-rows-subtle">
<view
class="link-chip link-chip-subtle {{member.contactUnlocked ? 'link-chip-open' : ''}}"
wx:if="{{member.contactRaw || member.contactDisplay}}"
catchtap="tapContactRow"
>
<view class="link-chip-icon link-chip-icon-phone link-chip-icon-subtle">
<icon name="smartphone" size="26" color="rgba(148,163,184,0.85)" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label link-chip-label-subtle">手机</text>
<text class="link-chip-val mono link-chip-val-subtle">{{member.contactDisplay || member.contactRaw}}</text>
</view>
<view class="link-chip-action link-chip-action-subtle">
<text>{{member.contactUnlocked ? '复制' : '解锁'}}</text>
<icon wx:if="{{!member.contactUnlocked}}" name="chevron-right" size="22" color="rgba(100,116,139,0.8)" customClass="lc-arr"></icon>
</view>
</view>
<view
class="link-chip link-chip-subtle {{member.wechatUnlocked ? 'link-chip-open' : ''}}"
wx:if="{{member.wechatRaw || member.wechatDisplay}}"
catchtap="tapWechatRow"
>
<view class="link-chip-icon link-chip-icon-wx link-chip-icon-subtle">
<icon name="message-circle" size="26" color="rgba(148,163,184,0.85)" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label link-chip-label-subtle">微信</text>
<text class="link-chip-val mono link-chip-val-subtle">{{member.wechatDisplay || member.wechatRaw}}</text>
</view>
<view class="link-chip-action link-chip-action-subtle">
<text>{{member.wechatUnlocked ? '复制' : '解锁'}}</text>
<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 class="card" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.contactRaw || member.contactDisplay || member.wechatRaw || member.wechatDisplay}}">
<view class="card-head">
<icon name="user" size="48" color="#00CED1" customClass="card-icon"></icon>
<text class="card-label">基本信息</text>
</view>
<view class="card-body">
<view class="field" wx:if="{{member.industry}}">
<text class="f-key">行业</text>
<text class="f-val">{{member.industry}}</text>
<!-- 一体化信息区(单卡片内分区 -->
<view class="mono-card mono-card-compact" wx:if="{{member.industry || member.position || member.businessScale || member.skills || member.bestMonth || member.achievement || member.turningPoint || member.canHelp || member.needHelp || member.project}}">
<!-- 职业画像 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.industry || member.position || member.businessScale}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">职业画像</text>
</view>
<view class="field" wx:if="{{member.position}}">
<text class="f-key">职位</text>
<text class="f-val">{{member.position}}</text>
</view>
<view class="field" wx:if="{{member.businessScale}}">
<text class="f-key">业务体量</text>
<text class="f-val">{{member.businessScale}}</text>
</view>
<view class="divider" wx:if="{{member.industry || member.position || member.businessScale}}"></view>
<view class="field" wx:if="{{member.skills}}">
<text class="f-key">我擅长</text>
<text class="f-val">{{member.skills}}</text>
</view>
<view class="field" wx:if="{{member.contactRaw || member.contactDisplay}}">
<text class="f-key">联系方式</text>
<view class="f-row">
<text class="f-val mono">{{member.contactUnlocked ? member.contactFull : (member.contactDisplay || member.contactRaw)}}</text>
<view class="icon-copy icon-eye-off" wx:if="{{member.contactRaw && !member.contactUnlocked}}" bindtap="unlockField" data-field="contact">
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
</view>
<view class="icon-copy" wx:elif="{{member.contactRaw && member.contactUnlocked}}" bindtap="copyContact"><icon name="clipboard" size="32" color="#00CED1"></icon></view>
<view class="kv-grid">
<view class="kv-cell" wx:if="{{member.industry}}">
<text class="kv-k">行业</text>
<text class="kv-v">{{member.industry}}</text>
</view>
</view>
<view class="field" wx:if="{{member.wechatRaw || member.wechatDisplay}}">
<text class="f-key">微信号</text>
<view class="f-row">
<text class="f-val mono">{{member.wechatUnlocked ? member.wechatFull : (member.wechatDisplay || member.wechatRaw)}}</text>
<view class="icon-copy icon-eye-off" wx:if="{{member.wechatRaw && !member.wechatUnlocked}}" bindtap="unlockField" data-field="wechat">
<image class="icon-img" src="/assets/icons/eye-off.svg" mode="aspectFit"/>
</view>
<view class="icon-copy" wx:elif="{{member.wechatRaw && member.wechatUnlocked}}" bindtap="copyWechat"><icon name="clipboard" size="32" color="#00CED1"></icon></view>
<view class="kv-cell" wx:if="{{member.position}}">
<text class="kv-k">职位</text>
<text class="kv-v">{{member.position}}</text>
</view>
<view class="kv-cell kv-cell-full" wx:if="{{member.businessScale}}">
<text class="kv-k">业务体量</text>
<text class="kv-v">{{member.businessScale}}</text>
</view>
</view>
</view>
</view>
<!-- 个人故事(未填写行已隐藏) -->
<view class="card" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
<view class="card-head">
<icon name="lightbulb" size="48" color="#FFD700" customClass="card-icon bulb"></icon>
<text class="card-label">个人故事</text>
<view class="mono-divider" wx:if="{{(member.industry || member.position || member.businessScale) && member.skills}}"></view>
<!-- 核心能力 -->
<view class="mono-sec mono-sec-tight skills-showcase" wx:if="{{member.skills}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">我擅长</text>
</view>
<view class="skills-quote">
<text class="skills-quote-text">{{member.skills}}</text>
</view>
</view>
<view class="card-body">
<view class="story" wx:if="{{member.bestMonth}}">
<view class="story-head"><icon name="trophy" size="28" color="#FFD700" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月做的是什么</text></view>
<view class="mono-divider" wx:if="{{member.skills && (member.bestMonth || member.achievement || member.turningPoint)}}"></view>
<!-- 个人故事 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.bestMonth || member.achievement || member.turningPoint}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">个人故事</text>
</view>
<view class="story story-compact" wx:if="{{member.bestMonth}}">
<view class="story-head"><icon name="trophy" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月</text></view>
<text class="story-a">{{member.bestMonth}}</text>
</view>
<view class="divider" wx:if="{{member.bestMonth}}"></view>
<view class="story" wx:if="{{member.achievement}}">
<view class="story-head"><icon name="star" size="28" color="#FFD700" customClass="story-icon"></icon><text class="story-q">最有成就感的一件事</text></view>
<view class="story-gap story-gap-tight" wx:if="{{member.bestMonth && (member.achievement || member.turningPoint)}}"></view>
<view class="story story-compact" wx:if="{{member.achievement}}">
<view class="story-head"><icon name="star" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最有成就感的事</text></view>
<text class="story-a">{{member.achievement}}</text>
</view>
<view class="divider" wx:if="{{member.achievement}}"></view>
<view class="story" wx:if="{{member.turningPoint}}">
<view class="story-head"><icon name="refresh-cw" size="28" color="#FFD700" customClass="story-icon turn"></icon><text class="story-q">人生的转折点</text></view>
<view class="story-gap story-gap-tight" wx:if="{{member.achievement && member.turningPoint}}"></view>
<view class="story story-compact" wx:if="{{member.turningPoint}}">
<view class="story-head"><icon name="refresh-cw" size="24" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">人生的转折点</text></view>
<text class="story-a">{{member.turningPoint}}</text>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.bestMonth || member.achievement || member.turningPoint) && (member.canHelp || member.needHelp)}}"></view>
<!-- 互助 -->
<view class="mono-sec mono-sec-tight" wx:if="{{member.canHelp || member.needHelp}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">互助需求</text>
</view>
<view class="help-grid">
<view class="help-tile help-give" wx:if="{{member.canHelp}}">
<text class="help-tile-tag">我能帮你</text>
<text class="help-tile-txt">{{member.canHelp}}</text>
</view>
<view class="help-tile help-need" wx:if="{{member.needHelp}}">
<text class="help-tile-tag need">我需要</text>
<text class="help-tile-txt">{{member.needHelp}}</text>
</view>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.canHelp || member.needHelp) && member.project}}"></view>
<view class="mono-sec mono-sec-tight" wx:if="{{member.project}}">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">项目介绍</text>
</view>
<text class="proj-body proj-body-compact">{{member.project}}</text>
</view>
</view>
<!-- 互助需求(未填写行已隐藏 -->
<view class="card" wx:if="{{member.canHelp || member.needHelp}}">
<view class="card-head">
<icon name="handshake" size="48" color="#00CED1" customClass="card-icon"></icon>
<text class="card-label">互助需求</text>
</view>
<view class="card-body">
<view class="help-box help-give" wx:if="{{member.canHelp}}">
<text class="help-tag">我能帮你</text>
<text class="help-txt">{{member.canHelp}}</text>
<!-- 底部:分享 + 双入口(同一视觉块 -->
<view class="footer-panel">
<view class="footer-pills">
<view class="pill pill-gold" bindtap="goToVip">
<icon name="sparkles" size="30" color="#FBBF24" customClass="pill-ic"></icon>
<text class="pill-txt">成为超级个体</text>
</view>
<view class="help-box help-need" wx:if="{{member.needHelp}}">
<text class="help-tag need">我需要帮助</text>
<text class="help-txt">{{member.needHelp}}</text>
<view class="pill pill-teal" bindtap="goToMatch">
<icon name="users" size="30" color="#5EEAD4" customClass="pill-ic"></icon>
<text class="pill-txt">找更多伙伴</text>
</view>
</view>
</view>
<!-- 项目介绍 -->
<view class="card" wx:if="{{member.project}}">
<view class="card-head">
<icon name="rocket" size="48" color="#00CED1" customClass="card-icon rocket"></icon>
<text class="card-label">项目介绍</text>
</view>
<text class="proj-txt">{{member.project}}</text>
</view>
<!-- 底部按钮 -->
<view class="bottom-wrap">
<view class="btn-super" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="36" color="#F59E0B" customClass="btn-arrow"></icon>
</view>
</view>
<view style="height:160rpx;"></view>
<view class="scroll-pad"></view>
</scroll-view>
<!-- 加载和空状态 -->
<view class="state-wrap" wx:if="{{loading}}">
<view class="loading-dot"></view>
<text class="state-txt">加载中...</text>

View File

@@ -1,172 +1,629 @@
/* 卡若创业派对 - 个人资料页enhanced_professional_profile 1:1 还原 */
.page { background: #050B14; min-height: 100vh; color: #fff; }
/* 导航栏 */
.nav-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 999;
display: flex; align-items: center; justify-content: space-between;
padding: 0 32rpx; height: 44px;
background: rgba(5, 11, 20, 0.9);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
/* 卡若创业派对 - 超级个体详情(高阶单页 · 深色玻璃态 */
.page {
background: radial-gradient(120% 80% at 50% -20%, rgba(20, 80, 90, 0.35) 0%, #050B14 45%);
min-height: 100vh;
color: #fff;
}
.nav-back { width: 80rpx; height: 80rpx; display: flex; align-items: center; justify-content: flex-start; }
.nav-icon { font-size: 44rpx; color: #5EEAD4; font-weight: 300; }
.nav-title { font-size: 34rpx; font-weight: 700; color: #fff; letter-spacing: 2rpx; }
.nav-right { display: flex; align-items: center; gap: 16rpx; }
.nav-icon-wrap { padding: 8rpx; }
.nav-icon-dot { font-size: 28rpx; color: rgba(255,255,255,0.8); }
.scroll-wrap { height: calc(100vh - 88px); }
/* —— 导航 —— */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 999;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28rpx;
height: 44px;
background: rgba(5, 11, 20, 0.72);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: flex-start;
}
.nav-icon {
font-size: 44rpx;
color: #5eead4;
font-weight: 300;
}
.nav-title {
font-size: 32rpx;
font-weight: 600;
color: #f8fafc;
letter-spacing: 4rpx;
}
.nav-placeholder {
width: 72rpx;
}
/* ===== 顶部 Profile 卡片 ===== */
.card-profile {
position: relative; margin: 32rpx 32rpx 0;
padding: 64rpx 40rpx 48rpx;
.scroll-wrap {
box-sizing: border-box;
}
/* —— 首屏外壳(头像 + 链接列) —— */
.shell {
position: relative;
margin: 28rpx 24rpx 0;
padding: 40rpx 32rpx 36rpx;
border-radius: 32rpx;
background: #0F1720;
border: 1rpx solid rgba(255, 255, 255, 0.08);
background: linear-gradient(145deg, rgba(22, 36, 48, 0.95) 0%, rgba(12, 20, 32, 0.98) 100%);
border: 1rpx solid rgba(94, 234, 212, 0.12);
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.45), inset 0 1rpx 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.profile-deco {
position: absolute; top: 0; left: 0; right: 0; height: 128rpx;
background: linear-gradient(180deg, rgba(30, 58, 69, 0.3) 0%, transparent 100%);
.shell-glow {
position: absolute;
top: -40%;
right: -20%;
width: 70%;
height: 80%;
background: radial-gradient(circle, rgba(45, 212, 191, 0.12) 0%, transparent 70%);
pointer-events: none;
}
.profile-body { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
.hero-profile {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 8rpx;
}
.hero-avatar-block {
display: flex;
flex-direction: column;
align-items: center;
max-width: 520rpx;
}
.hero-avatar-block-hover {
opacity: 0.94;
}
.contact-rows {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 32rpx;
}
.contact-rows-subtle {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
}
.avatar-outer {
position: relative;
width: 176rpx; height: 176rpx;
margin-bottom: 32rpx;
width: 168rpx;
height: 168rpx;
flex-shrink: 0;
}
.avatar-wrap {
position: relative;
width: 100%; height: 100%;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.1);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.4);
border: 2rpx solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.35);
}
.avatar-wrap.vip-ring {
border: 4rpx solid transparent;
background: linear-gradient(135deg, #F59E0B, #5EEAD4, #F59E0B);
border: 3rpx solid transparent;
background: linear-gradient(135deg, #f59e0b, #5eead4, #f59e0b);
background-size: 200% 200%;
animation: vipGlow 4s ease infinite;
animation: vipGlow 5s ease infinite;
}
@keyframes vipGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.avatar-img { width: 100%; height: 100%; object-fit: cover; }
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-ph {
width: 100%; height: 100%;
background: #17212F;
display: flex; align-items: center; justify-content: center;
font-size: 56rpx; color: #5EEAD4; font-weight: 700;
width: 100%;
height: 100%;
background: #1a2332;
display: flex;
align-items: center;
justify-content: center;
font-size: 52rpx;
color: #5eead4;
font-weight: 700;
}
.vip-tag {
position: absolute; bottom: -4rpx; right: -4rpx;
background: linear-gradient(135deg, #F59E0B, #e8920d);
color: #000; font-size: 20rpx; font-weight: 800;
padding: 6rpx 14rpx; border-radius: 16rpx;
position: absolute;
bottom: -2rpx;
right: -2rpx;
background: linear-gradient(135deg, #fbbf24, #d97706);
color: #0f172a;
font-size: 18rpx;
font-weight: 800;
padding: 6rpx 12rpx;
border-radius: 12rpx;
z-index: 2;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
box-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.35);
}
.profile-name { font-size: 40rpx; font-weight: 700; color: #fff; margin-bottom: 24rpx; letter-spacing: 2rpx; }
.profile-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; flex-wrap: wrap; }
.tag { font-size: 24rpx; font-weight: 500; padding: 8rpx 24rpx; border-radius: 999rpx; }
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94, 234, 212, 0.2); }
.tag-region { background: #1F2937; color: #D1D5DB; border: 1rpx solid rgba(255, 255, 255, 0.1); display: flex; align-items: center; gap: 8rpx; }
.pin-icon { color: #EF4444; font-size: 22rpx; }
/* ===== 通用卡片 ===== */
.card {
margin: 32rpx;
padding: 40rpx 40rpx;
border-radius: 32rpx;
background: #0F1720;
border: 1rpx solid rgba(255, 255, 255, 0.08);
.link-chip {
display: flex;
flex-direction: row;
align-items: center;
gap: 20rpx;
padding: 22rpx 22rpx;
border-radius: 20rpx;
background: rgba(15, 23, 42, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
transition: border-color 0.2s;
}
.card-head { display: flex; align-items: center; gap: 20rpx; margin-bottom: 40rpx; }
.card-icon { font-size: 40rpx; }
.card-icon.bulb { filter: sepia(1) saturate(3) hue-rotate(15deg); }
.card-icon.rocket { opacity: 0.9; }
.card-label { font-size: 30rpx; font-weight: 700; color: #fff; letter-spacing: 1rpx; }
.card-body { }
.field { margin-bottom: 32rpx; }
.field:last-child { margin-bottom: 0; }
.f-key { display: block; font-size: 26rpx; color: #94A3B8; margin-bottom: 12rpx; }
.f-val { font-size: 30rpx; font-weight: 500; color: #fff; line-height: 1.6; }
.f-val.mono { font-family: ui-monospace, monospace; letter-spacing: 2rpx; }
.f-row { display: flex; align-items: center; gap: 16rpx; }
.icon-copy { font-size: 36rpx; color: #94A3B8; opacity: 0.6; padding: 8rpx; }
.icon-eye-off { display: flex; align-items: center; justify-content: center; }
.icon-eye-off .icon-img { width: 40rpx; height: 40rpx; }
.divider { height: 1rpx; background: rgba(255, 255, 255, 0.05); margin: 32rpx 0; }
/* ===== 个人故事 ===== */
.story { margin-bottom: 32rpx; }
.story:last-child { margin-bottom: 0; }
.story-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
.story-icon { font-size: 32rpx; }
.story-icon.turn { opacity: 0.9; }
.story-q { font-size: 26rpx; font-weight: 500; color: #94A3B8; }
.story-a { display: block; font-size: 28rpx; color: #E5E7EB; line-height: 1.7; }
/* ===== 互助需求 ===== */
.help-box {
padding: 32rpx;
border-radius: 24rpx;
margin-bottom: 24rpx;
background: #17212F;
border: 1rpx solid rgba(255, 255, 255, 0.05);
.link-chip-subtle {
gap: 16rpx;
padding: 14rpx 8rpx 14rpx 12rpx;
border-radius: 0;
background: transparent;
border: none;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.05);
}
.help-box:last-child { margin-bottom: 0; }
.help-tag {
display: inline-block;
font-size: 22rpx; font-weight: 600;
padding: 6rpx 16rpx; border-radius: 12rpx;
margin-bottom: 16rpx;
.link-chip-subtle:last-of-type {
border-bottom: none;
}
.help-give .help-tag { color: #5EEAD4; background: #112D2A; }
.help-need .help-tag { color: #F59E0B; background: #2D1F0D; }
.help-txt { display: block; font-size: 26rpx; color: #fff; line-height: 1.6; letter-spacing: 1rpx; }
/* ===== 项目介绍 ===== */
.proj-txt { font-size: 28rpx; color: #E5E7EB; line-height: 1.7; }
/* ===== 底部按钮 ===== */
.bottom-wrap {
padding: 48rpx 32rpx 0;
.link-chip-subtle:active {
background: rgba(255, 255, 255, 0.03);
}
.btn-super {
.link-chip-open.link-chip-subtle {
border-bottom-color: rgba(255, 255, 255, 0.05);
}
.link-chip:active {
border-color: rgba(94, 234, 212, 0.35);
background: rgba(15, 30, 40, 0.75);
}
.link-chip-subtle:active {
border-color: transparent;
}
.link-chip-open {
border-color: rgba(94, 234, 212, 0.25);
}
.link-chip-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.link-chip-icon-subtle {
width: 48rpx;
height: 48rpx;
border-radius: 12rpx;
background: rgba(255, 255, 255, 0.04);
}
.link-chip-icon-phone {
background: rgba(45, 212, 191, 0.12);
}
.link-chip-icon-wx {
background: rgba(52, 211, 153, 0.12);
}
.link-chip-icon-subtle.link-chip-icon-phone,
.link-chip-icon-subtle.link-chip-icon-wx {
background: rgba(255, 255, 255, 0.04);
}
.lc-ic {
display: block;
}
.link-chip-main {
flex: 1;
min-width: 0;
}
.link-chip-label {
display: block;
font-size: 20rpx;
color: #94a3b8;
margin-bottom: 6rpx;
}
.link-chip-label-subtle {
font-size: 18rpx;
color: rgba(100, 116, 139, 0.85);
margin-bottom: 4rpx;
}
.link-chip-val {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #f8fafc;
line-height: 1.35;
word-break: break-all;
}
.link-chip-val-subtle {
font-size: 22rpx;
font-weight: 400;
color: rgba(148, 163, 184, 0.92);
}
.link-chip-val.mono {
font-family: ui-monospace, monospace;
letter-spacing: 1rpx;
}
.link-chip-action {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4rpx;
font-size: 22rpx;
font-weight: 600;
color: #5eead4;
}
.link-chip-action-subtle {
font-size: 20rpx;
font-weight: 500;
color: rgba(100, 116, 139, 0.95);
}
.lc-arr {
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;
display: block;
text-align: center;
margin-top: 20rpx;
width: 100%;
font-size: 32rpx;
font-weight: 700;
color: #fff;
letter-spacing: 2rpx;
line-height: 1.35;
word-break: break-all;
}
.profile-tags {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
flex-wrap: wrap;
margin-top: 16rpx;
width: 100%;
padding: 32rpx 0;
border-radius: 999rpx;
background: transparent;
border: 1rpx solid rgba(245, 158, 11, 0.3);
color: #F59E0B;
font-size: 30rpx; font-weight: 500;
letter-spacing: 2rpx;
}
.btn-arrow { font-size: 36rpx; font-weight: 300; }
.profile-tags-modern {
gap: 10rpx;
margin-top: 18rpx;
}
.tag {
font-size: 22rpx;
font-weight: 500;
padding: 8rpx 20rpx;
border-radius: 999rpx;
}
.profile-tags-modern .tag-mbti {
font-size: 20rpx;
font-weight: 600;
letter-spacing: 2rpx;
padding: 6rpx 18rpx;
background: rgba(45, 212, 191, 0.1);
color: #7ee8dc;
border: 1rpx solid rgba(94, 234, 212, 0.2);
}
.profile-tags-modern .tag-region {
font-size: 20rpx;
padding: 6rpx 16rpx 6rpx 14rpx;
background: rgba(255, 255, 255, 0.06);
color: rgba(203, 213, 225, 0.95);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.tag-mbti {
background: rgba(19, 78, 74, 0.6);
color: #5eead4;
border: 1rpx solid rgba(94, 234, 212, 0.25);
}
.tag-region {
background: rgba(30, 41, 59, 0.8);
color: #cbd5e1;
border: 1rpx solid rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
gap: 8rpx;
}
.pin-icon {
color: #f87171;
font-size: 22rpx;
}
/* ===== 状态 ===== */
.state-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 60vh; gap: 24rpx; }
.state-txt { font-size: 28rpx; color: #64748B; }
.state-emoji { font-size: 96rpx; }
/* —— 一体化信息卡 —— */
.mono-card {
margin: 24rpx 24rpx 0;
padding: 8rpx 0 32rpx;
border-radius: 32rpx;
background: rgba(15, 23, 34, 0.88);
border: 1rpx solid rgba(255, 255, 255, 0.07);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.25);
}
.mono-card-compact {
padding-bottom: 24rpx;
}
.mono-sec {
padding: 28rpx 32rpx 8rpx;
}
.mono-sec-tight {
padding: 18rpx 28rpx 6rpx;
}
.mono-sec-head {
margin-bottom: 24rpx;
}
.mono-sec-head-tight {
margin-bottom: 14rpx;
}
.mono-sec-kicker {
display: block;
font-size: 18rpx;
font-weight: 700;
color: rgba(94, 234, 212, 0.55);
letter-spacing: 6rpx;
margin-bottom: 8rpx;
}
.mono-sec-title {
font-size: 30rpx;
font-weight: 700;
color: #f8fafc;
}
.mono-divider {
height: 1rpx;
margin: 4rpx 28rpx;
background: linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.15), transparent);
}
.kv-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx 20rpx;
}
.kv-cell {
width: calc(50% - 10rpx);
box-sizing: border-box;
}
.kv-cell-full {
width: 100%;
}
.kv-cell .kv-k {
display: block;
font-size: 20rpx;
color: #64748b;
margin-bottom: 6rpx;
}
.kv-cell .kv-v {
font-size: 26rpx;
color: #e2e8f0;
line-height: 1.5;
font-weight: 500;
}
.kv {
margin-bottom: 28rpx;
}
.kv:last-child {
margin-bottom: 8rpx;
}
.kv-k {
display: block;
font-size: 22rpx;
color: #64748b;
margin-bottom: 10rpx;
}
.kv-v {
font-size: 28rpx;
color: #e2e8f0;
line-height: 1.65;
font-weight: 500;
}
.skills-showcase .skills-quote {
padding: 20rpx 22rpx 20rpx 20rpx;
border-radius: 18rpx;
background: linear-gradient(105deg, rgba(45, 212, 191, 0.08) 0%, rgba(15, 23, 42, 0.5) 100%);
border-left: 6rpx solid #2dd4bf;
box-shadow: inset 0 0 0 1rpx rgba(45, 212, 191, 0.12);
}
.skills-quote-text {
font-size: 26rpx;
color: #f1f5f9;
line-height: 1.6;
font-weight: 500;
}
.story {
margin-bottom: 8rpx;
}
.story-compact .story-head {
margin-bottom: 8rpx;
}
.story-compact .story-a {
font-size: 26rpx;
line-height: 1.55;
padding-left: 0;
}
.story-gap {
height: 28rpx;
}
.story-gap-tight {
height: 16rpx;
}
.story-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.story-icon {
flex-shrink: 0;
}
.story-q {
font-size: 22rpx;
font-weight: 600;
color: #94a3b8;
}
.story-a {
display: block;
font-size: 28rpx;
color: #e2e8f0;
line-height: 1.7;
padding-left: 4rpx;
}
.help-grid {
display: flex;
flex-direction: column;
gap: 14rpx;
}
.help-tile {
padding: 22rpx 24rpx;
border-radius: 20rpx;
background: rgba(23, 33, 47, 0.9);
border: 1rpx solid rgba(255, 255, 255, 0.06);
}
.help-tile-tag {
display: inline-block;
font-size: 20rpx;
font-weight: 700;
padding: 8rpx 18rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
}
.help-give .help-tile-tag {
color: #5eead4;
background: rgba(6, 78, 59, 0.45);
border: 1rpx solid rgba(45, 212, 191, 0.2);
}
.help-need .help-tile-tag.need {
color: #fbbf24;
background: rgba(69, 47, 8, 0.45);
border: 1rpx solid rgba(251, 191, 36, 0.2);
}
.help-tile-txt {
font-size: 26rpx;
color: #f1f5f9;
line-height: 1.55;
}
.proj-body {
font-size: 28rpx;
color: #cbd5e1;
line-height: 1.75;
padding-bottom: 8rpx;
}
.proj-body-compact {
font-size: 26rpx;
line-height: 1.55;
}
/* —— 底栏:分享 + 双入口 —— */
.footer-panel {
margin: 32rpx 24rpx 0;
padding: 28rpx;
border-radius: 28rpx;
background: rgba(12, 18, 28, 0.92);
border: 1rpx solid rgba(94, 234, 212, 0.1);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.3);
}
.footer-pills {
display: flex;
flex-direction: row;
gap: 16rpx;
}
.pill {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 22rpx 12rpx;
border-radius: 20rpx;
background: rgba(30, 41, 59, 0.6);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.pill:active {
background: rgba(51, 65, 85, 0.7);
}
.pill-gold {
border-color: rgba(251, 191, 36, 0.2);
}
.pill-teal {
border-color: rgba(94, 234, 212, 0.2);
}
.pill-ic {
flex-shrink: 0;
}
.pill-txt {
font-size: 24rpx;
font-weight: 600;
color: rgba(248, 250, 252, 0.88);
}
.scroll-pad {
height: calc(120rpx + env(safe-area-inset-bottom));
}
/* —— 状态 —— */
.state-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.state-txt {
font-size: 28rpx;
color: #64748b;
}
.state-emoji {
font-size: 96rpx;
}
.loading-dot {
width: 56rpx; height: 56rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 4rpx solid rgba(94, 234, 212, 0.2);
border-top-color: #5EEAD4;
border-top-color: #5eead4;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -7,6 +7,22 @@
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
}
Page({
data: {
@@ -33,6 +49,8 @@ Page({
readCountText: '0',
totalReadTimeText: '0',
matchHistoryText: '0',
orderCountText: '0',
giftPayCountText: '0',
// 最近阅读
recentChapters: [],
@@ -78,6 +96,11 @@ Page({
// 我的余额
walletBalanceText: '--',
// 已解锁章节(订单倒序;默认最多展示 5 条,底部倒三角展开)
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
},
onLoad() {
@@ -164,6 +187,7 @@ Page({
this.loadPendingConfirm()
this.loadVipStatus()
this.loadWalletBalance()
this.loadUnlockedChapters()
} else {
const guestReadCount = app.getReadCount()
this.setData({
@@ -177,6 +201,9 @@ Page({
pendingEarnings: '-',
earningsLoading: false,
recentChapters: [],
unlockedChaptersFull: [],
displayUnlockedChapters: [],
unlockedExpanded: false,
totalReadTime: 0,
matchHistory: 0,
totalReadTimeText: '0',
@@ -185,6 +212,91 @@ 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 || '')
}
}
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 })
}
},
expandUnlockedChapters() {
if (this.data.unlockedExpanded) return
trackClick('my', 'tab_click', '已解锁章节_展开')
const full = this.data.unlockedChaptersFull || []
this.setData({
unlockedExpanded: true,
displayUnlockedChapters: full
})
},
async loadDashboardStats() {
const userId = app.globalData.userInfo?.id
if (!userId) return
@@ -212,6 +324,8 @@ Page({
const readCount = Number(res.data.readCount || 0)
const totalReadTime = Number(res.data.totalReadMinutes || 0)
const matchHistory = Number(res.data.matchHistory || 0)
const orderCount = Number(res.data.orderCount || 0)
const giftPayCount = Number(res.data.giftPayCount || 0)
this.setData({
readCount,
totalReadTime,
@@ -219,6 +333,8 @@ Page({
readCountText: formatStatNum(readCount),
totalReadTimeText: formatStatNum(totalReadTime),
matchHistoryText: formatStatNum(matchHistory),
orderCountText: formatStatNum(orderCount),
giftPayCountText: formatStatNum(giftPayCount),
recentChapters
})
} catch (e) {

View File

@@ -49,20 +49,20 @@
</view>
<view class="profile-stats-row">
<view class="profile-stat" bindtap="goToChapters">
<text class="profile-stat-val">{{readCountText}}</text>
<text class="profile-stat-val">{{readCountText || '0'}}</text>
<text class="profile-stat-label">已读章节</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{referralCount}}</text>
<text class="profile-stat-label">推荐好友</text>
</view>
<view class="profile-stat" wx:if="{{referralEnabled}}" bindtap="goToReferral">
<text class="profile-stat-val">{{pendingEarnings === '-' ? '--' : pendingEarnings}}</text>
<text class="profile-stat-label">我的收益</text>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToMatch">
<text class="profile-stat-val">{{matchHistoryText}}</text>
<text class="profile-stat-label">匹配伙伴</text>
</view>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
<text class="profile-stat-val">{{walletBalanceText}}</text>
<text class="profile-stat-label">我的余额</text>
<view class="profile-stat" wx:if="{{!auditMode}}" bindtap="goToReferral">
<text class="profile-stat-val">{{pendingEarnings || '0.00'}}</text>
<text class="profile-stat-label">我的收益</text>
</view>
</view>
</view>
@@ -92,31 +92,63 @@
</view>
</view>
<!-- 阅读统计 -->
<!-- 快捷入口:我的订单 + 我的代付 -->
<view class="card stats-card">
<view class="card-header">
<image class="card-icon-img" src="/assets/icons/eye-teal.svg" mode="aspectFit"/>
<text class="card-title">阅读统计</text>
<text class="card-title">快捷入口</text>
</view>
<view class="stats-grid">
<view class="stat-box" bindtap="goToChapters">
<image class="stat-icon-img" src="/assets/icons/book-open-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{readCountText}}</text>
<text class="stat-label">已读章节</text>
<view class="stat-box" bindtap="handleMenuTap" data-id="orders">
<image class="stat-icon-img" src="/assets/icons/list-teal.svg" mode="aspectFit"/>
<text class="stat-num">订单</text>
<text class="stat-label">我的订单</text>
</view>
<view class="stat-box" bindtap="goToChapters">
<image class="stat-icon-img" src="/assets/icons/clock-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{totalReadTimeText}}</text>
<text class="stat-label">阅读分钟</text>
<view class="stat-box" bindtap="handleMenuTap" data-id="giftPay">
<image class="stat-icon-img" src="/assets/icons/share-teal.svg" mode="aspectFit"/>
<text class="stat-num">代付</text>
<text class="stat-label">我的代付</text>
</view>
<view class="stat-box" bindtap="goToMatch">
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{matchHistoryText}}</text>
<text class="stat-label">匹配伙伴</text>
<view class="stat-box" bindtap="handleMenuTap" data-id="wallet">
<image class="stat-icon-img" src="/assets/icons/wallet-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{walletBalanceText}}</text>
<text class="stat-label">我的余额</text>
</view>
</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">
@@ -145,23 +177,9 @@
</view>
</view>
<!-- 我的订单 + 设置 -->
<view class="card menu-card">
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
<view class="menu-left">
<view class="menu-icon-wrap icon-teal"><image class="menu-icon-img" src="/assets/icons/folder-teal.svg" mode="aspectFit"/></view>
<text class="menu-text">我的订单</text>
</view>
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.35)" customClass="menu-arrow"></icon>
</view>
<view class="menu-item" bindtap="handleMenuTap" data-id="giftPay">
<view class="menu-left">
<view class="menu-icon-wrap icon-teal"><icon name="gift" size="32" color="#4FD1C5" customClass="menu-icon"></icon></view>
<text class="menu-text">我的代付</text>
</view>
<icon name="chevron-right" size="28" color="rgba(255,255,255,0.35)" customClass="menu-arrow"></icon>
</view>
<view class="menu-item" wx:if="{{showSettingsEntry}}" bindtap="handleMenuTap" data-id="settings">
<!-- 设置 -->
<view class="card menu-card" wx:if="{{showSettingsEntry}}">
<view class="menu-item" bindtap="handleMenuTap" data-id="settings">
<view class="menu-left">
<view class="menu-icon-wrap icon-gray"><image class="menu-icon-img" src="/assets/icons/settings-gray.svg" mode="aspectFit"/></view>
<text class="menu-text">设置</text>

View File

@@ -74,10 +74,6 @@
.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-actions { display: flex; align-items: center; gap: 16rpx; flex-shrink: 0; }
.profile-edit-btn { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 16rpx; background: rgba(255,255,255,0.08); border-radius: 12rpx; }
.profile-edit-icon { width: 28rpx; height: 28rpx; opacity: 0.7; }
.profile-edit-text { font-size: 24rpx; color: rgba(255,255,255,0.7); }
.user-name {
font-size: 44rpx; font-weight: bold; color: #fff;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;
@@ -166,7 +162,20 @@
display: flex; align-items: center; justify-content: space-between;
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.recent-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; }
.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; }
@@ -174,6 +183,25 @@
.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 {
@@ -197,8 +225,10 @@
.icon-blue .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-gray { background: rgba(156,163,175,0.15); }
.icon-gray .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-gold { background: rgba(200,161,70,0.2); }
.icon-gold .menu-icon-img { width: 32rpx; height: 32rpx; }
.icon-amber { background: rgba(245,158,11,0.2); }
.menu-icon-emoji { font-size: 28rpx; }
.menu-right { display: flex; align-items: center; gap: 12rpx; }
.menu-balance { font-size: 26rpx; color: #4FD1C5; font-weight: 500; }
.menu-text { font-size: 28rpx; color: #E5E7EB; font-weight: 500; }
.menu-arrow { font-size: 36rpx; color: #9CA3AF; }
@@ -272,5 +302,14 @@
.modal-btn-cancel { background: rgba(255,255,255,0.1); color: #fff; }
.modal-btn-confirm { background: #4FD1C5; color: #000; font-weight: 600; }
/* 代付链接卡片 */
.gift-list { display: flex; flex-direction: column; gap: 16rpx; }
.gift-item { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; }
.gift-left { flex: 1; min-width: 0; }
.gift-title { display: block; font-size: 28rpx; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gift-meta { display: block; font-size: 22rpx; color: #9CA3AF; margin-top: 6rpx; }
.gift-share-btn { display: inline-block; padding: 8rpx 28rpx; background: #4FD1C5; color: #000; font-size: 24rpx; font-weight: 600; border-radius: 20rpx; }
.gift-done { font-size: 24rpx; color: #6B7280; }
/* 底部留白:配合 page padding-bottom避免内容被 TabBar 遮挡 */
.bottom-space { height: calc(80rpx + env(safe-area-inset-bottom, 0px)); }

View File

@@ -34,7 +34,7 @@
<text class="doc-p">我们可能适时更新本政策,更新后将通过小程序内公示等方式通知您。继续使用即视为接受更新后的政策。</text>
<text class="doc-section">八、联系我们</text>
<text class="doc-p">如有隐私相关疑问或投诉,请通过 Soul 派对房与我们联系。</text>
<text class="doc-p">如有隐私相关疑问或投诉,请通过小程序内「关于作者」或 Soul 派对房与我们联系。</text>
</view>
</scroll-view>
</view>

View File

@@ -1,9 +1,4 @@
/* 资料编辑 - comprehensive_profile_editor_v1_1 | 配色 enhancedinput/textarea 用 view 包裹 */
/* 分享名片 canvas隐藏仅用于生成图片 */
.share-card-canvas {
position: fixed; left: -9999px; top: 0; width: 500px; height: 400px;
}
.page {
background: #050B14; min-height: 100vh; color: #fff;
width: 100%; box-sizing: border-box; overflow-x: hidden;
@@ -206,11 +201,8 @@
.btn-choose-avatar {
width: 100%;
height: 88rpx;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 88rpx;
text-align: center;
background: #5EEAD4;
color: #050B14;
font-size: 30rpx;

View File

@@ -1,135 +1,175 @@
<!-- 个人资料展示页 - enhanced_professional_profile 1:1 重构 -->
<!-- 卡若创业派对 - 个人资料展示页(与 member-detail 同一视觉) -->
<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>
<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-right" bindtap="goToEdit"><text class="nav-more">⋯</text></view>
<view class="nav-edit" bindtap="goToEdit">
<icon name="edit" size="32" color="#5EEAD4"></icon>
</view>
</view>
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="loading" wx:if="{{loading}}">加载中...</view>
<scroll-view wx:else class="scroll-main" scroll-y>
<!-- 头像区卡片 -->
<view class="hero-card" wx:if="{{profile}}">
<view class="hero-gradient"></view>
<view class="hero-content">
<view class="hero-avatar">
<image wx:if="{{profile.avatar}}" class="avatar-img" src="{{profile.avatar}}" mode="aspectFill"/>
<view wx:else class="avatar-placeholder">{{profile.nickname ? profile.nickname[0] : '?'}}</view>
</view>
<text class="hero-name">{{profile.nickname || '未设置昵称'}}</text>
<view class="hero-tags">
<text class="tag tag-mbti" wx:if="{{profile.mbti}}">{{profile.mbti}}</text>
<view class="tag tag-region" wx:if="{{profile.region}}"><icon name="map-pin" size="24" color="currentColor" customClass="tag-icon"></icon><text>{{profile.region}}</text></view>
</view>
</view>
</view>
<view class="state-wrap" wx:if="{{loading}}">
<view class="loading-dot"></view>
<text class="state-txt">加载中...</text>
</view>
<!-- 基本信息 -->
<view class="section">
<view class="section-head">
<icon name="user" size="40" color="#00CED1" customClass="section-icon"></icon>
<text class="section-title">基本信息</text>
</view>
<view class="section-body">
<view class="field" wx:if="{{profile.industry}}">
<text class="field-label">行业</text>
<text class="field-value">{{profile.industry}}</text>
</view>
<view class="field" wx:if="{{profile.position}}">
<text class="field-label">职位</text>
<text class="field-value">{{profile.position}}</text>
</view>
<view class="field" wx:if="{{profile.businessScale}}">
<text class="field-label">业务体量</text>
<text class="field-value">{{profile.businessScale}}</text>
</view>
<view class="field-divider" wx:if="{{profile.industry || profile.position || profile.businessScale}}"></view>
<view class="field" wx:if="{{profile.skills}}">
<text class="field-label">我擅长</text>
<text class="field-value">{{profile.skills}}</text>
</view>
<view class="field" wx:if="{{profile.phoneMask || profile.phone}}">
<text class="field-label">联系方式</text>
<view class="field-value-row" bindtap="copyPhone">
<text class="field-value mono">{{profile.phoneMask || profile.phone || '未填写'}}</text>
<text class="field-hint" wx:if="{{profile.phone}}">复制</text>
<scroll-view scroll-y class="scroll-wrap" style="height: calc(100vh - {{statusBarHeight + 44}}px - 120rpx);" wx:if="{{!loading && profile}}">
<!-- 首屏壳:头像 + 联系方式 -->
<view class="shell">
<view class="shell-glow"></view>
<view class="hero-row">
<view class="avatar-outer">
<view class="avatar-wrap">
<image class="avatar-img" wx:if="{{profile.avatar}}" src="{{profile.avatar}}" mode="aspectFill"/>
<view class="avatar-ph" wx:else><text>{{profile.nickname ? profile.nickname[0] : '?'}}</text></view>
</view>
</view>
<view class="field" wx:if="{{profile.wechatMask || profile.wechat}}">
<text class="field-label">微信号</text>
<view class="field-value-row" bindtap="copyWechat">
<text class="field-value mono">{{profile.wechatMask || profile.wechat || '未填写'}}</text>
<text class="field-hint" wx:if="{{profile.wechat}}">复制</text>
<view class="link-column">
<text class="link-column-title">我的联系方式</text>
<view class="link-chip" wx:if="{{profile.phone}}" bindtap="copyPhone">
<view class="link-chip-icon link-chip-icon-phone">
<icon name="smartphone" size="34" color="#5EEAD4" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label">手机号</text>
<text class="link-chip-val mono">{{profile.phone}}</text>
</view>
<view class="link-chip-action"><text>复制</text></view>
</view>
<view class="link-chip" wx:if="{{profile.wechat}}" bindtap="copyWechat">
<view class="link-chip-icon link-chip-icon-wx">
<icon name="message-circle" size="34" color="#34D399" customClass="lc-ic"></icon>
</view>
<view class="link-chip-main">
<text class="link-chip-label">微信号</text>
<text class="link-chip-val mono">{{profile.wechat}}</text>
</view>
<view class="link-chip-action"><text>复制</text></view>
</view>
<view class="link-empty" wx:if="{{!profile.phone && !profile.wechat}}">
<text class="link-empty-txt">未填写联系方式</text>
</view>
</view>
<view class="field-empty" wx:if="{{!profile.industry && !profile.position && !profile.businessScale && !profile.skills && !profile.phone && !profile.wechat}}">
点击右上角 ⋯ 编辑完善资料
</view>
<text class="profile-name">{{profile.nickname || '未设置昵称'}}</text>
<view class="profile-tags" wx:if="{{profile.mbti || profile.region}}">
<text class="tag tag-mbti" wx:if="{{profile.mbti}}">{{profile.mbti}}</text>
<view class="tag tag-region" wx:if="{{profile.region}}">
<icon name="map-pin" size="24" color="currentColor" customClass="pin-icon"></icon>
<text>{{profile.region}}</text>
</view>
</view>
</view>
<!-- 个人故事 -->
<view class="section" wx:if="{{profile.storyBestMonth || profile.storyAchievement || profile.storyTurning}}">
<view class="section-head">
<icon name="lightbulb" size="40" color="#FFD700" customClass="section-icon section-icon-yellow"></icon>
<text class="section-title">个人故事</text>
<!-- 一体化信息卡 -->
<view class="mono-card" wx:if="{{profile.industry || profile.position || profile.businessScale || profile.skills || profile.storyBestMonth || profile.storyAchievement || profile.storyTurning || profile.helpOffer || profile.helpNeed || profile.projectIntro}}">
<!-- 职业画像 -->
<view class="mono-sec" wx:if="{{profile.industry || profile.position || profile.businessScale}}">
<view class="mono-sec-head">
<text class="mono-sec-title">职业画像</text>
</view>
<view class="kv" wx:if="{{profile.industry}}">
<text class="kv-k">行业</text>
<text class="kv-v">{{profile.industry}}</text>
</view>
<view class="kv" wx:if="{{profile.position}}">
<text class="kv-k">职位</text>
<text class="kv-v">{{profile.position}}</text>
</view>
<view class="kv" wx:if="{{profile.businessScale}}">
<text class="kv-k">业务体量</text>
<text class="kv-v">{{profile.businessScale}}</text>
</view>
</view>
<view class="section-body">
<view class="story-block" wx:if="{{profile.storyBestMonth}}">
<view class="story-head"><icon name="trophy" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">最赚钱的一个月做的是什么</text></view>
<text class="story-text">{{profile.storyBestMonth}}</text>
<view class="mono-divider" wx:if="{{(profile.industry || profile.position || profile.businessScale) && profile.skills}}"></view>
<!-- 我擅长 -->
<view class="mono-sec skills-showcase" wx:if="{{profile.skills}}">
<view class="mono-sec-head">
<text class="mono-sec-title">我擅长</text>
</view>
<view class="field-divider" wx:if="{{profile.storyBestMonth && (profile.storyAchievement || profile.storyTurning)}}"></view>
<view class="story-block" wx:if="{{profile.storyAchievement}}">
<view class="story-head"><icon name="star" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">最有成就感的一件事</text></view>
<text class="story-text">{{profile.storyAchievement}}</text>
<view class="skills-quote">
<text class="skills-quote-text">{{profile.skills}}</text>
</view>
<view class="field-divider" wx:if="{{profile.storyAchievement && profile.storyTurning}}"></view>
<view class="story-block" wx:if="{{profile.storyTurning}}">
<view class="story-head"><icon name="refresh-cw" size="28" color="#FFD700" customClass="story-emoji"></icon><text class="story-label">人生的转折点</text></view>
<text class="story-text">{{profile.storyTurning}}</text>
</view>
<view class="mono-divider" wx:if="{{profile.skills && (profile.storyBestMonth || profile.storyAchievement || profile.storyTurning)}}"></view>
<!-- 个人故事 -->
<view class="mono-sec" wx:if="{{profile.storyBestMonth || profile.storyAchievement || profile.storyTurning}}">
<view class="mono-sec-head">
<text class="mono-sec-title">个人故事</text>
</view>
<view class="story" wx:if="{{profile.storyBestMonth}}">
<view class="story-head"><icon name="trophy" size="28" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最赚钱的一个月</text></view>
<text class="story-a">{{profile.storyBestMonth}}</text>
</view>
<view class="story-gap" wx:if="{{profile.storyBestMonth && (profile.storyAchievement || profile.storyTurning)}}"></view>
<view class="story" wx:if="{{profile.storyAchievement}}">
<view class="story-head"><icon name="star" size="28" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">最有成就感的事</text></view>
<text class="story-a">{{profile.storyAchievement}}</text>
</view>
<view class="story-gap" wx:if="{{profile.storyAchievement && profile.storyTurning}}"></view>
<view class="story" wx:if="{{profile.storyTurning}}">
<view class="story-head"><icon name="refresh-cw" size="28" color="#FBBF24" customClass="story-icon"></icon><text class="story-q">人生的转折点</text></view>
<text class="story-a">{{profile.storyTurning}}</text>
</view>
</view>
<view class="mono-divider" wx:if="{{(profile.storyBestMonth || profile.storyAchievement || profile.storyTurning) && (profile.helpOffer || profile.helpNeed)}}"></view>
<!-- 互助需求 -->
<view class="mono-sec" wx:if="{{profile.helpOffer || profile.helpNeed}}">
<view class="mono-sec-head">
<text class="mono-sec-title">互助需求</text>
</view>
<view class="help-grid">
<view class="help-tile help-give" wx:if="{{profile.helpOffer}}">
<text class="help-tile-tag">我能帮你</text>
<text class="help-tile-txt">{{profile.helpOffer}}</text>
</view>
<view class="help-tile help-need" wx:if="{{profile.helpNeed}}">
<text class="help-tile-tag need">我需要</text>
<text class="help-tile-txt">{{profile.helpNeed}}</text>
</view>
</view>
</view>
<view class="mono-divider" wx:if="{{(profile.helpOffer || profile.helpNeed) && profile.projectIntro}}"></view>
<!-- 项目介绍 -->
<view class="mono-sec" wx:if="{{profile.projectIntro}}">
<view class="mono-sec-head">
<text class="mono-sec-title">项目介绍</text>
</view>
<text class="proj-body">{{profile.projectIntro}}</text>
</view>
</view>
<!-- 互助需求 -->
<view class="section" wx:if="{{profile.helpOffer || profile.helpNeed}}">
<view class="section-head">
<icon name="handshake" size="40" color="#00CED1" customClass="section-icon"></icon>
<text class="section-title">互助需求</text>
</view>
<view class="section-body">
<view class="help-block" wx:if="{{profile.helpOffer}}">
<text class="help-tag help-tag-accent">我能帮你</text>
<text class="help-text">{{profile.helpOffer}}</text>
</view>
<view class="help-block" wx:if="{{profile.helpNeed}}">
<text class="help-tag help-tag-orange">我需要帮助</text>
<text class="help-text">{{profile.helpNeed}}</text>
</view>
</view>
<!-- 空态 -->
<view class="empty-hint" wx:if="{{!profile.industry && !profile.position && !profile.businessScale && !profile.skills && !profile.storyBestMonth && !profile.storyAchievement && !profile.storyTurning && !profile.helpOffer && !profile.helpNeed && !profile.projectIntro}}">
<icon name="edit" size="48" color="#334155"></icon>
<text class="empty-hint-txt">资料尚未完善,点击右上角编辑</text>
</view>
<!-- 项目介绍 -->
<view class="section" wx:if="{{profile.projectIntro}}">
<view class="section-head">
<icon name="rocket" size="40" color="#00CED1" customClass="section-icon"></icon>
<text class="section-title">项目介绍</text>
</view>
<view class="section-body">
<text class="project-text">{{profile.projectIntro}}</text>
</view>
</view>
<view class="bottom-spacer"></view>
<view class="scroll-pad"></view>
</scroll-view>
<!-- 底部按钮 - 设计稿为描边橙色 -->
<view class="bottom-bar">
<view class="vip-btn-outline" bindtap="goToVip">
<!-- 底部按钮 -->
<view class="bottom-bar" wx:if="{{!loading}}">
<view class="vip-btn" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="36" color="#00CED1" customClass="vip-btn-arrow"></icon>
<icon name="chevron-right" size="32" color="#0f172a"></icon>
</view>
</view>
</view>

View File

@@ -1,115 +1,183 @@
/* 个人资料展示页 - enhanced_professional_profile 1:1 重构 */
.page { background: #050B14; min-height: 100vh; color: #fff; }
/* 卡若创业派对 - 个人资料展示(与 member-detail 同一视觉语言) */
.page {
background: radial-gradient(120% 80% at 50% -20%, rgba(20, 80, 90, 0.35) 0%, #050B14 45%);
min-height: 100vh;
color: #fff;
}
/* —— 导航 —— */
.nav-bar {
position: fixed; top: 0; left: 0; right: 0; z-index: 100;
position: fixed; top: 0; left: 0; right: 0; z-index: 999;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 32rpx;
background: rgba(5,11,20,0.9); backdrop-filter: blur(8rpx);
border-bottom: 1rpx solid rgba(255,255,255,0.05);
padding: 0 28rpx; height: 44px;
background: rgba(5, 11, 20, 0.72);
backdrop-filter: blur(20rpx); -webkit-backdrop-filter: blur(20rpx);
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.nav-back { padding: 16rpx; margin-left: -8rpx; }
.back-icon { font-size: 40rpx; color: #5EEAD4; }
.nav-title { font-size: 34rpx; font-weight: bold; }
.nav-right { padding: 16rpx; }
.nav-more { font-size: 48rpx; color: #fff; line-height: 1; }
.nav-placeholder { width: 100%; }
.nav-back { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: flex-start; }
.nav-icon { font-size: 44rpx; color: #5eead4; font-weight: 300; }
.nav-title { font-size: 32rpx; font-weight: 600; color: #f8fafc; letter-spacing: 4rpx; }
.nav-edit { width: 72rpx; height: 72rpx; display: flex; align-items: center; justify-content: flex-end; }
.loading { padding: 96rpx; text-align: center; color: #94A3B8; }
.scroll-wrap { box-sizing: border-box; }
.scroll-main { height: calc(100vh - 120rpx); padding: 0 32rpx 32rpx; }
/* 头像区卡片 */
.hero-card {
position: relative; overflow: hidden;
background: #0F1720; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 32rpx; margin-bottom: 32rpx;
padding: 64rpx 32rpx; display: flex; flex-direction: column; align-items: center;
/* —— 首屏外壳 —— */
.shell {
position: relative; margin: 28rpx 24rpx 0; padding: 40rpx 32rpx 36rpx;
border-radius: 32rpx;
background: linear-gradient(145deg, rgba(22, 36, 48, 0.95) 0%, rgba(12, 20, 32, 0.98) 100%);
border: 1rpx solid rgba(94, 234, 212, 0.12);
box-shadow: 0 24rpx 80rpx rgba(0, 0, 0, 0.45), inset 0 1rpx 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.hero-gradient {
position: absolute; top: 0; left: 0; right: 0; height: 128rpx;
background: linear-gradient(to bottom, rgba(30,58,69,0.3) 0%, transparent 100%);
.shell-glow {
position: absolute; top: -40%; right: -20%; width: 70%; height: 80%;
background: radial-gradient(circle, rgba(45, 212, 191, 0.12) 0%, transparent 70%);
pointer-events: none;
}
.hero-content { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; }
.hero-avatar {
width: 176rpx; height: 176rpx; border-radius: 50%;
overflow: hidden; border: 2rpx solid rgba(255,255,255,0.1);
margin-bottom: 32rpx;
.hero-row { position: relative; z-index: 1; display: flex; align-items: flex-start; gap: 28rpx; }
.avatar-outer { position: relative; width: 168rpx; height: 168rpx; flex-shrink: 0; }
.avatar-wrap {
width: 100%; height: 100%; border-radius: 50%; overflow: hidden;
border: 2rpx solid rgba(255, 255, 255, 0.12);
box-shadow: 0 12rpx 40rpx rgba(0, 0, 0, 0.35);
}
.avatar-img { width: 100%; height: 100%; display: block; }
.avatar-placeholder {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
font-size: 72rpx; font-weight: bold; color: #5EEAD4;
background: rgba(94,234,212,0.2);
.avatar-img { width: 100%; height: 100%; object-fit: cover; }
.avatar-ph {
width: 100%; height: 100%; background: #1a2332;
display: flex; align-items: center; justify-content: center;
font-size: 52rpx; color: #5eead4; font-weight: 700;
}
.hero-name { font-size: 40rpx; font-weight: bold; margin-bottom: 24rpx; }
.hero-tags { display: flex; align-items: center; justify-content: center; gap: 24rpx; }
.tag { padding: 8rpx 24rpx; border-radius: 999rpx; font-size: 24rpx; font-weight: 500; }
.tag-mbti { background: #134E4A; color: #5EEAD4; border: 1rpx solid rgba(94,234,212,0.2); }
.tag-region { display: flex; align-items: center; gap: 8rpx; background: #1F2937; color: #d1d5db; border: 1rpx solid rgba(255,255,255,0.1); }
.tag-region .tag-icon { flex-shrink: 0; }
/* 通用区块 */
.section {
background: #0F1720; border: 1rpx solid rgba(255,255,255,0.08);
border-radius: 32rpx; margin-bottom: 32rpx;
padding: 40rpx; box-shadow: 0 16rpx 32rpx rgba(0,0,0,0.2);
/* 右侧联系方式列 */
.link-column { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 16rpx; }
.link-column-title { font-size: 26rpx; font-weight: 700; color: #f1f5f9; letter-spacing: 2rpx; }
.link-chip {
display: flex; align-items: center; gap: 20rpx;
padding: 22rpx; border-radius: 20rpx;
background: rgba(15, 23, 42, 0.65);
border: 1rpx solid rgba(94, 234, 212, 0.25);
}
.section-head { display: flex; align-items: center; gap: 20rpx; margin-bottom: 40rpx; }
.section-icon { font-size: 40rpx; }
.section-icon-yellow { filter: brightness(1.2); }
.section-title { font-size: 30rpx; font-weight: bold; }
.section-body { }
.field { margin-bottom: 48rpx; }
.field:last-child { margin-bottom: 0; }
.field-label { display: block; font-size: 26rpx; color: #94A3B8; margin-bottom: 16rpx; }
.field-value { font-size: 30rpx; font-weight: 500; color: #fff; line-height: 1.5; }
.field-value.mono { font-family: monospace; letter-spacing: 0.02em; }
.field-value-row { display: flex; align-items: center; gap: 16rpx; }
.field-hint { font-size: 24rpx; color: #5EEAD4; }
.field-divider { height: 1rpx; background: rgba(255,255,255,0.05); margin: 32rpx 0; }
.field-empty { font-size: 26rpx; color: #64748b; }
/* 个人故事 */
.story-block { margin-bottom: 48rpx; }
.story-block:last-child { margin-bottom: 0; }
.story-head { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.story-emoji { font-size: 32rpx; }
.story-label { font-size: 26rpx; font-weight: 500; color: #94A3B8; }
.story-text { font-size: 28rpx; color: #e5e7eb; line-height: 1.6; display: block; }
/* 互助需求 */
.help-block {
background: #17212F; border: 1rpx solid rgba(255,255,255,0.05);
border-radius: 20rpx; padding: 32rpx; margin-bottom: 24rpx;
.link-chip:active { background: rgba(15, 30, 40, 0.75); }
.link-chip-icon {
width: 64rpx; height: 64rpx; border-radius: 16rpx;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.help-block:last-child { margin-bottom: 0; }
.help-tag {
display: inline-block; font-size: 22rpx; font-weight: 500;
padding: 8rpx 16rpx; border-radius: 8rpx; margin-bottom: 16rpx;
.link-chip-icon-phone { background: rgba(45, 212, 191, 0.12); }
.link-chip-icon-wx { background: rgba(52, 211, 153, 0.12); }
.lc-ic { display: block; }
.link-chip-main { flex: 1; min-width: 0; }
.link-chip-label { display: block; font-size: 20rpx; color: #94a3b8; margin-bottom: 6rpx; }
.link-chip-val { display: block; font-size: 26rpx; font-weight: 600; color: #f8fafc; line-height: 1.35; word-break: break-all; }
.link-chip-val.mono { font-family: ui-monospace, monospace; letter-spacing: 1rpx; }
.link-chip-action { flex-shrink: 0; font-size: 22rpx; font-weight: 600; color: #5eead4; }
.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-txt { font-size: 24rpx; color: #64748b; }
.profile-name {
position: relative; z-index: 1; display: block; text-align: center;
margin-top: 36rpx; font-size: 40rpx; font-weight: 700; color: #fff; letter-spacing: 4rpx;
}
.help-tag-accent { background: #112D2A; color: #5EEAD4; }
.help-tag-orange { background: #2D1F0D; color: #F59E0B; }
.help-text { font-size: 26rpx; color: #fff; line-height: 1.6; display: block; }
.profile-tags {
position: relative; z-index: 1;
display: flex; align-items: center; justify-content: center; gap: 20rpx; flex-wrap: wrap;
margin-top: 24rpx;
}
.tag { font-size: 24rpx; font-weight: 500; padding: 10rpx 26rpx; border-radius: 999rpx; }
.tag-mbti { background: rgba(19, 78, 74, 0.6); color: #5eead4; border: 1rpx solid rgba(94, 234, 212, 0.25); }
.tag-region {
background: rgba(30, 41, 59, 0.8); color: #cbd5e1; border: 1rpx solid rgba(255, 255, 255, 0.08);
display: flex; align-items: center; gap: 8rpx;
}
.pin-icon { color: #f87171; font-size: 22rpx; }
.project-text { font-size: 28rpx; color: #e5e7eb; line-height: 1.6; }
/* —— 一体化信息卡 —— */
.mono-card {
margin: 24rpx 24rpx 0; padding: 8rpx 0 32rpx; border-radius: 32rpx;
background: rgba(15, 23, 34, 0.88);
border: 1rpx solid rgba(255, 255, 255, 0.07);
box-shadow: 0 16rpx 48rpx rgba(0, 0, 0, 0.25);
}
.mono-sec { padding: 28rpx 32rpx 8rpx; }
.mono-sec-head { margin-bottom: 24rpx; }
.mono-sec-title { font-size: 32rpx; font-weight: 700; color: #f8fafc; }
.mono-divider { height: 1rpx; margin: 8rpx 32rpx; background: linear-gradient(90deg, transparent, rgba(148, 163, 184, 0.15), transparent); }
.bottom-spacer { height: 180rpx; }
.kv { margin-bottom: 28rpx; }
.kv:last-child { margin-bottom: 8rpx; }
.kv-k { display: block; font-size: 22rpx; color: #64748b; margin-bottom: 10rpx; }
.kv-v { font-size: 28rpx; color: #e2e8f0; line-height: 1.65; font-weight: 500; }
/* 底部按钮 - 设计稿:透明背景 + 橙色描边 */
.skills-showcase .skills-quote {
padding: 28rpx 28rpx 28rpx 24rpx; border-radius: 20rpx;
background: linear-gradient(105deg, rgba(45, 212, 191, 0.08) 0%, rgba(15, 23, 42, 0.5) 100%);
border-left: 6rpx solid #2dd4bf;
box-shadow: inset 0 0 0 1rpx rgba(45, 212, 191, 0.12);
}
.skills-quote-text { font-size: 30rpx; color: #f1f5f9; line-height: 1.75; font-weight: 500; }
.story { margin-bottom: 8rpx; }
.story-gap { height: 28rpx; }
.story-head { display: flex; align-items: center; gap: 12rpx; margin-bottom: 12rpx; }
.story-icon { flex-shrink: 0; }
.story-q { font-size: 24rpx; font-weight: 600; color: #94a3b8; }
.story-a { display: block; font-size: 28rpx; color: #e2e8f0; line-height: 1.7; padding-left: 4rpx; }
.help-grid { display: flex; flex-direction: column; gap: 20rpx; }
.help-tile {
padding: 28rpx; border-radius: 22rpx;
background: rgba(23, 33, 47, 0.9); border: 1rpx solid rgba(255, 255, 255, 0.06);
}
.help-tile-tag {
display: inline-block; font-size: 20rpx; font-weight: 700;
padding: 8rpx 18rpx; border-radius: 12rpx; margin-bottom: 16rpx;
}
.help-give .help-tile-tag { color: #5eead4; background: rgba(6, 78, 59, 0.45); border: 1rpx solid rgba(45, 212, 191, 0.2); }
.help-need .help-tile-tag.need { color: #fbbf24; background: rgba(69, 47, 8, 0.45); border: 1rpx solid rgba(251, 191, 36, 0.2); }
.help-tile-txt { font-size: 28rpx; color: #f1f5f9; line-height: 1.65; }
.proj-body { font-size: 28rpx; color: #cbd5e1; line-height: 1.75; padding-bottom: 8rpx; }
/* —— 空态 —— */
.empty-hint {
margin: 48rpx 24rpx; padding: 64rpx 32rpx;
display: flex; flex-direction: column; align-items: center; gap: 24rpx;
border-radius: 28rpx; background: rgba(15, 23, 34, 0.5);
border: 1rpx dashed rgba(148, 163, 184, 0.15);
}
.empty-hint-txt { font-size: 26rpx; color: #64748b; }
.scroll-pad { height: calc(80rpx + env(safe-area-inset-bottom)); }
/* —— 底部 —— */
.bottom-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 50;
padding: 32rpx; padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
background: rgba(5,11,20,0.95); backdrop-filter: blur(8rpx);
border-top: 1rpx solid rgba(255,255,255,0.05);
padding: 20rpx 24rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(5, 11, 20, 0.92); backdrop-filter: blur(20rpx);
border-top: 1rpx solid rgba(255, 255, 255, 0.06);
}
.vip-btn-outline {
display: flex; align-items: center; justify-content: center; gap: 16rpx;
width: 100%; height: 96rpx;
background: transparent; color: #F59E0B;
border: 2rpx solid rgba(245,158,11,0.3);
border-radius: 999rpx; font-size: 30rpx; font-weight: 500;
.vip-btn {
display: flex; align-items: center; justify-content: center; gap: 12rpx;
width: 100%; height: 88rpx; border-radius: 999rpx; border: none;
background: linear-gradient(135deg, #5eead4 0%, #2dd4bf 50%, #14b8a6 100%);
color: #0f172a; font-size: 28rpx; font-weight: 700;
box-shadow: 0 8rpx 28rpx rgba(45, 212, 191, 0.35);
}
.vip-btn-arrow { font-size: 36rpx; }
.vip-btn:active { opacity: 0.85; }
/* —— 加载态 —— */
.state-wrap {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 60vh; gap: 24rpx;
}
.state-txt { font-size: 28rpx; color: #64748b; }
.loading-dot {
width: 56rpx; height: 56rpx; border-radius: 50%;
border: 4rpx solid rgba(94, 234, 212, 0.2); border-top-color: #5eead4;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

View File

@@ -23,20 +23,23 @@ Page({
if (userId) {
const res = await app.request(`/api/miniprogram/orders?userId=${userId}`)
if (res && res.success && res.data) {
const orders = (res.data || []).map(item => ({
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() : '--'
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 })
return
}
}
const purchasedSections = app.globalData.purchasedSections || []
const purchasedSections = [...(app.globalData.purchasedSections || [])].reverse()
const orders = purchasedSections.map((id, index) => ({
id: `order_${index}`,
sectionId: id,
@@ -49,7 +52,7 @@ Page({
this.setData({ orders })
} catch (e) {
console.error('加载订单失败:', e)
const purchasedSections = app.globalData.purchasedSections || []
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',
@@ -61,6 +64,14 @@ Page({
}
},
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}` })
},
goBack() { getApp().goBackOrToHome() },
onShareAppMessage() {

View File

@@ -15,9 +15,12 @@
</view>
<view class="orders-list" wx:elif="{{orders.length > 0}}">
<view class="order-item" wx:for="{{orders}}" wx:key="id">
<view class="order-item" wx:for="{{orders}}" wx:key="id" bindtap="goToRead" data-id="{{item.sectionId}}" data-mid="{{item.sectionMid}}">
<view class="order-info">
<text class="order-title">{{item.title}}</text>
<view class="order-title-row">
<text class="order-unlock-icon">🔓</text>
<text class="order-title">{{item.title}}</text>
</view>
<text class="order-time">{{item.createTime}}</text>
</view>
<view class="order-right">

View File

@@ -9,8 +9,11 @@
@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-info { flex: 1; }
.order-title { font-size: 28rpx; color: #fff; display: block; margin-bottom: 8rpx; }
.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; }

View File

@@ -13,14 +13,44 @@
* - contentSegments 解析每行mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
*/
import accessManager from '../../utils/chapterAccessManager'
import readingTracker from '../../utils/readingTracker'
const accessManager = require('../../utils/chapterAccessManager')
const readingTracker = require('../../utils/readingTracker')
const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const { trackClick } = require('../../utils/trackClick')
const app = getApp()
/** 阅读页解析正文用:人物字典 + #标签(与 /config/read-extras 一致) */
function getContentParseConfig() {
const g = getApp().globalData || {}
const raw = Array.isArray(g.mentionPersons) ? g.mentionPersons : []
const persons = raw.map((p) => ({
personId: p.personId || '',
token: p.token || '',
name: (p.name || '').trim(),
label: (p.label || '').trim(),
aliases: p.aliases != null ? String(p.aliases) : '',
}))
const linkTags = Array.isArray(g.linkTagsConfig) ? g.linkTagsConfig : []
return { persons, linkTags }
}
/** 补全 mentionDisplay避免旧数据无字段昵称去空白防「@ 名」 */
function normalizeMentionSegments(segments) {
if (!Array.isArray(segments)) return []
return segments.map((row) => {
if (!Array.isArray(row)) return row
return row.map((seg) => {
if (!seg || seg.type !== 'mention') return seg
const nick = String(seg.nickname || '')
.replace(/^[\s\u00a0\u200b\u3000]+/g, '')
.replace(/[\s\u00a0\u200b\u3000]+$/g, '')
return { ...seg, nickname: nick, mentionDisplay: '@' + nick }
})
})
}
Page({
data: {
// 系统信息
@@ -312,6 +342,8 @@ Page({
async loadContent(id, accessState, prefetchedChapter) {
const cacheKey = `chapter_${id}`
try {
await app.getReadExtras()
const parseCfg = getContentParseConfig()
const sectionPrice = this.data.sectionPrice ?? 1
let res = prefetchedChapter
if (!res || !res.content) {
@@ -328,13 +360,13 @@ Page({
// 已解锁用 data.content完整内容未解锁用 content预览先 determineAccessState 再 loadContent 保证顺序正确
const displayContent = accessManager.canAccessFullContent(accessState) ? (res.data?.content ?? res.content) : res.content
if (res && displayContent) {
const { lines, segments } = contentParser.parseContent(displayContent)
const { lines, segments } = contentParser.parseContent(displayContent, parseCfg)
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
const updates = {
content: displayContent,
contentParagraphs: lines,
contentSegments: segments,
contentSegments: normalizeMentionSegments(segments),
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
chapterTitle: res.chapterTitle || ''
@@ -355,13 +387,14 @@ Page({
try {
const cached = wx.getStorageSync(cacheKey)
if (cached && cached.content) {
const { lines, segments } = contentParser.parseContent(cached.content)
await app.getReadExtras()
const { lines, segments } = contentParser.parseContent(cached.content, getContentParseConfig())
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
this.setData({
content: cached.content,
contentParagraphs: lines,
contentSegments: segments,
contentSegments: normalizeMentionSegments(segments),
previewParagraphs: lines.slice(0, previewCount),
partTitle: cached.partTitle || '',
chapterTitle: cached.chapterTitle || ''
@@ -460,8 +493,9 @@ Page({
},
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML
setChapterContent(res) {
const { lines, segments } = contentParser.parseContent(res.content)
async setChapterContent(res) {
await app.getReadExtras()
const { lines, segments } = contentParser.parseContent(res.content, getContentParseConfig())
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
const sectionPrice = this.data.sectionPrice ?? 1
@@ -478,7 +512,7 @@ Page({
content: res.content,
previewContent: lines.slice(0, previewCount).join('\n'),
contentParagraphs: lines,
contentSegments: segments,
contentSegments: normalizeMentionSegments(segments),
previewParagraphs: lines.slice(0, previewCount),
partTitle: res.partTitle || '',
// 导航栏、分享等使用的文章标题,同样统一为 sectionTitle
@@ -504,7 +538,7 @@ Page({
if (currentRetry >= maxRetries) {
this.setData({
contentParagraphs: ['内容加载失败', '请检查网络连接后下拉刷新重试'],
contentSegments: contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments,
contentSegments: normalizeMentionSegments(contentParser.parseContent('内容加载失败\n请检查网络连接后下拉刷新重试').segments),
previewParagraphs: ['内容加载失败']
})
return
@@ -514,7 +548,7 @@ Page({
try {
const res = await this.fetchChapterWithTimeout(id, 8000)
if (res && res.content) {
this.setChapterContent(res)
await this.setChapterContent(res)
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 重试成功:', id, '第', currentRetry + 1, '次')
return
@@ -680,12 +714,6 @@ Page({
})
return
}
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
wx.showToast({ title: '操作太频繁请2分钟后再试', icon: 'none' })
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
@@ -881,10 +909,8 @@ Page({
#创业派对 #私域运营 #商业案例`
wx.setClipboardData({
data: shareText,
success: () => {
wx.showToast({ title: '文案已复制', icon: 'success' })
}
data: shareText
// 不额外 showToast系统已有「内容已复制」避免与自定义文案叠两层
})
},
@@ -935,12 +961,17 @@ Page({
wx.setClipboardData({
data: copyText,
success: () => {
wx.showModal({
title: '文案已复制',
content: '请点击右上角「···」菜单,选择「分享到朋友圈」即可发布',
showCancel: false,
confirmText: '知道了'
})
// 系统会在 setClipboardData 成功后自动 toast「内容已复制」与下方引导弹窗重复先关掉再弹窗
wx.hideToast()
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '文案已复制。\n\n请点击右上角「···」菜单选择「分享到朋友圈」即可发布。',
showCancel: false,
confirmText: '知道了'
})
}, 120)
},
fail: () => {
wx.showToast({ title: '复制失败,请手动复制', icon: 'none' })
@@ -1099,7 +1130,28 @@ Page({
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
// 检查金额是否有效
const userInfo = app.globalData.userInfo
if (userInfo?.id) {
const avatar = userInfo.avatarUrl || ''
const nickname = userInfo.nickname || userInfo.nickName || ''
const needProfile = !avatar || avatar.includes('default') || avatar.includes('132') || !nickname || nickname === '微信用户'
if (needProfile) {
const res = await new Promise(resolve => {
wx.showModal({
title: '完善资料',
content: '购买前请先完善头像和昵称',
confirmText: '去完善',
cancelText: '稍后',
success: resolve
})
})
if (res.confirm) {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
return
}
}
}
if (!amount || amount <= 0) {
console.error('[Pay] 金额无效:', amount)
wx.showToast({ title: '价格信息错误', icon: 'none' })
@@ -1246,13 +1298,13 @@ Page({
// 支付接口失败时,显示客服联系方式
wx.showModal({
title: '支付通道维护中',
content: '微信支付正在审核中,请添加客服微信(28533368)手动购买,感谢理解!',
content: '微信支付正在审核中,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买,感谢理解!',
confirmText: '复制微信号',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
data: app.globalData.serviceWechat || '28533368',
success: () => {
wx.showToast({ title: '微信号已复制', icon: 'success' })
}
@@ -1294,13 +1346,13 @@ Page({
// 支付失败,可能是参数错误或权限问题
wx.showModal({
title: '支付失败',
content: '微信支付暂不可用,请添加客服微信(28533368)手动购买',
content: '微信支付暂不可用,请添加客服微信(' + (app.globalData.serviceWechat || '28533368') + ')手动购买',
confirmText: '复制微信号',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.setClipboardData({
data: '28533368',
data: app.globalData.serviceWechat || '28533368',
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
})
}
@@ -1453,18 +1505,22 @@ Page({
wx.navigateTo({ url: '/pages/referral/referral' })
},
// 生成海报
// 生成海报Canvas 2D API
async generatePoster() {
wx.showLoading({ title: '生成中...' })
this.setData({ showPosterModal: true, isGeneratingPoster: true })
await new Promise((resolve) => {
this.setData({ showPosterModal: true, isGeneratingPoster: true }, () => resolve())
})
await new Promise((resolve) => {
if (typeof wx.nextTick === 'function') wx.nextTick(resolve)
else setTimeout(resolve, 50)
})
try {
const ctx = wx.createCanvasContext('posterCanvas', this)
const { section, contentParagraphs, sectionId, sectionMid } = this.data
const userInfo = app.globalData.userInfo
const userId = userInfo?.id || ''
// 获取小程序码(带推荐人参数,优先 mid 与新链接一致)
let qrcodeImage = null
try {
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
@@ -1473,109 +1529,102 @@ Page({
method: 'POST',
data: { scene, page: 'pages/read/read', width: 280 }
})
if (qrRes.success && qrRes.image) {
qrcodeImage = qrRes.image
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 canvas = canvasNode.node
const ctx = canvas.getContext('2d')
let dpr = 2
try {
if (typeof wx.getWindowInfo === 'function') {
dpr = wx.getWindowInfo().pixelRatio || 2
} else if (wx.getSystemInfoSync) {
dpr = wx.getSystemInfoSync().pixelRatio || 2
}
} catch (e) {
console.log('[Poster] 获取小程序码失败,使用占位符')
} catch (_) {
dpr = 2
}
// 海报尺寸 300x450
const width = 300
const height = 450
// 背景渐变
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
const grd = ctx.createLinearGradient(0, 0, 0, height)
grd.addColorStop(0, '#1a1a2e')
grd.addColorStop(1, '#16213e')
ctx.setFillStyle(grd)
ctx.fillStyle = grd
ctx.fillRect(0, 0, width, height)
// 顶部装饰条
ctx.setFillStyle('#00CED1')
ctx.fillStyle = '#00CED1'
ctx.fillRect(0, 0, width, 4)
// 标题区域
ctx.setFillStyle('#ffffff')
ctx.setFontSize(14)
ctx.fillStyle = '#ffffff'
ctx.font = '14px sans-serif'
ctx.fillText('📚 卡若创业派对', 20, 35)
// 章节标题
ctx.setFontSize(18)
ctx.setFillStyle('#ffffff')
ctx.font = '18px sans-serif'
ctx.fillStyle = '#ffffff'
const title = section?.title || '精彩内容'
const titleLines = this.wrapText(ctx, title, width - 40, 18)
const titleLines = this.wrapText2d(ctx, title, width - 40)
let y = 70
titleLines.forEach(line => {
ctx.fillText(line, 20, y)
y += 26
})
// 分隔线
ctx.setStrokeStyle('rgba(255,255,255,0.1)')
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.setFontSize(12)
ctx.setFillStyle('rgba(255,255,255,0.8)')
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.wrapText(ctx, summary, width - 40, 12)
summaryLines.slice(0, 6).forEach(line => {
ctx.fillText(line, 20, y)
y += 20
})
// 底部区域背景
ctx.setFillStyle('rgba(0,206,209,0.1)')
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.setFillStyle('#ffffff')
ctx.setFontSize(13)
ctx.fillStyle = '#ffffff'
ctx.font = '13px sans-serif'
ctx.fillText('长按识别小程序码', 20, height - 60)
ctx.setFillStyle('rgba(255,255,255,0.6)')
ctx.setFontSize(11)
ctx.fillStyle = 'rgba(255,255,255,0.6)'
ctx.font = '11px sans-serif'
ctx.fillText('长按小程序码阅读全文', 20, height - 38)
// 绘制小程序码或占位符
const drawQRCode = () => {
return new Promise((resolve) => {
if (qrcodeImage) {
// 下载base64图片并绘制
const fs = wx.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/qrcode_${Date.now()}.png`
const base64Data = qrcodeImage.replace(/^data:image\/\w+;base64,/, '')
fs.writeFile({
filePath,
data: base64Data,
encoding: 'base64',
success: () => {
ctx.drawImage(filePath, width - 85, height - 85, 70, 70)
resolve()
},
fail: () => {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
} else {
this.drawQRPlaceholder(ctx, width, height)
resolve()
}
})
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)
}
} else {
this.drawQRPlaceholder2d(ctx, width, height)
}
await drawQRCode()
ctx.draw(true, () => {
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
})
wx.hideLoading()
this.setData({ isGeneratingPoster: false })
} catch (e) {
console.error('生成海报失败:', e)
wx.hideLoading()
@@ -1583,21 +1632,19 @@ Page({
this.setData({ showPosterModal: false, isGeneratingPoster: false })
}
},
// 绘制小程序码占位符
drawQRPlaceholder(ctx, width, height) {
ctx.setFillStyle('#ffffff')
drawQRPlaceholder2d(ctx, width, height) {
ctx.fillStyle = '#ffffff'
ctx.beginPath()
ctx.arc(width - 50, height - 50, 35, 0, Math.PI * 2)
ctx.fill()
ctx.setFillStyle('#00CED1')
ctx.setFontSize(9)
ctx.fillStyle = '#00CED1'
ctx.font = '9px sans-serif'
ctx.fillText('扫码', width - 57, height - 52)
ctx.fillText('阅读', width - 57, height - 40)
},
// 文字换行处理
wrapText(ctx, text, maxWidth, fontSize) {
wrapText2d(ctx, text, maxWidth) {
const lines = []
let line = ''
for (let i = 0; i < text.length; i++) {
@@ -1619,39 +1666,47 @@ Page({
this.setData({ showPosterModal: false })
},
// 保存海报到相册
// 保存海报到相册Canvas 2D
savePoster() {
wx.canvasToTempFilePath({
canvasId: 'posterCanvas',
success: (res) => {
wx.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' })
this.setData({ showPosterModal: false })
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
wx.showModal({
title: '提示',
content: '需要相册权限才能保存海报',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
wx.openSetting()
}
wx.createSelectorQuery().in(this)
.select('#posterCanvas')
.fields({ node: true, size: true })
.exec(res => {
if (!res || !res[0] || !res[0].node) {
wx.showToast({ title: '保存失败', icon: 'none' })
return
}
const canvas = res[0].node
wx.canvasToTempFilePath({
canvas,
success: (r2) => {
wx.saveImageToPhotosAlbum({
filePath: r2.tempFilePath,
success: () => {
wx.showToast({ title: '已保存到相册', icon: 'success' })
this.setData({ showPosterModal: false })
},
fail: (err) => {
if (err.errMsg.includes('auth deny')) {
wx.showModal({
title: '提示',
content: '需要相册权限才能保存海报',
confirmText: '去设置',
success: (m) => {
if (m.confirm) wx.openSetting()
}
})
} else {
wx.showToast({ title: '保存失败', icon: 'none' })
}
})
} else {
wx.showToast({ title: '保存失败', icon: 'none' })
}
}
})
},
fail: () => {
wx.showToast({ title: '生成图片失败', icon: 'none' })
}
})
},
fail: () => {
wx.showToast({ title: '生成图片失败', icon: 'none' })
}
}, this)
}, this)
})
},
// 阻止冒泡

View File

@@ -54,11 +54,9 @@
<!-- 完整内容 - 免费或已购买(支持 @ mention / #linkTag / 图片) -->
<view class="article" wx:if="{{accessState === 'free' || accessState === 'unlocked_purchased'}}">
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
<text user-select wx:if="{{!(item.length === 1 && item[0].type === 'image')}}"><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text>
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<text wx:if="{{seg.type === 'text'}}" user-select>{{seg.text}}</text>
<text wx:elif="{{seg.type === 'mention'}}" class="mention" user-select bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">@{{seg.nickname}}</text>
<text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" user-select bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text>
<image wx:elif="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>
@@ -94,23 +92,17 @@
<!-- 分享操作区 -->
<view class="action-section">
<view class="action-row-inline">
<view class="action-btn-inline" bindtap="onShareTimelineTap">
<view class="action-btn-inner">
<image class="action-btn-icon" src="/assets/icons/share.svg" mode="aspectFit"></image>
<text class="action-text-small">分享到朋友圈</text>
</view>
<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" bindtap="generatePoster">
<view class="action-btn-inner">
<image class="action-btn-icon" src="/assets/icons/image.svg" mode="aspectFit"></image>
<text class="action-text-small">生成海报</text>
</view>
<view class="action-btn-inline btn-poster-inline" bindtap="generatePoster">
<icon name="image" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">生成海报</text>
</view>
<view class="action-btn-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<view class="action-btn-inner">
<image class="action-btn-icon" src="/assets/icons/gift.svg" mode="aspectFit"></image>
<text class="action-text-small">代付分享</text>
</view>
<view class="action-btn-inline btn-gift-inline" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="32" color="#00CED1" customClass="action-icon-small"></icon>
<text class="action-text-small">代付分享</text>
</view>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
@@ -130,15 +122,23 @@
<!-- 渐变遮罩 -->
<view class="fade-mask"></view>
<!-- 付费墙 - 未登录 -->
<!-- 付费墙 - 未登录:显示购买按钮(朋友圈/分享场景) -->
<view class="paywall">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">登录后继续阅读</text>
<text class="paywall-desc">已阅读{{previewPercent}}%,登录后查看完整内容</text>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已预览部分内容,登录并购买后阅读全文</text>
<view class="login-btn" bindtap="showLoginModal">
<text class="login-btn-text">手机号一键登录</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>
</view>
<!-- 章节导航 -->
@@ -185,7 +185,7 @@
<view class="paywall">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc">已阅读{{previewPercent}}%,购买后继续阅读</text>
<text class="paywall-desc">已阅读50%,购买后继续阅读</text>
<!-- 购买选项(审核模式隐藏) -->
<view class="purchase-options" wx:if="{{!auditMode}}">
@@ -280,7 +280,7 @@
<!-- 海报预览 -->
<view class="poster-preview">
<canvas canvas-id="posterCanvas" class="poster-canvas" style="width: 300px; height: 450px;"></canvas>
<canvas type="2d" id="posterCanvas" class="poster-canvas" style="width: 300px; height: 450px;"></canvas>
</view>
<view class="poster-actions">
@@ -359,6 +359,6 @@
<!-- 右下角悬浮按钮 - 分享到朋友圈 -->
<view class="fab-share" bindtap="shareToMoments">
<image class="fab-circle-icon" src="/assets/icons/circle.svg" mode="aspectFit"></image>
<icon name="share" size="44" color="#0f172a" customClass="fab-share-icon"></icon>
</view>
</view>

View File

@@ -479,10 +479,11 @@
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 24rpx 12rpx;
border-radius: 16rpx;
border: 2rpx solid rgba(0, 206, 209, 0.25);
background: rgba(0, 206, 209, 0.08);
border: none;
background: transparent;
line-height: normal;
box-sizing: border-box;
overflow: hidden;
@@ -492,28 +493,31 @@
border: none;
}
.action-btn-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
.btn-share-inline {
background: rgba(7, 193, 96, 0.15);
border: 2rpx solid rgba(7, 193, 96, 0.3);
}
.action-btn-icon {
width: 44rpx;
height: 44rpx;
filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%);
.btn-poster-inline {
background: rgba(255, 215, 0, 0.15);
border: 2rpx solid rgba(255, 215, 0, 0.3);
}
.action-icon-small {
font-size: 28rpx;
flex-shrink: 0;
}
.action-text-small {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
color: #ffffff;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
flex: 1;
min-width: 0;
}
.share-tip-inline {
@@ -655,6 +659,9 @@
}
/* ===== 代付分享 ===== */
.btn-gift-inline {
/* 与 btn-share-inline 同风格 */
}
.gift-share-row {
display: flex;
align-items: center;
@@ -1248,15 +1255,20 @@
.fab-share {
position: fixed;
right: 32rpx;
width:70rpx!important;
bottom: calc(120rpx + env(safe-area-inset-bottom));
height: 70rpx;
border-radius: 60rpx;
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;
display:flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.fab-share::after {
@@ -1265,10 +1277,18 @@
.fab-share:active {
transform: scale(0.95);
box-shadow: 0 4rpx 20rpx rgba(0, 206, 209, 0.5);
}
.fab-circle-icon {
width: 72rpx;
height: 72rpx;
.fab-icon {
padding:16rpx;
width: 50rpx;
height: 50rpx;
display: block;
}
.fab-share-icon {
font-size: 44rpx;
line-height: 1;
}

View File

@@ -63,8 +63,7 @@ Page({
posterReferralLink: '',
posterNickname: '',
posterNicknameInitial: '',
posterCaseCount: 62,
posterCaseCount: 62
},
onLoad() {
@@ -94,28 +93,14 @@ Page({
// 生成邀请码
const referralCode = userInfo.referralCode || 'SOUL' + (userInfo.id || Date.now().toString(36)).toUpperCase().slice(-6)
console.log('[Referral] 开始加载分销数据userId:', userInfo.id)
// 从API获取真实数据
let realData = null
try {
// app.request 第一个参数是 URL 字符串(会自动拼接 baseUrl
const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id)
console.log('[Referral] API返回:', JSON.stringify(res).substring(0, 200))
if (res && res.success && res.data) {
realData = res.data
console.log('[Referral] ✅ 获取推广数据成功')
console.log('[Referral] - bindingCount:', realData.bindingCount)
console.log('[Referral] - paidCount:', realData.paidCount)
console.log('[Referral] - earnings:', realData.earnings)
console.log('[Referral] - expiringCount:', realData.stats?.expiringCount)
} else {
console.log('[Referral] ❌ API返回格式错误:', res?.error || 'unknown')
}
} catch (e) {
console.log('[Referral] API调用失败:', e.message || e)
console.log('[Referral] 错误详情:', e)
console.error('[Referral] API调用失败:', e.message || e)
}
// 使用真实数据或默认值
@@ -123,14 +108,10 @@ Page({
let convertedBindings = realData?.convertedUsers || []
let expiredBindings = realData?.expiredUsers || []
console.log('[Referral] activeBindings:', activeBindings.length)
console.log('[Referral] convertedBindings:', convertedBindings.length)
console.log('[Referral] expiredBindings:', expiredBindings.length)
// 计算即将过期的数量7天内
const expiringCount = realData?.stats?.expiringCount || activeBindings.filter(b => b.daysRemaining <= 7 && b.daysRemaining > 0).length
console.log('[Referral] expiringCount:', expiringCount)
// 计算各类统计
const bindingCount = realData?.bindingCount || activeBindings.length
@@ -153,7 +134,6 @@ Page({
purchaseCount: user.purchaseCount || 0,
conversionDate: user.conversionDate ? this.formatDate(user.conversionDate) : '--'
}
console.log('[Referral] 格式化用户:', formatted.nickname, formatted.status, formatted.daysRemaining + '天')
return formatted
}
@@ -169,15 +149,6 @@ Page({
const availableEarningsNum = Math.max(0, totalCommissionNum - withdrawnNum - pendingWithdrawNum)
const minWithdrawAmount = realData?.minWithdrawAmount || 10
console.log('=== [Referral] 收益计算(完整版)===')
console.log('累计佣金 (totalCommission):', totalCommissionNum)
console.log('已提现金额 (withdrawnEarnings):', withdrawnNum)
console.log('待审核金额 (pendingWithdrawAmount):', pendingWithdrawNum)
console.log('可提现金额 = 累计 - 已提现 - 待审核 =', totalCommissionNum, '-', withdrawnNum, '-', pendingWithdrawNum, '=', availableEarningsNum)
console.log('最低提现金额 (minWithdrawAmount):', minWithdrawAmount)
console.log('按钮判断:', availableEarningsNum, '>=', minWithdrawAmount, '=', availableEarningsNum >= minWithdrawAmount)
console.log('✅ 按钮应该:', availableEarningsNum >= minWithdrawAmount ? '🟢 启用(绿色)' : '⚫ 禁用(灰色)')
const hasWechatId = !!(userInfo?.wechat || userInfo?.wechatId || wx.getStorageSync('user_wechat'))
this.setData({
isLoggedIn: true,
@@ -234,20 +205,6 @@ Page({
})
console.log('[Referral] ✅ 数据设置完成')
console.log('[Referral] - 绑定中:', this.data.bindingCount)
console.log('[Referral] - 即将过期:', this.data.expiringCount)
console.log('[Referral] - 收益:', this.data.earnings)
console.log('=== [Referral] 按钮状态验证 ===')
console.log('累计佣金 (totalCommission):', this.data.totalCommission)
console.log('待审核金额 (pendingWithdrawAmount):', this.data.pendingWithdrawAmount)
console.log('可提现金额 (availableEarnings 显示):', this.data.availableEarnings)
console.log('可提现金额 (availableEarningsNum 判断):', this.data.availableEarningsNum, typeof this.data.availableEarningsNum)
console.log('最低提现金额 (minWithdrawAmount):', this.data.minWithdrawAmount, typeof this.data.minWithdrawAmount)
console.log('按钮启用条件:', this.data.availableEarningsNum, '>=', this.data.minWithdrawAmount, '=', this.data.availableEarningsNum >= this.data.minWithdrawAmount)
console.log('✅ 最终结果: 按钮应该', this.data.availableEarningsNum >= this.data.minWithdrawAmount ? '🟢 启用' : '⚫ 禁用')
// 隐藏加载提示
wx.hideLoading()
} else {

View File

@@ -3,20 +3,13 @@
* 账号绑定功能
*/
const app = getApp()
const { toAvatarPath } = require('../../utils/util.js')
Page({
data: {
statusBarHeight: 44,
isLoggedIn: false,
userInfo: null,
version: '1.0.0',
isDevMode: false, // 是否开发版(用于显示切换账号入口)
// 切换账号(开发)
showSwitchAccountModal: false,
switchAccountUserId: '',
switchAccountLoading: false,
version: '',
// 绑定信息
phoneNumber: '',
@@ -38,16 +31,27 @@ Page({
wx.showShareMenu({ withShareTimeline: true })
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
const envVersion = accountInfo?.miniProgram?.envVersion || ''
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
isLoggedIn: app.globalData.isLoggedIn,
userInfo: app.globalData.userInfo,
isDevMode: envVersion === 'develop'
isDevMode: envVersion === 'develop',
version: displayVersion
})
this.loadBindingInfo()
},
onShow() {
const accountInfo = wx.getAccountInfoSync ? wx.getAccountInfoSync() : null
const wxPkgVersion = (accountInfo?.miniProgram?.version || '').trim()
const displayVersion =
wxPkgVersion ||
(app.globalData.appDisplayVersion || '1.7.1')
this.setData({ version: displayVersion })
this.loadBindingInfo()
},
@@ -247,92 +251,6 @@ Page({
}
},
// 获取微信头像(新版授权)
async getWechatAvatar() {
try {
const res = await wx.getUserProfile({
desc: '用于完善会员资料'
})
if (res.userInfo) {
const { nickName, avatarUrl: tempAvatarUrl } = res.userInfo
wx.showLoading({ title: '上传中...', mask: true })
// 1. 先上传图片到服务器
console.log('[Settings] 开始上传头像:', tempAvatarUrl)
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: {
folder: 'avatars'
},
success: (uploadResult) => {
try {
const data = JSON.parse(uploadResult.data)
if (data.success) {
resolve(data)
} else {
reject(new Error(data.error || '上传失败'))
}
} catch (err) {
reject(new Error('解析响应失败'))
}
},
fail: (err) => {
reject(err)
}
})
})
// 2. 获取上传后的完整URL显示用保存时只传路径
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
}
console.log('[Settings] 头像上传成功:', avatarUrl)
// 3. 更新本地
this.setData({
userInfo: {
...this.data.userInfo,
nickname: nickName,
avatar: avatarUrl
}
})
// 4. 同步到服务器数据库(只保存路径,不含域名)
const userId = app.globalData.userInfo?.id
if (userId) {
await app.request('/api/miniprogram/user/profile', {
method: 'POST',
data: { userId, nickname: nickName, avatar: toAvatarPath(avatarUrl) }
})
}
// 5. 更新全局
if (app.globalData.userInfo) {
app.globalData.userInfo.nickname = nickName
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.hideLoading()
wx.showToast({ title: '头像更新成功', icon: 'success' })
}
} catch (e) {
wx.hideLoading()
console.error('[Settings] 获取头像失败:', e)
wx.showToast({
title: e.message || '获取头像失败',
icon: 'none'
})
}
},
// 微信隐私协议同意getPhoneNumber 需先同意)
onAgreePrivacyForPhone() {
if (app._privacyResolve) {
@@ -402,71 +320,6 @@ Page({
this.setData({ showBindModal: false })
},
// 跳转账户密码登录页(开发)
goToDevLogin() {
wx.navigateTo({ url: '/pages/dev-login/dev-login' })
},
// 打开切换账号弹窗(开发)
openSwitchAccountModal() {
this.setData({
showSwitchAccountModal: true,
switchAccountUserId: app.globalData.userInfo?.id || ''
})
},
// 关闭切换账号弹窗
closeSwitchAccountModal() {
if (this.data.switchAccountLoading) return
this.setData({ showSwitchAccountModal: false, switchAccountUserId: '' })
},
// 切换账号 userId 输入
onSwitchAccountUserIdInput(e) {
this.setData({ switchAccountUserId: e.detail.value.trim() })
},
// 确认切换账号
async confirmSwitchAccount() {
const userId = this.data.switchAccountUserId.trim()
if (!userId || this.data.switchAccountLoading) return
this.setData({ switchAccountLoading: true })
try {
const res = await app.request('/api/miniprogram/dev/login-as', {
method: 'POST',
data: { userId }
})
if (res && res.success && res.data) {
const { token, user } = res.data
const openId = res.data.openId || ''
wx.setStorageSync('token', token)
wx.setStorageSync('userInfo', user)
app.globalData.userInfo = user
app.globalData.isLoggedIn = true
app.globalData.purchasedSections = user.purchasedSections || []
app.globalData.hasFullBook = user.hasFullBook || false
app.globalData.isVip = user.isVip || false
app.globalData.vipExpireDate = user.vipExpireDate || ''
if (openId) {
app.globalData.openId = openId
wx.setStorageSync('openId', openId)
}
this.setData({
showSwitchAccountModal: false,
switchAccountUserId: '',
switchAccountLoading: false
})
this.loadBindingInfo()
wx.showToast({ title: '已切换为 ' + (user.nickname || userId), icon: 'success' })
} else {
throw new Error(res?.error || '切换失败')
}
} catch (e) {
this.setData({ switchAccountLoading: false })
wx.showToast({ title: e.message || '切换失败', icon: 'none' })
}
},
// 清除缓存
clearCache() {
wx.showModal({

View File

@@ -113,21 +113,7 @@
<text class="tip-text">提示:绑定微信号才能使用提现功能</text>
</view>
<!-- 开发专用:切换账号(仅开发版显示) -->
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="openSwitchAccountModal">
<view class="dev-switch-inner">
<icon name="wrench" size="40" color="#8e8e93" customClass="dev-switch-icon"></icon>
<text class="dev-switch-text">切换账号(开发)</text>
<text class="dev-switch-desc">输入 userId 切换为其他账号调试</text>
</view>
</view>
<view class="dev-switch-card" wx:if="{{isDevMode}}" bindtap="goToDevLogin">
<view class="dev-switch-inner">
<icon name="smartphone" size="40" color="#8e8e93" customClass="dev-switch-icon"></icon>
<text class="dev-switch-text">账户密码登录</text>
<text class="dev-switch-desc">输入对方手机号登录,密码可留空</text>
</view>
</view>
<!-- 开发专用入口已移除 -->
<view class="logout-btn" wx:if="{{isLoggedIn}}" bindtap="handleLogout">退出登录</view>
</view>
@@ -141,10 +127,10 @@
</view>
<view class="modal-body">
<view class="form-input-wrap">
<view class="input-wrapper">
<input
class="form-input-inner"
type="{{bindType === 'phone' ? 'number' : 'text'}}"
class="form-input"
placeholder="{{bindType === 'phone' ? '请输入11位手机号' : bindType === 'wechat' ? '请输入微信号' : '请输入支付宝账号'}}"
placeholder-class="input-placeholder"
value="{{bindValue}}"
@@ -164,28 +150,4 @@
</view>
</view>
<!-- 切换账号弹窗(开发) -->
<view class="modal-overlay" wx:if="{{showSwitchAccountModal}}" bindtap="closeSwitchAccountModal">
<view class="modal-content" catchtap="stopPropagation">
<view class="modal-header">
<text class="modal-title">切换账号(开发)</text>
<view class="modal-close" bindtap="closeSwitchAccountModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
</view>
<view class="modal-body">
<view class="form-input-wrap">
<input
class="form-input-inner"
placeholder="请输入目标用户的 userId如 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg"
placeholder-class="input-placeholder"
value="{{switchAccountUserId}}"
bindinput="onSwitchAccountUserIdInput"
/>
</view>
<text class="bind-tip">从管理端或数据库获取要调试的用户 ID切换后将以该用户身份操作</text>
<view class="btn-primary {{!switchAccountUserId || switchAccountLoading ? 'btn-disabled' : ''}}" bindtap="confirmSwitchAccount">
{{switchAccountLoading ? '切换中...' : '确认切换'}}
</view>
</view>
</view>
</view>
</view>

View File

@@ -116,9 +116,9 @@
.modal-title { font-size: 36rpx; font-weight: 700; color: #fff; }
.modal-close { width: 64rpx; height: 64rpx; background: rgba(255,255,255,0.08); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32rpx; color: rgba(255,255,255,0.5); }
.modal-body { padding: 16rpx 40rpx 48rpx; }
/* 弹窗 input外边包 viewpadding 写在 view 上,避免光标截断 */
.form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; margin-bottom: 32rpx; }
.form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; }
.input-wrapper { margin-bottom: 32rpx; }
.form-input { width: 100%; padding: 32rpx 24rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; font-size: 32rpx; color: #fff; box-sizing: border-box; transition: all 0.2s; }
.form-input:focus { border-color: rgba(0,206,209,0.5); background: rgba(0,206,209,0.05); }
.input-placeholder { color: rgba(255,255,255,0.25); }
.bind-tip { font-size: 24rpx; color: rgba(255,255,255,0.4); margin-bottom: 40rpx; display: block; line-height: 1.6; text-align: center; }
.btn-primary { padding: 32rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #000; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 28rpx; }

View File

@@ -1,4 +1,4 @@
import accessManager from '../../utils/chapterAccessManager'
const accessManager = require('../../utils/chapterAccessManager')
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
@@ -12,16 +12,16 @@ Page({
originalPrice: 6980,
/* 按 premium_membership_landing_v1 设计稿 */
contentRights: [
{ title: '解锁全部章节', desc: '365天全案精读', icon: 'book-open' },
{ title: '案例库', desc: '100+创业实战案例', icon: 'book-open' },
{ title: '智能纪要', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ title: '会议纪要库', desc: '往期完整沉淀', icon: 'folder' }
{ title: '匹配伙伴', desc: '精准匹配创业伙伴', icon: 'users' },
{ title: '派对专属', desc: '创业派对房专享', icon: 'star' },
{ title: '老板排行', desc: '创业老板排行榜', icon: 'bar-chart' },
{ title: '轮流置顶', desc: '首页获客曝光位', icon: 'arrow-up' }
],
socialRights: [
{ title: '匹配创业伙伴', desc: '精准人脉匹配', icon: 'users' },
{ title: '创业老板排行', desc: '项目曝光展示', icon: 'bar-chart' },
{ title: '链接资源', desc: '深度私域资源池', icon: 'link' },
{ title: '专属VIP标识', desc: '金色尊享光圈', icon: 'check' }
{ title: '案例宝库', desc: '100+赚钱案例库', icon: 'book-open' },
{ title: '全书解锁', desc: '365天全案精读', icon: 'folder' },
{ title: '每日总结', desc: 'AI每日精华推送', icon: 'lightbulb' },
{ title: '获取客资', desc: '文章@你即可获客', icon: 'link' }
],
purchasing: false
},

View File

@@ -3,12 +3,15 @@ const { trackClick } = require('../../utils/trackClick')
Page({
data: {
statusBarHeight: 44,
statusBarHeight: 0,
balance: 0,
balanceText: '0.00',
totalRecharged: '0.00',
totalGifted: '0.00',
totalRefunded: '0.00',
transactions: [],
loading: true,
rechargeAmounts: [10, 30, 50, 100],
rechargeAmounts: [10, 30, 50, 1000],
selectedAmount: 30,
auditMode: false,
},
@@ -35,6 +38,9 @@ Page({
this.setData({
balance: res.data.balance || 0,
balanceText: (res.data.balance || 0).toFixed(2),
totalRecharged: (res.data.totalRecharged || 0).toFixed(2),
totalGifted: (res.data.totalGifted || 0).toFixed(2),
totalRefunded: (res.data.totalRefunded || 0).toFixed(2),
loading: false,
})
}
@@ -51,10 +57,9 @@ Page({
if (res && res.data) {
const list = (res.data || []).map(t => ({
...t,
amountText: Math.abs(t.amount || 0).toFixed(2),
amountSign: (t.amount || 0) >= 0 ? '+' : '-',
description: t.type === 'recharge' ? '充值' : t.type === 'consume' ? '阅读消费' : t.type === 'refund' ? '退款' : '其他',
createdAt: t.createdAt ? new Date(t.createdAt).toLocaleString('zh-CN') : '--',
amountText: (t.amount || 0).toFixed(2),
amountSign: (t.amount || 0) >= 0 ? '+' : '',
description: t.description || (t.type === 'recharge' ? '充值' : t.type === 'gift' ? '赠送' : t.type === 'refund' ? '退款' : t.type === 'consume' ? '阅读消费' : '其他'),
}))
this.setData({ transactions: list })
}
@@ -112,6 +117,7 @@ Page({
wx.requestPayment({
...params,
success: async () => {
// Confirm the recharge
await app.request({
url: '/api/miniprogram/balance/recharge/confirm',
method: 'POST',
@@ -126,16 +132,53 @@ Page({
}
})
} else {
wx.showToast({ title: payRes?.error || '创建支付失败', icon: 'none' })
wx.showToast({ title: '创建支付失败', icon: 'none' })
}
}
} catch (e) {
wx.hideLoading()
console.error('[Wallet] recharge error', e)
console.error('[Wallet] recharge error:', e)
wx.showToast({ title: '充值失败:' + (e.message || e.errMsg || '网络异常'), icon: 'none', duration: 3000 })
}
},
async handleRefund() {
trackClick('wallet', 'btn_click', '退款')
if (this.data.balance <= 0) {
wx.showToast({ title: '余额为零', icon: 'none' })
return
}
const userId = app.globalData.userInfo.id
const balance = this.data.balance
wx.showModal({
title: '余额退款',
content: `退回全部余额 ¥${balance.toFixed(2)}\n\n退款将在1-3个工作日内原路返回`,
confirmText: '确认退款',
cancelText: '取消',
success: async (res) => {
if (!res.confirm) return
wx.showLoading({ title: '处理中...' })
try {
const result = await app.request({
url: '/api/miniprogram/balance/refund',
method: 'POST',
data: { userId, amount: balance }
})
wx.hideLoading()
if (result && result.data) {
wx.showToast({ title: result.data.message || '退款成功', icon: 'success' })
this.loadBalance()
this.loadTransactions()
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: '退款失败', icon: 'none' })
}
}
})
},
goBack() {
wx.navigateBack()
},

View File

@@ -5,6 +5,7 @@
padding-bottom: 64rpx;
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
@@ -45,6 +46,7 @@
width: 100%;
}
/* 余额卡片 - 渐变背景 */
.balance-card {
margin: 24rpx 24rpx 32rpx;
background: linear-gradient(135deg, #1c1c1e 0%, rgba(56, 189, 172, 0.15) 100%);
@@ -90,6 +92,7 @@
color: rgba(255, 255, 255, 0.4);
}
/* 区块标题 */
.section {
margin: 0 24rpx 32rpx;
}
@@ -110,6 +113,7 @@
color: rgba(255, 255, 255, 0.45);
}
/* 金额选择卡片 */
.amount-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -170,6 +174,7 @@
color: rgba(213, 255, 250, 0.72);
}
/* 操作按钮 */
.action-row {
display: flex;
gap: 24rpx;
@@ -189,7 +194,13 @@
background: #38bdac;
color: #0a0a0a;
}
.btn-refund {
background: #1c1c1e;
color: rgba(255, 255, 255, 0.9);
border: 2rpx solid rgba(56, 189, 172, 0.4);
}
/* 交易记录 */
.transactions {
background: #1c1c1e;
border-radius: 24rpx;

View File

@@ -4,7 +4,7 @@
"description": "卡若创业派对 - 来自派对房的真实商业故事",
"appid": "wxb8bbb2b10dec74aa",
"setting": {
"urlCheck": false,
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": true,

View File

@@ -27,8 +27,8 @@
"name": "开发登录",
"pathName": "pages/dev-login/dev-login",
"query": "",
"scene": null,
"launchMode": "default"
"launchMode": "default",
"scene": null
},
{
"name": "pages/member-detail/member-detail",

View File

@@ -206,4 +206,4 @@ class ChapterAccessManager {
// 导出单例
const accessManager = new ChapterAccessManager()
export default accessManager
module.exports = accessManager

View File

@@ -27,6 +27,29 @@ function decodeEntities(str) {
.replace(/&#39;/g, "'")
}
/**
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
*/
function cleanSingleLineField(s) {
if (!s && s !== 0) return ''
let t = decodeEntities(String(s))
.replace(/<br\s*\/?>/gi, ' ')
.replace(/\r\n|\r|\n/g, ' ')
.replace(/[\s\u00a0\u200b\u200c\u200d\ufeff\u3000]+/g, ' ')
.trim()
return t
}
/** @提及昵称:去首尾空白、零宽、全角空格;合并内部换行/<br> */
function cleanMentionNickname(n) {
return cleanSingleLineField(n)
}
/** 纯文本在 mention 节点前若已有「@」,去掉末尾 @,避免渲染成「找@@阿浪」 */
function stripTrailingAtForMention(before) {
return before.replace(/[@][\s\u00a0\u200b]*$/u, '')
}
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
@@ -39,20 +62,25 @@ function parseBlockToSegments(block) {
let m
while ((m = tokenRe.exec(block)) !== null) {
// 前置纯文本
const before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
// 前置纯文本mention 紧挨手写「找@」时去掉重复 @
let before = decodeEntities(block.slice(lastEnd, m.index).replace(/<[^>]+>/g, ''))
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
before = stripTrailingAtForMention(before)
}
if (before.trim()) segs.push({ type: 'text', text: before })
const tag = m[0]
if (/data-type="mention"/i.test(tag)) {
// @mention — TipTap mention span
// @mention — TipTap mention spanspan 内常见「@ 昵称」多空格,统一紧挨显示)
const idMatch = tag.match(/data-id="([^"]*)"/)
const labelMatch = tag.match(/data-label="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '')
const userId = idMatch ? idMatch[1].trim() : ''
const nickname = labelMatch ? labelMatch[1].trim() : innerText.replace(/^@/, '').trim()
if (userId || nickname) segs.push({ type: 'mention', userId, nickname })
let nickname = labelMatch ? labelMatch[1] : innerText.replace(/^[@]\s*/, '')
nickname = cleanMentionNickname((nickname || '').trim())
if (userId || nickname) {
segs.push({ type: 'mention', userId, nickname, mentionDisplay: '@' + nickname })
}
} else if (/data-type="linkTag"/i.test(tag)) {
// #linkTag — 自定义 span 格式data-type="linkTag" data-url="..." data-tag-type="..." data-page-path="..." data-app-id="..."
@@ -62,7 +90,7 @@ function parseBlockToSegments(block) {
const tagIdMatch = tag.match(/data-tag-id="([^"]*)"/)
const appIdMatch = tag.match(/data-app-id="([^"]*)"/)
const mpKeyMatch = tag.match(/data-mp-key="([^"]*)"/)
const innerText = tag.replace(/<[^>]+>/g, '').replace(/^#/, '').trim()
const innerText = cleanSingleLineField(tag.replace(/<[^>]+>/g, '').replace(/^#/, ''))
const url = urlMatch ? urlMatch[1] : ''
const tagType = tagTypeMatch ? tagTypeMatch[1] : 'url'
const pagePath = pagePathMatch ? pagePathMatch[1] : ''
@@ -75,7 +103,7 @@ function parseBlockToSegments(block) {
// #linkTag — 旧格式 <a href>insertLinkTag 旧版产生url 可能为空)
// m[1] = href, m[2] = innerText以 # 开头)
const url = m[1] || ''
const label = (m[2] || '').replace(/^#/, '').trim()
const label = cleanSingleLineField((m[2] || '').replace(/^#/, ''))
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
@@ -100,8 +128,10 @@ function parseBlockToSegments(block) {
/**
* 从 HTML 中解析出 lines纯文本行和 segments含富文本片段
* @param {string} html
* @param {object} [config] - { persons: [], linkTags: [] },用于对 text 段自动匹配 @人名 / #标签
*/
function parseHtmlToSegments(html) {
function parseHtmlToSegments(html, config) {
const lines = []
const segments = []
@@ -125,7 +155,7 @@ function parseHtmlToSegments(html) {
for (const block of blocks) {
if (!block.trim()) continue
const blockSegs = parseBlockToSegments(block)
let blockSegs = parseBlockToSegments(block)
if (!blockSegs.length) continue
// 纯图片行独立成段
@@ -135,6 +165,20 @@ function parseHtmlToSegments(html) {
continue
}
// 对 text 段再跑一遍 @人名 / #标签 自动匹配(处理未用 TipTap 插入而是手打的 @xxx
if (config && (config.persons?.length || config.linkTags?.length)) {
const expanded = []
for (const seg of blockSegs) {
if (seg.type === 'text' && seg.text) {
const sub = matchLineToSegments(seg.text, config)
expanded.push(...sub)
} else {
expanded.push(seg)
}
}
blockSegs = expanded
}
// 行纯文本用于 linespreviewParagraphs 降级展示)
const lineText = decodeEntities(block.replace(/<[^>]+>/g, '')).trim()
lines.push(lineText)
@@ -144,29 +188,150 @@ function parseHtmlToSegments(html) {
return { lines, segments }
}
/** 纯文本按行解析(无 HTML 标签) */
function parsePlainTextToSegments(text) {
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const segments = lines.map(line => [{ type: 'text', text: line }])
/** 清理 Markdown 格式标记(**加粗** *斜体* __加粗__ _斜体_ ~~删除线~~ `代码` 等)*/
function stripMarkdownFormatting(text) {
if (!text) return text
let s = text
s = s.replace(/^#{1,6}\s+/gm, '')
s = s.replace(/\*\*(.+?)\*\*/g, '$1')
s = s.replace(/__(.+?)__/g, '$1')
s = s.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '$1')
s = s.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '$1')
s = s.replace(/~~(.+?)~~/g, '$1')
s = s.replace(/`([^`]+)`/g, '$1')
s = s.replace(/^>\s+/gm, '')
s = s.replace(/^---$/gm, '')
s = s.replace(/^\* /gm, '• ')
s = s.replace(/^- /gm, '• ')
s = s.replace(/^\d+\.\s/gm, '')
return s
}
/**
* 对一行纯文本进行 @人名 / #标签 自动匹配,返回 segments 数组
* config: { persons: [{ personId, token, name, label, aliases }], linkTags: [...] }
* 点击加好友时须传 persons.token与 CKB lead 的 targetUserId 一致),不能用 personId。
*/
function matchLineToSegments(line, config) {
if (!config || (!config.persons?.length && !config.linkTags?.length)) {
return [{ type: 'text', text: line }]
}
// 编辑器/系统在 @ 与人名之间插入的普通空格,合并为紧挨 @(避免「找@ 阿浪」无法匹配人名)
line = line.replace(/([@])\s+(?=[\u4e00-\u9fffA-Za-z0-9_\u00b7])/g, '$1')
const normalize = s => (s || '').trim().toLowerCase()
const personMap = {}
const tagMap = {}
for (const p of (config.persons || [])) {
const token = (p.token || '').trim()
if (!token) continue
const display = (p.name || p.label || '').trim()
const aliasStr = p.aliases != null ? String(p.aliases) : ''
const keys = [display, p.label, ...(aliasStr ? aliasStr.split(',') : [])]
.map((x) => (x != null ? String(x) : '').trim())
.filter(Boolean)
.map(normalize)
.filter(Boolean)
for (const k of keys) {
if (!personMap[k]) personMap[k] = p
}
}
for (const t of (config.linkTags || [])) {
const keys = [t.label, ...(t.aliases ? t.aliases.split(',') : [])].map(normalize).filter(Boolean)
for (const k of keys) { if (!tagMap[k]) tagMap[k] = t }
}
const esc = n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const personNames = Object.keys(personMap).sort((a, b) => b.length - a.length).map(esc)
const tagLabels = Object.keys(tagMap).sort((a, b) => b.length - a.length).map(esc)
if (!personNames.length && !tagLabels.length) return [{ type: 'text', text: line }]
const parts = []
if (personNames.length) parts.push('[@]\\s*(' + personNames.join('|') + ')')
if (tagLabels.length) parts.push('[#]\\s*(' + tagLabels.join('|') + ')')
const pattern = new RegExp(parts.join('|'), 'gi')
const segs = []
let lastEnd = 0
let m
while ((m = pattern.exec(line)) !== null) {
if (m.index > lastEnd) {
segs.push({ type: 'text', text: line.slice(lastEnd, m.index) })
}
const full = m[0]
if (/^[@]/u.test(full)) {
const body = full.replace(/^[@]\s*/u, '')
const person = personMap[normalize(body)]
if (person) {
const nick = cleanSingleLineField(person.name || person.label || body)
const uid = (person.token || '').trim()
if (uid) {
segs.push({ type: 'mention', userId: uid, nickname: nick, mentionDisplay: '@' + nick })
} else {
segs.push({ type: 'text', text: full })
}
} else {
segs.push({ type: 'text', text: full })
}
} else {
const body = full.replace(/^[#]\s*/u, '')
const tag = tagMap[normalize(body)]
if (tag) {
segs.push({
type: 'linkTag',
label: tag.label || body,
url: tag.url || '',
tagType: tag.type || 'url',
pagePath: tag.pagePath || '',
tagId: tag.tagId || '',
appId: tag.appId || '',
mpKey: tag.mpKey || ''
})
} else {
segs.push({ type: 'text', text: full })
}
}
lastEnd = m.index + full.length
}
if (lastEnd < line.length) {
segs.push({ type: 'text', text: line.slice(lastEnd) })
}
return segs.length ? segs : [{ type: 'text', text: line }]
}
/** 纯文本/Markdown 按行解析 */
function parsePlainTextToSegments(text, config) {
const cleaned = stripMarkdownFormatting(text)
const lines = cleaned.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const segments = lines.map(line => matchLineToSegments(line, config))
return { lines, segments }
}
/** 清理残留的 Markdown 图片引用文本(如 "image.png![](xxx)" */
function stripOrphanImageRefs(text) {
if (!text) return text
text = text.replace(/[^\s]*\.(?:png|jpg|jpeg|gif|webp|svg|bmp)!\[[^\]]*\]\([^)]*\)/gi, '')
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
return text
}
/**
* 将原始内容解析为 contentSegments用于阅读页展示
* @param {string} rawContent
* @param {object} [config] - { persons: [], linkTags: [] }
* @returns {{ lines: string[], segments: Array<Array<segment>> }}
*/
function parseContent(rawContent) {
function parseContent(rawContent, config) {
if (!rawContent || typeof rawContent !== 'string') {
return { lines: [], segments: [] }
}
if (isHtmlContent(rawContent)) {
return parseHtmlToSegments(rawContent)
let content = stripOrphanImageRefs(rawContent)
if (isHtmlContent(content)) {
return parseHtmlToSegments(content, config)
}
return parsePlainTextToSegments(rawContent)
return parsePlainTextToSegments(content, config)
}
module.exports = {
parseContent,
isHtmlContent
isHtmlContent,
cleanSingleLineField,
}

View File

@@ -170,10 +170,10 @@ class ReadingTracker {
const userId = app.globalData.userInfo?.id
if (!userId) return
// 计算本次上报的时长(仅发送增量 delta后端会累加避免重复累加导致阅读分钟数异常
// 计算本次上报的时长
const now = Date.now()
const delta = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += delta
const duration = Math.round((now - this.activeTracker.lastScrollTime) / 1000)
this.activeTracker.totalDuration += duration
this.activeTracker.lastScrollTime = now
try {
@@ -183,7 +183,7 @@ class ReadingTracker {
userId,
sectionId: this.activeTracker.sectionId,
progress: this.activeTracker.maxProgress,
duration: Math.max(0, delta),
duration: this.activeTracker.totalDuration,
status: this.activeTracker.isCompleted ? 'completed' : 'reading',
completedAt: this.activeTracker.completedAt
}
@@ -246,4 +246,4 @@ class ReadingTracker {
// 导出单例
const readingTracker = new ReadingTracker()
export default readingTracker
module.exports = readingTracker

View File

@@ -26,7 +26,8 @@ function getAppInstance() {
}
const RULE_COOLDOWN_KEY = 'rule_engine_cooldown'
const COOLDOWN_MS = 60 * 1000
// 0 = 关闭冷却(需求:去掉「操作频繁 / N 分钟」类体感限制)
const COOLDOWN_MS = 0
let _cachedRules = null
let _cacheTs = 0
const CACHE_TTL = 5 * 60 * 1000
@@ -45,6 +46,7 @@ const TRIGGER_SCENE_MAP = {
}
function isInCooldown(ruleId) {
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return false
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
const ts = map[ruleId]
@@ -57,6 +59,7 @@ function isInCooldown(ruleId) {
}
function setCooldown(ruleId) {
if (!COOLDOWN_MS || COOLDOWN_MS <= 0) return
try {
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
map[ruleId] = Date.now()

View File

@@ -9,11 +9,11 @@ const app = getApp()
*/
function trackClick(module, action, target, extra) {
const userId = app.globalData.userInfo?.id || ''
app.request({
url: '/api/miniprogram/track',
if (!userId) return
app.request('/api/miniprogram/track', {
method: 'POST',
data: {
userId: userId || undefined,
userId,
action,
target,
extraData: Object.assign({ module, page: module }, extra || {})

View File

@@ -32,18 +32,6 @@ const formatMoney = (amount, decimals = 2) => {
return Number(amount).toFixed(decimals)
}
/**
* 格式化统计数字≥1万显示 x.xw≥1千显示 x.xk否则原样
* @param {number} n - 原始数字
* @returns {string}
*/
const formatStatNum = n => {
const num = Number(n) || 0
if (num >= 10000) return (num / 10000).toFixed(1).replace(/\.0$/, '') + 'w'
if (num >= 1000) return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
return String(num)
}
// 防抖函数
const debounce = (fn, delay = 300) => {
let timer = null
@@ -163,6 +151,15 @@ const hideLoading = () => {
wx.hideLoading()
}
// 修复图片 URL 中 protocol 缺少冒号的问题(如 "https//..." → "https://..."
const normalizeImageUrl = (url) => {
if (!url || typeof url !== 'string') return ''
let s = url.trim()
if (!s) return ''
s = s.replace(/^(https?)\/\//, '$1://')
return s
}
// 显示确认框
const showConfirm = (title, content) => {
return new Promise((resolve) => {
@@ -192,7 +189,6 @@ module.exports = {
formatTime,
formatDate,
formatMoney,
formatStatNum,
formatNumber,
debounce,
throttle,
@@ -201,6 +197,7 @@ module.exports = {
isValidWechat,
deepClone,
getQueryParams,
normalizeImageUrl,
storage,
showToast,
showLoading,

14
new-soul/miniprogram/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Windows
[Dd]esktop.ini
Thumbs.db
$RECYCLE.BIN/
# macOS
.DS_Store
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
# Node.js
node_modules/

View File

@@ -0,0 +1,138 @@
# Soul创业实验 - 微信小程序
> 一场SOUL的创业实验场 - 来自Soul派对房的真实商业故事
## 📱 项目简介
本项目是《一场SOUL的创业实验场》的微信小程序版本完整还原了Web端的所有UI界面和功能。
## 🎨 设计特点
- **主题色**: Soul青色 (#00CED1)
- **设计风格**: 深色主题 + 毛玻璃效果
- **1:1还原**: 完全复刻Web端的UI设计
## 📂 项目结构
```
miniprogram/
├── app.js # 应用入口
├── app.json # 应用配置
├── app.wxss # 全局样式
├── custom-tab-bar/ # 自定义TabBar组件
│ ├── index.js
│ ├── index.json
│ ├── index.wxml
│ └── index.wxss
├── pages/
│ ├── index/ # 首页
│ ├── chapters/ # 目录页
│ ├── match/ # 找伙伴页
│ ├── my/ # 我的页面
│ ├── read/ # 阅读页
│ ├── about/ # 关于作者
│ ├── referral/ # 推广中心
│ ├── purchases/ # 订单页
│ └── settings/ # 设置页
├── utils/
│ ├── util.js # 工具函数
│ └── payment.js # 支付工具
├── assets/
│ └── icons/ # 图标资源
├── project.config.json # 项目配置
└── sitemap.json # 站点地图
```
## 🚀 功能列表
### 核心功能
- ✅ 首页 - 书籍展示、推荐章节、阅读进度
- ✅ 目录 - 完整章节列表、篇章折叠展开
- ✅ 找伙伴 - 匹配动画、匹配类型选择
- ✅ 我的 - 个人信息、订单、推广中心
- ✅ 阅读 - 付费墙、章节导航、分享功能
### 特色功能
- ✅ 自定义TabBar中间突出的找伙伴按钮
- ✅ 阅读进度条
- ✅ 匹配动画效果
- ✅ 付费墙与购买流程
- ✅ 分享海报功能
- ✅ 推广佣金系统
## 🛠 开发指南
### 环境要求
- 微信开发者工具 >= 1.06.2308310
- 基础库版本 >= 3.3.4
### 快速开始
1. **下载微信开发者工具**
- 前往 [微信开发者工具下载页面](https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html)
2. **导入项目**
- 打开微信开发者工具
- 选择"导入项目"
- 项目目录选择 `miniprogram` 文件夹
- AppID 使用: `wx432c93e275548671`
3. **编译运行**
- 点击"编译"按钮
- 在模拟器中预览效果
### 真机调试
1. 点击工具栏的"预览"按钮
2. 使用微信扫描二维码
3. 在真机上测试所有功能
## 📝 配置说明
### API配置
`app.js` 中修改 `globalData.baseUrl`:
```javascript
globalData: {
baseUrl: 'https://soul.ckb.fit', // 你的API地址
// ...
}
```
### AppID配置
`project.config.json` 中修改:
```json
{
"appid": "你的小程序AppID"
}
```
## 🎯 上线发布
1. **准备工作**
- 确保所有功能测试通过
- 检查API接口是否正常
- 确认支付功能已配置
2. **上传代码**
- 在开发者工具中点击"上传"
- 填写版本号和项目备注
3. **提交审核**
- 登录[微信公众平台](https://mp.weixin.qq.com)
- 进入"版本管理"
- 提交审核
4. **发布上线**
- 审核通过后点击"发布"
## 🔗 相关链接
- **Web版本**: https://soul.ckb.fit
- **作者微信**: 28533368
- **技术支持**: 存客宝
## 📄 版权信息
© 2024 卡若. All rights reserved.

View File

@@ -0,0 +1,67 @@
# miniprogram 功能还原分析报告
## 一、对比结论
| 项目 | miniprogram甲方 | miniprogram2你写的 |
|------|-------------------|------------------------|
| 页面分享 | 仅 read、referral 有 | 几乎所有页面都有 |
| scene 解析 | 无 | 有 utils/scene.js |
| 推荐码获取 | 分散userInfo?.referralCode 等) | 统一 app.getMyReferralCode() |
| 书籍 API | /api/book/all-chapters | /api/miniprogram/book/all-chapters |
| 特有页面 | vip、member-detail | scan、profile-edit |
## 二、已完成的还原项
### 1. 基础能力app.js + utils/scene.js
- **新增** `utils/scene.js`:扫码 scene 参数编解码,支持 `mid``id``ref`
- **app.js**
- 引入 `parseScene``handleReferralCode` 支持 `options.scene` 解析
- 新增 `getMyReferralCode()`:统一获取邀请码
- 新增 `getSectionMid(sectionId)`:根据 id 查 mid
- `loadBookData` 改为 `/api/miniprogram/book/all-chapters`
### 2. 页面分享onShareAppMessage
已为以下页面补充分享,路径统一带 `ref` 参数:
- index、chapters、match、my
- read、referral原有已统一用 getMyReferralCode
- search、settings、purchases、privacy
- withdraw-records、addresses、addresses/edit
- agreement、about、vip、member-detail
### 3. read.js 分享逻辑与 mid 支持
- 使用 `app.getMyReferralCode()` 替代 `userInfo?.referralCode || wx.getStorageSync('referralCode')`
- **mid 支持**onLoad 支持 `options.scene``options.mid`mid 有值无 id 时从 bookData 或 `/api/miniprogram/book/chapter/by-mid/:mid` 解析 id
- 分享 path 优先用 `mid=`(扫码/海报闭环),无则用 `id=`API 返回 `res.mid` 时写入 `sectionMid`
### 4. API 路径修正
- `app.loadBookData``/api/book/all-chapters``/api/miniprogram/book/all-chapters`
- `index.loadBookData``loadFeaturedFromServer``loadLatestChapters`:同上
- `chapters.loadDailyChapters`:同上
- **VIP 接口**`/api/vip/*``/api/miniprogram/vip/*`vip.js、my.js、index.js、member-detail.js
## 三、已处理项
- **vip 相关接口**:已改为 `/api/miniprogram/vip/*`members、status、profile符合项目边界。**后端需在 soul-api 的 miniprogram 组下挂对应路由**,可复用现有 handler。
- **页面结构**:保留 vip、member-detail未引入 scan、profile-edit
## 四、后端已补全soul-api
已在 miniprogram 组下新增以下路由:
| 路径 | 方法 | 用途 |
|------|------|------|
| `/api/miniprogram/vip/status` | GET | 查询用户 VIP 状态(按 fullbook/vip 订单判断) |
| `/api/miniprogram/vip/profile` | GET/POST | 获取/更新 VIP 资料(映射 users 表 nickname/phone |
| `/api/miniprogram/vip/members` | GET | VIP 会员列表(无 id或单个?id= |
| `/api/miniprogram/users` | GET | 用户列表(?limit=)或单个(?id=),首页超级个体、会员详情回退 |
## 五、后续建议
1. **soul-api 路由**:确认 `/api/miniprogram/book/all-chapters` 已注册VIP 接口见「四、后端待办」。
2. **referral.js**:已统一使用 `app.getMyReferralCode()` 作为回退。
3. **read.js mid 支持**:已完成。若 soul-api 未提供 `/api/miniprogram/book/chapter/by-mid/:mid`,扫码带 mid 时需依赖 bookData 已加载完成;建议后端补充 by-mid 接口。

750
new-soul/miniprogram/app.js Normal file
View File

@@ -0,0 +1,750 @@
/**
* Soul创业派对 - 小程序入口
* 开发: 卡若
*/
const { parseScene } = require('./utils/scene.js')
const DEFAULT_BASE_URL = 'https://soulapi.quwanzhi.com'
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
function getRuntimeBootstrapConfig() {
try {
const extCfg = wx.getExtConfigSync ? (wx.getExtConfigSync() || {}) : {}
return {
baseUrl: extCfg.apiBaseUrl || wx.getStorageSync('apiBaseUrl') || DEFAULT_BASE_URL,
appId: extCfg.appId || DEFAULT_APP_ID,
mchId: extCfg.mchId || DEFAULT_MCH_ID,
withdrawSubscribeTmplId: extCfg.withdrawSubscribeTmplId || DEFAULT_WITHDRAW_TMPL_ID
}
} catch (_) {
return {
baseUrl: DEFAULT_BASE_URL,
appId: DEFAULT_APP_ID,
mchId: DEFAULT_MCH_ID,
withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID
}
}
}
const bootstrapConfig = getRuntimeBootstrapConfig()
App({
globalData: {
// 运行配置:优先外部配置/缓存,其次默认值
baseUrl: bootstrapConfig.baseUrl,
appId: bootstrapConfig.appId,
// 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗
withdrawSubscribeTmplId: bootstrapConfig.withdrawSubscribeTmplId,
// 微信支付配置
mchId: bootstrapConfig.mchId,
// 用户信息
userInfo: null,
openId: null, // 微信openId支付必需
isLoggedIn: false,
// 书籍数据
bookData: null,
totalSections: 0,
supportWechat: '',
// 购买记录
purchasedSections: [],
hasFullBook: false,
// VIP 会员365天包含增值版免费与 hasFullBook=9.9 买断不同)
isVip: false,
vipExpireDate: '',
// 已读章节(仅统计有权限打开过的章节,用于首页「已读/待读」)
readSectionIds: [],
// 推荐绑定
pendingReferralCode: null, // 待绑定的推荐码
// 主题配置
theme: {
brandColor: '#00CED1',
brandSecondary: '#20B2AA',
goldColor: '#FFD700',
bgColor: '#000000',
cardBg: '#1c1c1e'
},
// 系统信息
systemInfo: null,
statusBarHeight: 44,
navBarHeight: 88,
// TabBar相关
currentTab: 0,
// 是否处于「单页模式」(如朋友圈文章里的单页预览)
// 用于在受限环境下给出引导文案,提示用户点击底部「前往小程序」进入完整体验
isSinglePageMode: false,
// 更新检测:上次检测时间戳,避免频繁请求
lastUpdateCheck: 0
},
onLaunch(options) {
this.globalData.readSectionIds = wx.getStorageSync('readSectionIds') || []
// 获取系统信息
this.getSystemInfo()
// 场景值兜底1154 为「朋友圈单页模式」进入
try {
const launchOpts = wx.getLaunchOptionsSync ? wx.getLaunchOptionsSync() : null
if (launchOpts && launchOpts.scene === 1154) {
this.globalData.isSinglePageMode = true
}
} catch (e) {
console.warn('[App] 读取 LaunchOptions 失败:', e)
}
// 检查登录状态
this.checkLoginStatus()
this.loadRuntimeConfig()
// 加载书籍数据
this.loadBookData()
// 检查更新
this.checkUpdate()
// 处理分享参数(推荐码绑定)
this.handleReferralCode(options)
},
// 小程序显示时:处理分享参数、检测更新(从后台切回时)
onShow(options) {
this.handleReferralCode(options)
this.checkUpdate()
},
// 处理推荐码绑定:官方以 options.scene 接收扫码参数(可同时带 mid/id + ref与 utils/scene 解析闭环
handleReferralCode(options) {
const query = options?.query || {}
let refCode = query.ref || query.referralCode
const sceneStr = (options && (typeof options.scene === 'string' ? options.scene : '')) || ''
if (sceneStr) {
const parsed = parseScene(sceneStr)
if (parsed.mid) this.globalData.initialSectionMid = parsed.mid
if (parsed.id) this.globalData.initialSectionId = parsed.id
if (parsed.ref) refCode = parsed.ref
}
if (refCode) {
console.log('[App] 检测到推荐码:', refCode)
// 立即记录访问(不需要登录,用于统计"通过链接进的人数"
this.recordReferralVisit(refCode)
// 保存待绑定的推荐码(不再在前端做"只能绑定一次"的限制让后端根据30天规则判断续期/抢夺)
this.globalData.pendingReferralCode = refCode
wx.setStorageSync('pendingReferralCode', refCode)
// 同步写入 referral_code供章节/找伙伴支付时传给后端,订单会记录 referrer_id 与 referral_code
wx.setStorageSync('referral_code', refCode)
// 如果已登录,立即尝试绑定,由 /api/miniprogram/referral/bind 按 30 天规则决定 new / renew / takeover
if (this.globalData.isLoggedIn && this.globalData.userInfo) {
this.bindReferralCode(refCode)
}
}
},
// 记录推荐访问(不需要登录,用于统计)
async recordReferralVisit(refCode) {
try {
// 获取openId如果有
const openId = this.globalData.openId || wx.getStorageSync('openId') || ''
const userId = this.globalData.userInfo?.id || ''
await this.request('/api/miniprogram/referral/visit', {
method: 'POST',
data: {
referralCode: refCode,
visitorOpenId: openId,
visitorId: userId,
source: 'miniprogram',
page: getCurrentPages()[getCurrentPages().length - 1]?.route || ''
},
silent: true
})
console.log('[App] 记录推荐访问成功')
} catch (e) {
console.log('[App] 记录推荐访问失败:', e.message)
// 忽略错误,不影响用户体验
}
},
// 绑定推荐码到用户(自己的推荐码不请求接口,避免 400 与控制台报错)
async bindReferralCode(refCode) {
try {
const userId = this.globalData.userInfo?.id
if (!userId || !refCode) return
const myCode = this.getMyReferralCode()
if (myCode && this._normalizeReferralCode(refCode) === this._normalizeReferralCode(myCode)) {
console.log('[App] 跳过绑定:不能使用自己的推荐码')
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
return
}
console.log('[App] 绑定推荐码:', refCode, '到用户:', userId)
const res = await this.request('/api/miniprogram/referral/bind', {
method: 'POST',
data: {
userId,
referralCode: refCode
},
silent: true
})
if (res.success) {
console.log('[App] 推荐码绑定成功')
wx.setStorageSync('boundReferralCode', refCode)
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
}
} catch (e) {
const msg = (e && e.message) ? String(e.message) : ''
if (msg.indexOf('不能使用自己的推荐码') !== -1) {
console.log('[App] 跳过绑定:不能使用自己的推荐码')
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
} else {
console.error('[App] 绑定推荐码失败:', e)
}
}
},
// 推荐码归一化后比较(忽略大小写、短横线等)
_normalizeReferralCode(code) {
if (!code || typeof code !== 'string') return ''
return code.replace(/[\s\-_]/g, '').toUpperCase().trim()
},
// 判断用户资料是否完善(昵称 + 头像)
_isProfileIncomplete(user) {
if (!user) return true
const nickname = (user.nickname || '').trim()
const avatar = (user.avatar || '').trim()
const isDefaultNickname = !nickname || nickname === '微信用户'
const noAvatar = !avatar
return isDefaultNickname || noAvatar
},
// 登录后若资料未完善,引导跳转到资料编辑页
_ensureProfileCompletedAfterLogin(user) {
try {
if (!user || !this._isProfileIncomplete(user)) return
const pages = getCurrentPages()
const current = pages[pages.length - 1]
// 避免在资料页内重复跳转
if (current && current.route === 'pages/profile-edit/profile-edit') return
wx.showToast({ title: '请先完善头像和昵称', icon: 'none', duration: 2000 })
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
} catch (e) {
console.warn('[App] 跳转资料编辑页失败:', e)
}
},
// 根据业务 id 从 bookData 查 mid用于跳转
getSectionMid(sectionId) {
const list = this.globalData.bookData || []
const ch = list.find(c => c.id === sectionId)
return ch?.mid || 0
},
// 获取当前用户的邀请码(用于分享带 ref未登录返回空字符串
getMyReferralCode() {
const user = this.globalData.userInfo
if (!user) return ''
if (user.referralCode) return user.referralCode
if (user.id) return 'SOUL' + String(user.id).toUpperCase().slice(-6)
return ''
},
/**
* 自定义导航栏「返回」:有上一页则返回,否则跳转首页(解决从分享进入时点返回无效的问题)
*/
goBackOrToHome() {
const pages = getCurrentPages()
if (pages.length <= 1) {
wx.switchTab({ url: '/pages/index/index' })
} else {
wx.navigateBack()
}
},
// 获取系统信息
getSystemInfo() {
try {
const systemInfo = wx.getSystemInfoSync()
this.globalData.systemInfo = systemInfo
this.globalData.statusBarHeight = systemInfo.statusBarHeight || 44
// 微信在单页模式下会在 systemInfo.mode 标记 singlePage
if (systemInfo.mode === 'singlePage') {
this.globalData.isSinglePageMode = true
}
// 计算导航栏高度
const menuButton = wx.getMenuButtonBoundingClientRect()
if (menuButton) {
this.globalData.navBarHeight = (menuButton.top - systemInfo.statusBarHeight) * 2 + menuButton.height + systemInfo.statusBarHeight
}
} catch (e) {
console.error('获取系统信息失败:', e)
}
},
/**
* 若当前处于朋友圈等「单页模式」,在尝试登录/购买前给用户友好提示,
* 引导用户点击底部「前往小程序」进入完整小程序再操作。
* 返回 false 表示应中断当前操作。
*/
ensureFullAppForAuth() {
// 每次调用时再做一次兜底检测,避免全局标记遗漏
try {
const sys = wx.getSystemInfoSync()
if (sys && sys.mode === 'singlePage') {
this.globalData.isSinglePageMode = true
}
} catch (e) {
console.warn('[App] ensureFullAppForAuth getSystemInfoSync error:', e)
}
if (!this.globalData.isSinglePageMode) return true
wx.showModal({
title: '请前往完整小程序',
content: '当前为朋友圈单页,仅支持部分浏览。如需登录和解锁内容,请点击底部「前往小程序」后再操作。',
showCancel: false,
confirmText: '我知道了',
})
return false
},
// 检查登录状态
checkLoginStatus() {
try {
const userInfo = wx.getStorageSync('userInfo')
const token = wx.getStorageSync('token')
if (userInfo && token) {
this.globalData.userInfo = userInfo
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = userInfo.purchasedSections || []
this.globalData.hasFullBook = userInfo.hasFullBook || false
this.globalData.isVip = userInfo.isVip || false
this.globalData.vipExpireDate = userInfo.vipExpireDate || ''
}
} catch (e) {
console.error('检查登录状态失败:', e)
}
},
async loadRuntimeConfig() {
try {
const res = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
const mpConfig = res?.mpConfig || {}
this.globalData.baseUrl = mpConfig.apiDomain || this.globalData.baseUrl
this.globalData.appId = mpConfig.appId || this.globalData.appId
this.globalData.mchId = mpConfig.mchId || this.globalData.mchId
this.globalData.withdrawSubscribeTmplId = mpConfig.withdrawSubscribeTmplId || this.globalData.withdrawSubscribeTmplId
this.globalData.supportWechat = mpConfig.supportWechat || mpConfig.customerWechat || mpConfig.serviceWechat || ''
try {
wx.setStorageSync('apiBaseUrl', this.globalData.baseUrl)
} catch (_) {}
} catch (e) {
console.warn('[App] 加载运行配置失败,继续使用默认配置:', e)
}
},
// 加载书籍数据
async loadBookData() {
try {
// 先从缓存加载
const cachedData = wx.getStorageSync('bookData')
if (cachedData) {
this.globalData.bookData = cachedData
}
// 从服务器获取最新数据
const res = await this.request('/api/miniprogram/book/all-chapters')
if (res && (res.data || res.chapters)) {
const chapters = res.data || res.chapters || []
this.globalData.bookData = chapters
this.globalData.totalSections = res.total || chapters.length || 0
wx.setStorageSync('bookData', chapters)
}
} catch (e) {
console.error('加载书籍数据失败:', e)
}
},
/**
* 小程序更新检测(基于 wx.getUpdateManager
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求)
*/
checkUpdate() {
try {
if (!wx.canIUse('getUpdateManager')) return
const now = Date.now()
const lastCheck = this.globalData.lastUpdateCheck || 0
if (lastCheck && now - lastCheck < 5 * 60 * 1000) return // 5 分钟内不重复检测
this.globalData.lastUpdateCheck = now
const updateManager = wx.getUpdateManager()
updateManager.onCheckForUpdate((res) => {
if (res.hasUpdate) {
console.log('[App] 发现新版本,正在下载...')
}
})
updateManager.onUpdateReady(() => {
wx.showModal({
title: '更新提示',
content: '新版本已准备好,重启后即可使用',
confirmText: '立即重启',
cancelText: '稍后',
success: (res) => {
if (res.confirm) {
updateManager.applyUpdate()
}
}
})
})
updateManager.onUpdateFailed(() => {
wx.showToast({
title: '更新失败,请稍后重试',
icon: 'none',
duration: 2500
})
})
} catch (e) {
console.warn('[App] checkUpdate failed:', e)
}
},
/**
* 从 soul-api 返回体中取错误提示文案(兼容 message / error 字段)
*/
_getApiErrorMsg(data, defaultMsg = '请求失败') {
if (!data || typeof data !== 'object') return defaultMsg
const msg = data.message || data.error
return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg
},
/**
* 统一请求方法。接口失败时会弹窗提示(与 soul-api 返回的 message/error 一致)。
* @param {string|object} urlOrOptions - 接口路径,或 { url, method, data, header, silent }
* @param {object} options - { method, data, header, silent }
* @param {boolean} options.silent - 为 true 时不弹窗,仅 reject用于静默请求如访问统计
*/
request(urlOrOptions, options = {}) {
let url
if (typeof urlOrOptions === 'string') {
url = urlOrOptions
} else if (urlOrOptions && typeof urlOrOptions === 'object' && urlOrOptions.url) {
url = urlOrOptions.url
options = { ...urlOrOptions, url: undefined }
} else {
url = ''
}
const silent = !!options.silent
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) {
// 业务失败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))
}
})
})
},
// 登录方法 - 获取openId用于支付加固错误处理避免审核报“登录报错”
async login() {
if (!this.ensureFullAppForAuth()) {
return null
}
try {
const loginRes = await new Promise((resolve, reject) => {
wx.login({ success: resolve, fail: reject })
})
if (!loginRes || !loginRes.code) {
console.warn('[App] wx.login 未返回 code')
wx.showToast({ title: '获取登录态失败,请重试', icon: 'none' })
return null
}
try {
const res = await this.request('/api/miniprogram/login', {
method: 'POST',
data: { code: loginRes.code }
})
if (res.success && res.data) {
// 保存openId
if (res.data.openId) {
this.globalData.openId = res.data.openId
wx.setStorageSync('openId', res.data.openId)
console.log('[App] 获取openId成功')
}
// 保存用户信息
if (res.data.user) {
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token || '')
// 登录成功后,检查待绑定的推荐码并执行绑定
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
if (pendingRef) {
console.log('[App] 登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
}
return res.data
}
} catch (apiError) {
console.log('[App] API登录失败:', apiError.message)
// 不使用模拟登录,提示用户网络问题
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
return null
}
return null
} catch (e) {
console.error('[App] 登录失败:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
return null
}
},
// 获取openId (支付必需)
async getOpenId() {
if (!this.ensureFullAppForAuth()) {
return null
}
// 先检查缓存
const cachedOpenId = wx.getStorageSync('openId')
if (cachedOpenId) {
this.globalData.openId = cachedOpenId
return cachedOpenId
}
// 没有缓存则登录获取
try {
const loginRes = await new Promise((resolve, reject) => {
wx.login({ success: resolve, fail: reject })
})
const res = await this.request('/api/miniprogram/login', {
method: 'POST',
data: { code: loginRes.code }
})
if (res.success && res.data?.openId) {
this.globalData.openId = res.data.openId
wx.setStorageSync('openId', res.data.openId)
// 接口同时返回 user 时视为登录,补全登录态并从登录开始绑定推荐码
if (res.data.user) {
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token || '')
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
if (pendingRef) {
console.log('[App] getOpenId 登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
}
return res.data.openId
}
} catch (e) {
console.error('[App] 获取openId失败:', e)
}
return null
},
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
async loginWithPhone(phoneCode) {
if (!this.ensureFullAppForAuth()) {
return null
}
try {
const loginRes = await new Promise((resolve, reject) => {
wx.login({ success: resolve, fail: reject })
})
if (!loginRes.code) {
wx.showToast({ title: '获取登录态失败', icon: 'none' })
return null
}
const res = await this.request('/api/miniprogram/phone-login', {
method: 'POST',
data: { code: loginRes.code, phoneCode }
})
if (res.success && res.data) {
const user = res.data.user
this.globalData.userInfo = user
this.globalData.isLoggedIn = true
this.globalData.purchasedSections = user.purchasedSections || []
this.globalData.hasFullBook = user.hasFullBook || false
wx.setStorageSync('userInfo', user)
wx.setStorageSync('token', res.data.token)
// 登录成功后绑定推荐码
const pendingRef = wx.getStorageSync('pendingReferralCode') || this.globalData.pendingReferralCode
if (pendingRef) {
console.log('[App] 手机号登录后自动绑定推荐码:', pendingRef)
this.bindReferralCode(pendingRef)
}
// 登录后引导完善资料
this._ensureProfileCompletedAfterLogin(user)
return res.data
}
} catch (e) {
console.log('[App] 手机号登录失败:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
return null
},
// 退出登录
logout() {
this.globalData.userInfo = null
this.globalData.isLoggedIn = false
this.globalData.purchasedSections = []
this.globalData.hasFullBook = false
wx.removeStorageSync('userInfo')
wx.removeStorageSync('token')
},
// 检查是否已购买章节
hasPurchased(sectionId) {
if (this.globalData.hasFullBook) return true
return this.globalData.purchasedSections.includes(sectionId)
},
// 标记章节为已读(仅在有权限打开时由阅读页调用,用于首页已读/待读统计)
markSectionAsRead(sectionId) {
if (!sectionId) return
const list = this.globalData.readSectionIds || []
if (list.includes(sectionId)) return
list.push(sectionId)
this.globalData.readSectionIds = list
wx.setStorageSync('readSectionIds', list)
},
// 已读章节数(用于首页展示)
getReadCount() {
return (this.globalData.readSectionIds || []).length
},
// 获取章节总数
getTotalSections() {
return this.globalData.totalSections
},
// 切换TabBar
switchTab(index) {
this.globalData.currentTab = index
},
// 显示Toast
showToast(title, icon = 'none') {
wx.showToast({
title,
icon,
duration: 2000
})
},
// 显示Loading
showLoading(title = '加载中...') {
wx.showLoading({
title,
mask: true
})
},
// 隐藏Loading
hideLoading() {
wx.hideLoading()
}
})

View File

@@ -0,0 +1,71 @@
{
"pages": [
"pages/chapters/chapters",
"pages/index/index",
"pages/match/match",
"pages/my/my",
"pages/read/read",
"pages/link-preview/link-preview",
"pages/about/about",
"pages/agreement/agreement",
"pages/privacy/privacy",
"pages/referral/referral",
"pages/purchases/purchases",
"pages/settings/settings",
"pages/search/search",
"pages/addresses/addresses",
"pages/addresses/edit",
"pages/withdraw-records/withdraw-records",
"pages/vip/vip",
"pages/member-detail/member-detail",
"pages/mentors/mentors",
"pages/mentor-detail/mentor-detail",
"pages/profile-show/profile-show",
"pages/profile-edit/profile-edit",
"pages/wallet/wallet"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#000000",
"navigationBarTitleText": "Soul创业派对",
"navigationBarTextStyle": "white",
"backgroundColor": "#000000",
"navigationStyle": "custom"
},
"tabBar": {
"custom": true,
"color": "#8e8e93",
"selectedColor": "#00CED1",
"backgroundColor": "#1c1c1e",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页"
},
{
"pagePath": "pages/chapters/chapters",
"text": "目录"
},
{
"pagePath": "pages/match/match",
"text": "找伙伴"
},
{
"pagePath": "pages/my/my",
"text": "我的"
}
]
},
"usingComponents": {
"icon": "/components/icon/icon"
},
"navigateToMiniProgramAppIdList": [
"wx6489c26045912fe1",
"wx3d15ed02e98b04e3"
],
"__usePrivacyCheck__": true,
"lazyCodeLoading": "requiredComponents",
"style": "v2",
"sitemapLocation": "sitemap.json"
}

Some files were not shown because too many files have changed in this diff Show More