diff --git a/.cursor/rules/party-ai-dev.mdc b/.cursor/rules/party-ai-dev.mdc
index eba06a1a..111c12cc 100644
--- a/.cursor/rules/party-ai-dev.mdc
+++ b/.cursor/rules/party-ai-dev.mdc
@@ -35,9 +35,17 @@
}
```
+### 需求必须 100% 完成(铁律)
+
+**禁止**:说「留到下一轮迭代」「后续再处理」。
+收到需求文档后,**全量开发**到每一项功能都落地为止。
+若单个功能涉及跨端改动(后端 + 管理端 + 小程序),必须三端同步改完。
+仅当功能在代码层面**无法实现**(如依赖第三方审核、硬件条件不具备)时,才可标注「依赖外部」并说明原因。
+
### 复盘格式
使用卡若 AI 标准复盘格式(🎯📌💡📝▶ 五块齐全),带日期+时间。
+开发完成后**必须**立即发送飞书复盘(不需要用户提醒)。
### 小程序上传约定(强制)
diff --git a/.cursor/rules/soul-karuo-dialogue.mdc b/.cursor/rules/soul-karuo-dialogue.mdc
new file mode 100644
index 00000000..3e916b40
--- /dev/null
+++ b/.cursor/rules/soul-karuo-dialogue.mdc
@@ -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. **直接执行**:用户说「直接做、别讲写了什么」时,**正文极短**;**复盘五块不可省**,可压缩过程为 1~2 条要点。
+
+## 与卡若中枢的衔接
+
+- Token/API 费用:助手侧通常无 Cursor 账单接口,在 💡 中说明「请用户在 Cursor Usage 自查」即可。
diff --git a/.cursor/rules/soul-project-boundary.mdc b/.cursor/rules/soul-project-boundary.mdc
index d311ed76..4e1ede63 100644
--- a/.cursor/rules/soul-project-boundary.mdc
+++ b/.cursor/rules/soul-project-boundary.mdc
@@ -24,6 +24,8 @@ alwaysApply: true
- 小程序只调 `/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)
@@ -67,7 +69,7 @@ alwaysApply: true
| 变更完成、检查一下、准备提交 | `.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`(产品经理三端分析 → 功能规划 → 指派) |
+| **加个需求**、加个需求:xxx | `.cursor/skills/product-manager/SKILL.md`(需求即执行:回复「好」→ 直接执行代码变更 → 回复结果) |
| **新版分析**、版本对比、迁移分析、甲方代码分析、快速分析新版、抽取需求 | `.cursor/skills/new-version-analyze/SKILL.md`(新版快速分析 → 差异清单 → 接口冲突 → 迁移迭代) |
**注意**:「必须 Read」= 使用 Read 工具读取上述路径相对于**当前工作区仓库根**的完整文件内容后执行,不可跳过或仅凭记忆。
diff --git a/.cursor/skills/karuo-party/SKILL.md b/.cursor/skills/karuo-party/SKILL.md
index 986528bf..acf194c6 100644
--- a/.cursor/skills/karuo-party/SKILL.md
+++ b/.cursor/skills/karuo-party/SKILL.md
@@ -7,8 +7,8 @@ description: >
triggers: 运营报表、视频切片、多平台分发、飞书视频下载、派对运营、卡若创业派对、派对填表、视频剪辑、一键分发、妙记下载
owner: 水岸
group: 运营
-version: "1.0"
-updated: "2026-03-20"
+version: "1.1"
+updated: "2026-03-21"
---
# 卡若创业派对运营 Skill 包
@@ -19,6 +19,14 @@ updated: "2026-03-20"
---
+## 零、交互默认(派对 AI)
+
+- **直接操作**:报表、下载、切片、分发、脚本、凭证刷新等,**不向用户反问**「要不要跑」;按本文档路径**直接执行**终端命令(与全局 `karuo-ai.mdc`「默认零提问」一致)。
+- **缺凭证/Token**:先读 `credentials/` 与脚本内刷新逻辑;失败则**一句**说明缺哪份文件或环境变量,不展开选择题。
+- **收尾**:技术类任务仍可在会话末跟卡若复盘五块(多根工作区以 `soul-karuo-dialogue.mdc` 为准)。
+
+---
+
## 一、技能包组成
本技能包包含以下 4 个核心子技能:
@@ -226,8 +234,48 @@ python3 auto_log.py
---
+## 九、闭环复盘发群(派对 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 v2(msg_type 必填) |
| 1.0 | 2026-03-20 | 初版:整合运营报表、视频切片、多平台分发、飞书视频文字下载 4 大技能,统一凭证管理 |
diff --git a/.cursor/skills/product-manager/SKILL.md b/.cursor/skills/product-manager/SKILL.md
index f4fb3137..03b32bdb 100644
--- a/.cursor/skills/product-manager/SKILL.md
+++ b/.cursor/skills/product-manager/SKILL.md
@@ -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` 或追加到 `需求汇总.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`
+- 《以界面定需求》界面清单
---
@@ -121,7 +116,7 @@ description: Soul 创业派对产品经理需求与验收。需求分析、需
## 6. 何时选用
-- 用户说**「加个需求:xxx」**时:执行 §0 加个需求流水线(三端分析 → 功能规划 → 指派)
+- 用户说**「加个需求:xxx」**时:执行 **§0 需求即执行**(回复「好」→ 三端落地 → 结果回复 + 卡若复盘收尾)
- 编辑 `开发文档/1、需求/`、`临时需求池/`、`开发文档/10、项目管理/` 时
- 进行需求分析、需求文档编写、验收标准定义时
- 用户说「需求分析」「产品经理」「验收」时
diff --git a/.cursorignore b/.cursorignore
new file mode 100644
index 00000000..5b0e682e
--- /dev/null
+++ b/.cursorignore
@@ -0,0 +1,3 @@
+# 减轻 Cursor 代码索引噪声(需要时仍可用 @路径 打开)
+.cursor/scripts/db-exec/node_modules/
+
diff --git a/content_upload.py b/content_upload.py
new file mode 100644
index 00000000..bb469820
--- /dev/null
+++ b/content_upload.py
@@ -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()
diff --git a/miniprogram/app.js b/miniprogram/app.js
index d3d370a2..05d644c9 100644
--- a/miniprogram/app.js
+++ b/miniprogram/app.js
@@ -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,14 +92,39 @@ App({
// config 统一缓存(5min),减少重复请求
configCache: null,
configCacheExpires: 0,
- // VIP 联系方式检测:上次检测时间戳,onShow 节流 5 分钟
+ // VIP 联系方式检测:上次检测时间戳,onShow 短节流(避免与 launch 重复打满接口)
lastVipContactCheck: 0,
- // 头像昵称检测:上次检测时间戳,onShow 节流 5 分钟
+ // 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
lastAvatarNicknameCheck: 0,
},
+ /** 正式版强制生产 API,避免误传 localhost 导致审核/线上全挂 */
+ initApiBaseUrl() {
+ const PRODUCTION = 'https://soulapi.quwanzhi.com'
+ const KEY = 'apiBaseUrl'
+ try {
+ const info = wx.getAccountInfoSync?.()
+ const env = info?.miniProgram?.envVersion || 'release'
+ if (env === 'release') {
+ this.globalData.baseUrl = PRODUCTION
+ 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) => {
@@ -169,10 +205,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)
@@ -558,9 +594,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()
@@ -688,10 +721,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 {
@@ -699,10 +733,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,
+ }
},
/**
@@ -759,14 +801,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()
@@ -1038,13 +1080,6 @@ App({
return null
},
- // 模拟登录已废弃 - 不再使用
- // 现在必须使用真实的微信登录获取openId作为唯一标识
- mockLogin() {
- console.warn('[App] mockLogin已废弃,请使用真实登录')
- return null
- },
-
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
async loginWithPhone(phoneCode) {
if (!this.ensureFullAppForAuth()) {
diff --git a/miniprogram/app.json b/miniprogram/app.json
index 19b8fee9..feeafc49 100644
--- a/miniprogram/app.json
+++ b/miniprogram/app.json
@@ -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",
diff --git a/miniprogram/assets/icons/list-teal.svg b/miniprogram/assets/icons/list-teal.svg
new file mode 100644
index 00000000..98fc22ab
--- /dev/null
+++ b/miniprogram/assets/icons/list-teal.svg
@@ -0,0 +1,8 @@
+
diff --git a/miniprogram/assets/icons/share-teal.svg b/miniprogram/assets/icons/share-teal.svg
new file mode 100644
index 00000000..ed187670
--- /dev/null
+++ b/miniprogram/assets/icons/share-teal.svg
@@ -0,0 +1,7 @@
+
diff --git a/miniprogram/assets/icons/unlock-muted-teal.svg b/miniprogram/assets/icons/unlock-muted-teal.svg
new file mode 100644
index 00000000..e7980f54
--- /dev/null
+++ b/miniprogram/assets/icons/unlock-muted-teal.svg
@@ -0,0 +1,6 @@
+
diff --git a/miniprogram/assets/icons/wallet-teal.svg b/miniprogram/assets/icons/wallet-teal.svg
new file mode 100644
index 00000000..9a9e56f2
--- /dev/null
+++ b/miniprogram/assets/icons/wallet-teal.svg
@@ -0,0 +1,4 @@
+
diff --git a/miniprogram/pages/chapters/chapters.js b/miniprogram/pages/chapters/chapters.js
index 2ff3381e..67400f7b 100644
--- a/miniprogram/pages/chapters/chapters.js
+++ b/miniprogram/pages/chapters/chapters.js
@@ -29,6 +29,9 @@ Page({
// 已加载的篇章章节缓存 { partId: chapters }
_loadedChapters: {},
+
+ // 小三角点击动画:当前触发的子章 id(与 chapter.id 比对)
+ _triangleAnimating: '',
// 固定模块 id -> mid(序言/尾声/附录,供 goToRead 传 mid)
fixedSectionsMap: {},
@@ -152,6 +155,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
@@ -227,6 +236,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
diff --git a/miniprogram/pages/chapters/chapters.wxml b/miniprogram/pages/chapters/chapters.wxml
index 2578a9b5..bd03e997 100644
--- a/miniprogram/pages/chapters/chapters.wxml
+++ b/miniprogram/pages/chapters/chapters.wxml
@@ -88,8 +88,8 @@
-
-
+
+
@@ -100,12 +100,14 @@
免费
- 已解锁
- ¥{{section.price}}
+ ¥{{section.price}}
+
+
+
diff --git a/miniprogram/pages/chapters/chapters.wxss b/miniprogram/pages/chapters/chapters.wxss
index dfcd4a12..48c3adc2 100644
--- a/miniprogram/pages/chapters/chapters.wxss
+++ b/miniprogram/pages/chapters/chapters.wxss
@@ -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;
diff --git a/miniprogram/pages/dev-login/dev-login.js b/miniprogram/pages/dev-login/dev-login.js
index a4e31757..dc691a44 100644
--- a/miniprogram/pages/dev-login/dev-login.js
+++ b/miniprogram/pages/dev-login/dev-login.js
@@ -1,6 +1,7 @@
/**
- * 卡若创业派对 - 开发登录页
- * 临时:账户=手机号,密码可空,用于切换为对方账号调试
+ * 卡若创业派对 - 开发登录页(仅本地调试)
+ * 勿写入 app.json pages:提审包不得注册本页。
+ * 需要用时在 app.json 的 pages 数组末尾临时加入 "pages/dev-login/dev-login"。
*/
const app = getApp()
const { checkAndExecute } = require('../../utils/ruleEngine.js')
diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js
index c9d457f2..5f244bba 100644
--- a/miniprogram/pages/index/index.js
+++ b/miniprogram/pages/index/index.js
@@ -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,8 +79,7 @@ Page({
// 展开状态(首页精选/最新)
featuredExpanded: false,
latestExpanded: false,
- featuredSectionsFull: [], // 展开时用 book/hot 加载的完整列表
- featuredExpandedLoading: false,
+ featuredSectionsFull: [], // 精选排行榜全量(最多 50),默认只展示前 3 条
// 功能配置(搜索开关)
searchEnabled: true,
@@ -136,28 +148,22 @@ Page({
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) {
@@ -166,48 +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 [recRes, latestRes] = await Promise.all([
- app.request({ url: '/api/miniprogram/book/recommended', silent: true }).catch(() => null),
+ 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. 精选推荐(recommended → hot 兜底)
- let featured = []
+ // 1. 精选推荐:一次拉全量(≤50),默认只显示 3 条;点列表下三角展开(与「最新新增」一致)
+ let featuredFull = []
if (recRes && recRes.success && Array.isArray(recRes.data) && recRes.data.length > 0) {
- featured = recRes.data.map((s, i) => toSection(s, i))
+ featuredFull = recRes.data.map((s) => toSectionFromRanking(s))
}
- if (featured.length === 0) {
+ 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) featured = hotList.slice(0, 3).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 (featured.length > 0) this.setData({ featuredSections: featured })
+ if (featuredFull.length > 0) {
+ this.setData({
+ featuredSectionsFull: featuredFull,
+ 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 || '',
@@ -334,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) {
@@ -439,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 })
@@ -501,50 +527,24 @@ Page({
wx.switchTab({ url: '/pages/match/match' })
},
- // 精选推荐:展开/折叠
- async toggleFeaturedExpanded() {
- if (this.data.featuredExpandedLoading) return
- trackClick('home', 'tab_click', this.data.featuredExpanded ? '精选收起' : '精选展开')
- if (this.data.featuredExpanded) {
- const collapsed = this.data.featuredSectionsFull.length > 0 ? this.data.featuredSectionsFull.slice(0, 3) : this.data.featuredSections
- this.setData({ featuredExpanded: false, featuredSections: collapsed })
- return
- }
- if (this.data.featuredSectionsFull.length > 0) {
- this.setData({ featuredExpanded: true, featuredSections: this.data.featuredSectionsFull })
- return
- }
- this.setData({ featuredExpandedLoading: true })
- try {
- const res = await app.request({ url: '/api/miniprogram/book/hot?limit=50', silent: true })
- const list = (res && res.data) ? res.data : []
- const tagMap = ['热门', '推荐', '精选']
- const full = list.map((s, i) => ({
- id: s.id || s.section_id,
- mid: s.mid ?? s.MID ?? 0,
- title: s.sectionTitle || s.section_title || s.title || s.chapterTitle || '',
- part: (s.partTitle || s.part_title || '').replace(/[_||]/g, ' ').trim(),
- tag: tagMap[i % 3] || '精选',
- tagClass: ['tag-hot', 'tag-rec', 'tag-rec'][i % 3] || 'tag-rec'
- }))
- this.setData({
- featuredSectionsFull: full,
- featuredSections: full,
- featuredExpanded: true,
- featuredExpandedLoading: false
- })
- } catch (e) {
- console.log('[Index] 加载精选更多失败:', e)
- this.setData({ featuredExpandedLoading: false })
- }
+ // 精选推荐:列表下方小三角展开(数据已在 loadFeaturedAndLatest 一次拉齐)
+ expandFeaturedChapters() {
+ if (this.data.featuredExpanded) return
+ const full = this.data.featuredSectionsFull || []
+ 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) {
diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml
index 8739bf3a..b373cd0a 100644
--- a/miniprogram/pages/index/index.wxml
+++ b/miniprogram/pages/index/index.wxml
@@ -4,12 +4,12 @@
-
+