31 Commits
main ... devlop

Author SHA1 Message Date
乘风
e6df2b78be 同步进程 2026-05-09 18:46:59 +08:00
乘风
c594bfb7e2 今日新增修改:121 2026-05-09 18:17:46 +08:00
乘风
0cc8e2b270 feat: enhance member detail page with dynamic tabs and image handling
- Introduced member business tabs for profile and dynamics, improving navigation and user experience.
- Implemented image parsing and normalization functions to handle article images effectively.
- Updated the UI to display article thumbnails and manage loading states, enhancing visual feedback.
- Refactored data synchronization methods to ensure member business tabs reflect current content accurately.

This update aims to streamline member profile interactions and improve the overall presentation of member-related content.
2026-05-09 18:15:45 +08:00
乘风
99d65dc664 feat: add super article management features and enhance UI
- Introduced a new page for managing user-specific articles, allowing users to view and edit their submissions.
- Implemented article audit status handling, including visual indicators for pending, approved, and rejected articles.
- Enhanced the article detail view with audit feedback and editing capabilities for articles in pending or rejected states.
- Updated the UI to dynamically reflect ownership and article status, improving user experience and clarity.

This update aims to streamline article management for users and provide better feedback on article submission statuses.
2026-05-09 17:49:45 +08:00
乘风
c84c53515e feat: update API base URLs and improve UI elements
- Changed API base URLs in app.js to point to the production server for deployment.
- Updated the my page UI by refining labels and removing unnecessary text for a cleaner look.
- Adjusted CSS styles to enhance layout and reduce whitespace, improving overall user experience.

This update aims to ensure proper API connectivity and enhance the visual presentation of the user interface.
2026-05-09 17:26:55 +08:00
乘风
b1db1efeb8 feat: enhance profile editing and image upload functionality
- Introduced a utility function to ensure absolute media URLs for user profile images.
- Updated the user cache synchronization method to handle profile data more effectively, including avatar, nickname, phone, and MBTI.
- Refactored the avatar upload process to utilize the new URL handling function, improving reliability.
- Modified the super article editor to focus on image uploads instead of file uploads, enhancing user experience with clearer labeling and prompts.

This update aims to streamline user profile management and improve the image upload experience in the application.
2026-05-09 16:23:59 +08:00
乘风
2645212d47 feat: update API base URLs and enhance icon designs
- Updated the API base URLs in app.js to point to local development server for testing.
- Enhanced SVG icons with gradient fills for improved visual appeal across various components.
- Refactored the avatar upload functionality in avatar-nickname.js and profile-edit.js to utilize a new upload utility for better code maintainability.
- Improved the layout and styling of the my page and super article editor for a more user-friendly interface.

This update aims to streamline development processes and enhance the overall user experience in the application.
2026-05-09 16:00:16 +08:00
乘风
b2b753d566 feat: unify planKey handling for lead management
- Added a new entry for the 存客宝计划 Key in the documentation, ensuring consistency between the displayed planKey and the apiKey used for retries.
- Implemented a caching mechanism for the JWT token in the ckbOpenGetToken function to reduce redundant API calls.
- Refactored the lead management logic to ensure that the same planKey is used for both displaying leads and retrying failed pushes, enhancing data integrity and reducing discrepancies.

This update aims to streamline lead management processes and improve the reliability of API interactions.
2026-05-08 15:39:47 +08:00
乘风
7399e392aa 今日新增修改:解决好友自动推送问题 2026-05-08 15:08:01 +08:00
乘风
bfd7610527 feat: update CKB workspace and testing documentation
- Added new entries for the 存客宝工作台 (CKB Workbench) in the skills and testing documentation, including mappings for join/match/lead types and related development documentation.
- Enhanced the CKBConfigPanel component to improve user experience with clearer labeling and functionality for managing leads.
- Implemented search functionality in the backend for filtering leads based on user input, improving data retrieval efficiency.
- Updated environment configuration for better clarity and consistency across development settings.

This update aims to enhance the usability of the CKB workspace and streamline lead management processes.
2026-05-08 11:06:43 +08:00
乘风
c78a0d531f feat: enhance login modal and avatar selection process
- Updated the login modal to include a user agreement section with clickable links to the user agreement and privacy policy.
- Improved button styling and layout for better user experience.
- Refactored avatar selection process to utilize local image paths, enhancing compatibility and reliability across different environments.
- Added utility functions for resolving avatar file paths and selecting images from the gallery or camera.

This update aims to streamline user interactions during login and avatar selection, ensuring a smoother experience.
2026-05-06 17:21:10 +08:00
乘风
5a88e87654 feat: implement audit mode functionality in article editor and user pages
- Added audit mode checks to restrict access to certain features and UI elements in the article editor and user pages.
- Updated the UI to disable input fields and display a mask when in audit mode, enhancing user feedback.
- Ensured that actions like submitting articles and inserting content are blocked in audit mode to prevent unauthorized usage.

This update aims to improve content management and user experience by enforcing stricter access controls during the audit process.
2026-05-06 14:32:38 +08:00
乘风
8abd0280b7 最新同步 2026-04-29 19:08:02 +08:00
乘风
78d39ed8a5 今日新增修改: 2026-04-28 18:39:08 +08:00
乘风
aec0c59b09 今日新增修改: 2026-04-27 10:40:26 +08:00
乘风
83a420f1b9 feat: implement audit mode restrictions across various pages
- Added checks for audit mode in VIP navigation and member detail pages, preventing access and displaying appropriate messages.
- Updated UI elements to conditionally render based on audit mode, enhancing user experience by hiding VIP-related options when in audit mode.
- Introduced loading indicators on the VIP page to manage user expectations during content validation.
- Enhanced error handling for image uploads in the article editor, ensuring a smoother content creation process.

This update aims to improve the overall user experience by enforcing access restrictions and providing clear feedback in audit mode.
2026-04-24 18:23:34 +08:00
乘风
f08c82c441 fix: 根据审核模式限制阅读页视频展示
更新了视频渲染逻辑:仅在非审核模式下显示视频。
确保在阅读页不同内容类型下,对视频元素的处理保持一致。
通过在受限场景中禁止视频播放,优化了用户体验。
2026-04-24 16:32:04 +08:00
乘风
73d56a225a feat: enhance link handling in reading page
- Added functionality to open article links in a mini program web-view, allowing users to preview external links.
- Implemented mobile hints for external links to improve user experience on mobile devices.
- Updated content parser to support new link types, including regular hyperlinks.
- Enhanced styling for article links to improve visibility and interaction.

Made-with: Cursor
2026-04-24 16:17:07 +08:00
乘风
51d38cff0f 新数据 2026-04-23 18:14:06 +08:00
乘风
e905fac69d Merge branch 'yongpxu-dev' into devlop
# Conflicts:
#	miniprogram/pages/read/read.wxml   resolved by yongpxu-dev version
#	miniprogram/utils/contentParser.js   resolved by yongpxu-dev version
2026-04-20 11:27:42 +08:00
乘风
5b5e01e9b5 1 2026-04-20 11:26:31 +08:00
乘风
37be3034ec fix(devdoc): Windows 合法资源路径 + 本地启动 bat 与说明
- 将误用 Markdown 语法的截图路径重命名为 截图1-占位说明图.png

- 新增 scripts/本地启动.bat;测试 README 补充 Windows 启动方式

- 新增 开发文档/Windows-资源命名与拉取.md

Made-with: Cursor
2026-04-20 11:20:47 +08:00
乘风
0dd6a1021d Merge branch 'devlop' of http://192.168.1.201:3000/fnvtk/Mycontent into devlop 2026-04-20 11:18:28 +08:00
乘风
7cd8ffdd26 feat: add MBTI tag functionality to user profile
- Introduced a new MBTI tag in the user profile section, displaying the user's MBTI type or a default label if not available.
- Implemented navigation to a mini program for MBTI testing, ensuring user phone number is included if logged in.
- Enhanced styling for the MBTI tag to improve visibility and user interaction.

Made-with: Cursor
2026-04-20 11:10:50 +08:00
乘风
34d284c129 1 2026-04-20 09:46:18 +08:00
乘风
0ce86645b1 feat: enhance deployment scripts and experience index
- Added a new webhook integration for Feishu/Lark groups, allowing for custom bot notifications.
- Refactored deployment scripts to unify the deployment process, improving maintainability and reducing redundancy.
- Updated the experience index to include recent entries and improved documentation for various roles.

Made-with: Cursor
2026-04-15 15:23:50 +08:00
乘风
5010e2dfba 特性:在内容展示中增加视频支持
- 在小程序阅读页面中实现了视频渲染,支持单个视频项目和内嵌视频片段。
- 更新了内容解析器,以处理视频标签并提取视频源。
- 增强了视频元素的样式,以确保其正确显示和响应性。

制造材料:Cursor
2026-04-14 16:14:21 +08:00
乘风
35e18a7ccb chore: update experience index and documentation for various roles
- Added recent experience entries for roles including Assistant, Mini Program Developer, Backend Engineer, Product Manager, Software Tester, and Team.
- Enhanced documentation structure in the development assistant's experience list and project index.
- Updated role-flow control documentation to reflect new methodologies and programming thinking.
- Improved error handling in the mini program and backend API for better user feedback during operations.

Made-with: Cursor
2026-04-14 11:24:18 +08:00
乘风
e391115938 Merge branch 'devlop' of http://192.168.1.201:3000/fnvtk/Mycontent into devlop 2026-04-13 15:25:23 +08:00
乘风
29c8ca96a2 Merge branch 'main' into devlop
# Conflicts:
#	miniprogram/app.js
#	miniprogram/pages/index/index.js
#	miniprogram/pages/read/read.js
#	miniprogram/pages/read/read.wxml
#	soul-admin/src/components/modules/leads/CkbLeadsPanel.tsx
#	soul-admin/src/components/modules/mbti/MbtiAvatarsManager.tsx
#	soul-admin/src/components/modules/user/UserDetailModal.tsx
#	soul-admin/src/pages/content/ContentPage.tsx
#	soul-admin/src/pages/dashboard/DashboardPage.tsx
#	soul-admin/src/pages/distribution/DistributionPage.tsx
#	soul-admin/src/pages/find-partner/FindPartnerPage.tsx
#	soul-admin/src/pages/users/SuperIndividualTab.tsx
#	soul-admin/src/pages/users/UsersPage.tsx
#	soul-admin/tsconfig.tsbuildinfo
#	soul-api/internal/handler/admin_mbti_avatars.go
#	soul-api/internal/handler/db.go
#	soul-api/internal/handler/miniprogram.go
#	soul-api/internal/handler/miniprogram_super_stats.go
#	soul-api/internal/handler/user.go
#	soul-api/internal/router/router.go
#	开发文档/索引.md
2026-04-13 14:32:56 +08:00
乘风
3cc33c7d7a 初始化 2026-04-13 14:32:32 +08:00
400 changed files with 32399 additions and 2766 deletions

View File

@@ -0,0 +1,6 @@
# 产品经理 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:以可验收功能点为最小同步单位;主需求与《以界面定需求》为验收真源;跨端规则变更须同时更新需求清单状态与优先级。
- **待办**:核对 2026-04-02 以来实现与文档差异vip-members/融合一期验收口径在需求文档中单点定稿。

View File

@@ -0,0 +1,7 @@
# 产品经理 经验记录 - 2026-04-14
## 吸收沉淀:同步开发需求
- **需求清单**:在 `开发文档/1、需求/需求汇总.md` 维护编号行SYNC / LOOP / MAINT 等),状态与责任角色与纪要待办一致。
- **验收绑定**:功能完成 = 实现可合并 + **需求表或项目索引当日一行**;跨端规则须在需求或运营与变更中单点写清优先级。
- **管理端必要性**:沿用产品 Skill — 纯文案/纯视觉可不建管理端;**配置/开关/审核/统计** 必须显式在需求表标「管理端入口」避免壳功能。

View File

@@ -6,3 +6,5 @@
| 2026-03-05 | 文章详情@某人高亮与一键加好友验收标准与待确认 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp主导航收敛与隐藏页面入口承载策略 | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-24 | 开发进度同步会议:文档同步原则、项目索引补齐 | [2026-03-24.md](./2026-03-24.md) |
| 2026-04-13 | 按功能同步开发文档会议:验收与需求真源、功能完成定义 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀:需求汇总表维护、验收与管理端必要性 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,6 @@
# 后端工程师 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**接口变更须写明路由组miniprogram/admin/db、路径、字段与错误码表结构/迁移在部署文档或临时需求池留痕;历史待办按 P0/P1 标注与依赖方。
- **待办**router/ossConfig、vip-members 等待办在文档中优先级化。

View File

@@ -0,0 +1,6 @@
# 后端工程师 经验记录 - 2026-04-14
## 吸收沉淀:接口与需求文档同步
- 新增/变更接口须在需求或运营摘要中写明 **路由组miniprogram/admin/db**、路径、关键字段与错误码;迁移写在 `开发文档/8、部署/` 或临时需求池(若存在)。
- **P0/P1 待办**router、ossConfig、vip-members 等)在 `需求汇总.md` 已单列 BACKEND-P0 行,合并前更新状态与依赖方。

View File

@@ -0,0 +1,13 @@
# 存客宝工作台「join·资源对接」接口测试误报失败
## 现象
管理端 `CKBConfigPanel` 接口测试对 `POST /api/ckb/join``type=investor``userId=admin_test``CKBJoin` 对资源对接在非空 `userId` 时校验 `userHasContentPurchase``admin_test` 非真实用户且无订单 → 返回 `success:false``CONTENT_PURCHASE_REQUIRED`,与其它 join 子项不一致。
## 处理
`internal/handler/ckb.go` 中:**当 `userId` 去空格后为 `admin_test` 时跳过购课校验**(该串仅工作台连通性测试使用)。真实用户 ID 仍为 UUID不受影响。
## 注意
勿将真实用户 ID 设为 `admin_test`;若后续收紧,可改为管理端专用 Header 或仅 `/api/db` 代理测试接口。

View File

@@ -0,0 +1,37 @@
# 存客宝:计划 Key 列表展示与重推实发 apiKey 一致
## 问题现象
- 管理端「推广中心 / 获客列表」**计划Key** 列显示为某条计划(如 `2y4v5-…`)。
-**重推** 时,实际请求 `ckbapi…/v1/api/scenarios``apiKey` 却是全局 `getCkbLeadApiKey()`(如 `IPQ9s-…`),存客宝返回 `无效的apiKey` / 401。
## 根因
1. **两套解析链**`GET /api/db/ckb-leads?mode=contact``planApiKey` 做多层兜底(`plan_api_key` → action 默认 → `target_person_id`/`persons.ckb_api_key` → index_link 全局人物);旧 **`retryOneLeadRecord`** 在 `plan_api_key` 为空时**直接** `getCkbLeadApiKey()`,且曾用 `params.targetUserId` 覆盖却**未读表字段 `target_person_id`**,甚至曾在非空快照上再被人物 key 覆盖。
2. **展示与写请求分叉**:运营按界面计划 Key 排障,与抓包不一致,误判为「存客宝挂了」。
## 修复要点(已实现)
-**`resolveLeadPlanAPIKeyForRetry`**(命名保留历史;语义为「本条线索应采信的计划 apiKey」顺序与列表原注释一致。
- **`retryOneLeadRecord`** 仅通过该函数取 key`target_person_id``params.targetUserId` 并用。
- **`DBCKBLeadList` mode=contact** 循环内 **`planApiKey` 改为调用同一函数**,与重推、定时重试(同走 `retryOneLeadRecord`)长期同源,避免再分叉。
- 调试用的 **`DBCKBLeadRetry` 终端打印、`pushLeadToCKB` URL 打印** 已按上线要求移除。
## 排查同类问题时的检查清单
| 检查项 | 说明 |
|--------|------|
| 是否存在「列表/详情一套、再请求另一套」 | 搜索同一业务字段在两处分别拼接 |
| `plan_api_key` 空时行为 | 是否与人物、`action/source` 一致 |
| 人物标识 | 表字段 `target_person_id` 与 JSON `targetUserId` 是否同时考虑 |
| 重推与首次提交 | 重推当前为 **GET scenarios 简化参数**;若与首次 **POST JSON** 不一致,可能仍有个案失败(产品另行评估) |
## 关联代码
- `soul-api/internal/handler/ckb.go``resolveLeadPlanAPIKeyForRetry``retryOneLeadRecord`
- `soul-api/internal/handler/db_ckb_leads.go``DBCKBLeadList` mode=contact 中 `planApiKey`
## 关联文档 / Skill
- `.cursor/skills/ckb-leads-api-test/SKILL.md`GotchasplanKey 与重推一致性)
- `.cursor/skills/api-dev/SKILL.md`Gotchas展示与下游请求字段同源

View File

@@ -10,3 +10,7 @@
| 2026-03-16 | ParseAutoLinkContent data-label存客宝 create planType/sceneId/status | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付 PayNotify beneficiaryUserID 权益归发起人gift-pay detail 返回 initiatorUserId | [2026-03-17.md](./2026-03-17.md) |
| 2026-03-24 | router 缺失四 handlerBookRanking、DBPersonPinnedToken、CKBPinnedPerson、AdminDashboardLeads | [2026-03-24.md](./2026-03-24.md) |
| 2026-04-13 | 按功能同步开发文档:接口/迁移文档化与 P0/P1 标注 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀接口与需求表同步、BACKEND-P0 行 | [2026-04-14.md](./2026-04-14.md) |
| 2026-05-08 | 存客宝获客列表计划Key与重推 apiKey`resolveLeadPlanAPIKeyForRetry` 与列表同源,避免展示与抓包分叉 | [2026-05-08-存客宝计划Key列表与重推一致.md](./2026-05-08-存客宝计划Key列表与重推一致.md) |
| 2026-05-08 | 存客宝工作台 join·资源对接测试`userId=admin_test` 跳过购课校验,避免 CONTENT_PURCHASE_REQUIRED 误报 | [2026-05-08-存客宝工作台资源对接接口测试.md](./2026-05-08-存客宝工作台资源对接接口测试.md) |

View File

@@ -0,0 +1,6 @@
# 团队 经验记录 - 2026-04-13
## 会议:按功能同步开发文档(跨角色)
- **决议**:功能完成定义包含 **代码可合并状态 + 当日文档/项目索引一行摘要(含日期)**;跨端字段或接口变更除各角色文档外,须在团队 evolution 或会议纪要留契约摘要。
- **关联**:详见 `agent/产品经理|后端工程师|管理端开发工程师|小程序开发工程师|软件测试/evolution/2026-04-13.md``.cursor/meeting/2026-04-13_按功能同步开发文档.md`

View File

@@ -0,0 +1,19 @@
# 团队 经验记录 - 2026-04-14
## 吸收沉淀:小程序优先闭环 + 需求同步
### 场景
评审「功能是否闭环」、区分界面写死与配置化、识别仅有样式无管理端入口、评估对其它模块的影响。
### 结论(团队约定)
1. **逆推顺序**:从小程序用户路径出发 → 数据来源(常量 / 本地 / `app.request`)→ `/api/miniprogram/*` 是否存在与错误语义 → 是否需要 **管理端或远程配置**(运营是否会改、是否与钱/风控相关)→ 是否影响支付/登录/分享/其它页 `onShow`
2. **写死 vs 配置化**:按 **变更频率、责任主体(运营/研发)、风险** 决策;需运行时开关/灰度时采用 **Feature Toggle** 思路(见业界 Martin Fowler *Feature Toggles*),避免永久堆砌 `if(flag)` 无退役计划。
3. **壳功能信号**:有入口但无 API、仅有样式、管理端无对应配置且业务上明显需运营改的标为 **技术债或假完成**,需求表状态不得标「已完成」。
4. **跨模块同步**:契约线(字段/枚举/缓存)、流程线(支付/分销/scene、权限配置线变更须在 **需求汇总 / 运营与变更 / 纪要** 之一可追溯,并双写本文件或 `2026-04-13` 纪要中的契约摘要。
5. **仓库需求真源**:本仓库已建 `开发文档/1、需求/需求汇总.md``开发文档/10、项目管理/运营与变更.md`,与 `.cursor/meeting/`、各角色 evolution 交叉引用。
### 同时影响
小程序、管理端、后端、测试、产品(文档与验收)。

View File

@@ -0,0 +1,50 @@
# 团队 经验记录 - 2026-04-15
## 联网吸收:优秀编程思维 → 智能体执行约束
> 以下为公开资料的**提炼与落地映射**,便于 Soul 多智能体Cursor 角色)统一行为;**非**替代项目 boundary / Skill 全文。
### 1. 垂直切片Vertical slicing
- **含义**:按「用户可感知的一小条价值」贯穿 **展示 → 接口 → 数据/配置**,而不是长期只做「全后端再全前端」的水平分层半成品。
- **对智能体**:拆任务时优先 **end-to-end 最小可演示**(例如:一条只读列表从真实 API 出数),再迭代字段与交互;避免单次会话产出大量未接线 UI。
**参考**:敏捷社区对 vertical slicing 的讨论(如 *The Art of the Vertical Slice* 等文章,关键词 *vertical slice user story*)。
### 2. 追踪弹Tracer bullets
- **出处思想**《The Pragmatic Programmer》——用一发「能穿过各层的薄路径」验证架构是否通畅。
- **与原型区别**:原型可丢;追踪弹是**进主分支的骨架**,后续在其上增厚。
- **对智能体**:强制 **小步、可编译、可运行、可测** 的增量;先打通再美化,降低大模型「一次写太多未验证代码」的风险。延伸阅读:*Tracer Bullets: Keeping AI Slop Under Control*`https://www.aihero.dev/tracer-bullets`)。
### 3. AI 编码与多智能体:验证优先
- **上下文优先**:先给目的、约束、边界、失败模式,再让生成代码(多篇 20242025 *AI coding best practices* 共性)。
- **分块生成 + 人审合并**:大纲 → 分文件小改 → 跑测试/静态检查;不把未读 diff 直接合并。
- **评估维度(借鉴 Agent 质量讨论)**:任务是否完成、工具/接口调用是否准确、是否引入多余依赖(参见 AWS 等「Agent 质量评估」类文章中的 TCR、工具准确率等提法
### 4. 工程文化:无责复盘与简单性
- **Blameless postmortem**:复盘关注**系统与流程**如何允许失误、行动项是否可验证避免归罪个人利于真实写入经验库Google SRE Book — Postmortem Culture`https://sre.google/sre-book/postmortem-culture/`)。
- **简单性**:偏好无聊但清晰的实现;与 Feature Toggle 一样,**实验性分支要有退役计划**。
### 5. 映射到本仓库Soul
| 外部思维 | 本仓库落地 |
|----------|------------|
| 垂直切片 / 追踪弹 | 跨端任务走 `role-flow-control`:先契约与最小联调,再扩功能;纪要/需求表可追溯 |
| 验证优先 | `change-checklist`、测试 Skill、`app.request` 与三端路由自检 |
| 无责复盘 | `.cursor/meeting/*` 问题与作答区 + 各角色 `evolution` 按日沉淀 |
| 简单性 | 禁止跨端乱调路由;避免无退役计划的永久 feature flag |
---
## 业务补齐(同日追加)
- **`app.request`**`success:false` 与部分非 200 响应在 `reject``Error` 上挂载 **`response`**,与 `miniprogram-dev` Skill 及 `referral/my` 提现分支一致。
- **导师咨询支付**`productType=mentor_consultation``getStandardPrice` 校验预约单归属与金额;`activateOrderBenefits` + `PayNotify``mentor_consultations.status``paid`**分销佣金**对导师单显式跳过(待产品另定规则)。
- **资源对接 `investor`**`CKBJoin` 未购章节时 **`success:false` + `errorCode: CONTENT_PURCHASE_REQUIRED`**,小程序 `match.js``e.response` 展示文案。
---
**最后更新**2026-04-15

View File

@@ -11,3 +11,6 @@
| 2026-03-16 | TipTap Mention 需 data-label否则显示 token | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付美团式流程与权益归属约定:读页→代付页→分享;权益/分佣归发起人 | [2026-03-17.md](./2026-03-17.md) |
| 2026-03-24 | 文档同步原则:实现变更后同步需求/运营与变更/项目索引 | [2026-03-24.md](./2026-03-24.md) |
| 2026-04-13 | 按功能同步开发文档:功能完成=实现+索引;跨端契约双写 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 小程序优先闭环审计、写死vs配置化、需求表骨架与 role-flow §1.2 | [2026-04-14.md](./2026-04-14.md) |
| 2026-04-15 | 联网垂直切片、追踪弹、AI验证优先、无责复盘role-flow §1.3 | [2026-04-15.md](./2026-04-15.md) |

View File

@@ -0,0 +1,6 @@
# 小程序开发工程师 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:页面级记录 `/api/miniprogram/*`、scene、支付/登录、分享 singlePage 等行为变更;纯 UI 无接口变更时也注明,便于测试裁剪回归。
- **待办**:主路径未记入文档的改动补索引一行。

View File

@@ -0,0 +1,6 @@
# 小程序开发工程师 经验记录 - 2026-04-14
## 吸收沉淀:闭环与硬编码自检
- 新功能自检:**页面 → 数据从哪来**;若仅为写死展示,在索引注明「无接口/无配置」以免误判完成度。
- 涉及 **scene、支付、登录、分享 singlePage** 的改动必须在 `需求汇总` 或项目索引可追溯,并评估对 **我的/阅读/代付** 等邻页的副作用。

View File

@@ -15,3 +15,5 @@
| 2026-03-17 | 代付美团式:读页→代付页→分享;详情页双态(发起人/好友);目录 loading、最新新增 5 条折叠 | [2026-03-17.md](./2026-03-17.md) |
| 2026-03-30 | 阅读页 `navigateToMiniProgram` 时 path 追加 `phone`(已登录且已绑定) | [2026-03-30.md](./2026-03-30.md) |
| 2026-04-11 | 阅读付费墙三态框体统一、`shareTipLine` 与文末分享区一致、需求文档闭环 | [2026-04-11-阅读付费墙三态与营销句闭环.md](./2026-04-11-阅读付费墙三态与营销句闭环.md) |
| 2026-04-13 | 按功能同步开发文档:页面级接口与分享行为、无接口变更亦标注 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀:闭环与硬编码自检、邻页副作用 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,6 @@
# 开发助理(橙子)经验记录 - 2026-04-14
## 吸收沉淀:各智能体同步开发需求
- 触发:用户「各智能体吸收经验沉淀,同步开发需求」→ 执行 `assistant-doc-sync` §5 + 文档同步:补齐仓库根 `开发文档/` 骨架,写入 `需求汇总.md``运营与变更.md`
- 同步 `经验清单.md``智能体-技能-经验总索引.md`、各角色 `evolution/索引.md`、项目索引一行、`sync-log`;轻量升级 `role-flow-control` §1.2(小程序逆推闭环)。

View File

@@ -0,0 +1,7 @@
# 开发助理 经验记录 - 2026-04-15
## 联网吸收:编程思维写入智能体
- **触发**:用户要求联网吸收优秀编程思维并沉淀到智能体。
- **动作**检索垂直切片、追踪弹、AI 编码验证、SRE 无责复盘等公开论述;写入 `agent/团队/evolution/2026-04-15.md`;在 `role-flow-control/SKILL.md` 增加 **§1.3 智能体协作提要**;更新 `经验清单``智能体-技能-经验总索引`、团队/助理橙子项目索引、`sync-log`
- **注意**:外链为思想参考;执行顺序仍以本仓库 `.cursor/rules` 与各主 Skill 为准。

View File

@@ -6,3 +6,5 @@
|------|------|------|
| 2026-03-21 | MBTI 头像小程序全链路兜底 + 匹配接口回填 | 2026-03-21-MBTI头像C端全链路兜底.md |
| 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts | 2026-03-16-交互习惯分析.md |
| 2026-04-14 | 各智能体吸收经验沉淀开发文档骨架、经验清单、role-flow §1.2 | 2026-04-14.md |
| 2026-04-15 | 联网吸收编程思维:团队 evolution、role-flow §1.3、索引与 sync-log | 2026-04-15.md |

View File

@@ -53,6 +53,8 @@
| 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-04-14 | 团队、产品、后端、管理端、小程序、测试、助理橙子 | 方法论/需求同步 | role-flow-control SKILL §1.2、assistant-doc-sync 文档树说明 | 吸收沉淀小程序逆推闭环、写死vs配置、需求汇总+运营与变更骨架;各角色 evolution/2026-04-14.md |
| 2026-04-15 | 团队、助理橙子 | 方法论(联网) | role-flow-control SKILL §1.3 | 垂直切片、追踪弹与 AI slop 控制、验证优先、Google SRE 无责复盘;详 `agent/团队/evolution/2026-04-15.md` |
---
@@ -63,4 +65,4 @@
---
**最后更新**2026-03-18
**最后更新**2026-04-15

View File

@@ -6,7 +6,7 @@
## 项目总结
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/2026-03-20-需求.md`(主需求清单),闭环结论见 `开发文档/10、项目管理/闭环与文档同步-2026-04-07.md`,临时需求/分析在 `临时需求池/`
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/2026-03-20-需求.md`(主需求清单,若未入库见同目录 `需求汇总.md`),闭环结论见 `开发文档/10、项目管理/闭环与文档同步-2026-04-07.md`(若存在),临时需求/分析在 `临时需求池/`(按需创建)
---
@@ -31,9 +31,11 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-03-31 | 会议:超级个体列表与 @ 列表融合方案(先数据互链再单页);一期验收口径待拍板 | 待续 |
| 2026-04-02 | 会议:工作进度与需求同步;一期融合验收口径澄清、各端索引与需求 delta 跟进 | 进行中 |
| 2026-04-07 | 开发文档与主需求交叉同步;闭环分析落盘(主路径成立、规则/风控与纪要问题区待闭环) | 已完成 |
| 2026-04-13 | 会议:按功能同步开发文档;功能完成=实现+文档/索引可追溯;待办见纪要问题区 | 进行中 |
| 2026-04-14 | 吸收沉淀:新建 `开发文档/1、需求/需求汇总.md``开发文档/10、项目管理/运营与变更.md`;需求清单 SYNC/LOOP/MAINT/BACKEND 行 | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-07
**最后更新**2026-04-14

View File

@@ -26,9 +26,11 @@
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿;纪要、经验入库、开发文档同步 | 已完成 |
| 2026-04-02 | 会议收尾:工作进度与需求同步会;纪要、各角色 evolution、项目索引与会议索引更新 | 已完成 |
| 2026-04-07 | 同步开发文档与主需求;新增《闭环与文档同步-2026-04-07》并更新索引、sync-log | 已完成 |
| 2026-04-14 | 吸收经验:各角色 evolution/2026-04-14、开发文档骨架、经验清单、总索引、role-flow §1.2 | 已完成 |
| 2026-04-15 | 联网吸收编程思维:团队 evolution/2026-04-15、role-flow §1.3、经验清单与 sync-log | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-07文档同步与闭环分析
**最后更新**2026-04-15

View File

@@ -43,9 +43,12 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-03-24 | 会议:三端闭环评审;提现/CKB/匹配后端校验与 errorCode 为 P0/P1 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合;优先扩展 vip-members 聚合 Person 关联字段 | 待续 |
| 2026-04-02 | 会议工作进度与需求同步deploy 验证后提交vip-members 聚合优先router/ossConfig 历史待办跟进 | 进行中 |
| 2026-04-13 | 会议:按功能同步开发文档;接口/迁移变更须标路由组与 P0/P1文档待补齐 | 进行中 |
| 2026-04-14 | 吸收沉淀:`需求汇总.md` 增加 BACKEND-P0 行;接口说明与迁移落文档 | 进行中 |
| 2026-04-15 | 业务补齐:`mentor_consultation` 支付计价/回调/权益;`CKBJoin` investor 显式失败;`getStandardPrice` 增加 buyerUserID | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-02
**最后更新**2026-04-15

View File

@@ -34,9 +34,12 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-31 | 会议:超级个体与 @ 列表融合;先数据与入口融合、路由边界不变 | 已完成 |
| 2026-04-02 | 会议:工作进度与需求同步;融合落地顺序与 deploy 验证门禁、问题与作答区待闭环 | 已完成 |
| 2026-04-07 | 文档同步:需求与开发文档索引对齐;闭环结论:主路径成立、规则/风控待加固项见《闭环与文档同步-2026-04-07》 | 已完成 |
| 2026-04-13 | 会议:按功能同步开发文档;功能完成=实现+索引一行;跨端契约双写团队/纪要 | 进行中 |
| 2026-04-14 | 吸收沉淀:小程序逆推闭环方法论;`role-flow-control` §1.2;开发文档需求真源 | 进行中 |
| 2026-04-15 | 联网:垂直切片/追踪弹/验证优先/无责复盘 → `role-flow-control` §1.3、`团队/evolution/2026-04-15.md` | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-04-07
**最后更新**2026-04-15

View File

@@ -49,9 +49,12 @@
| 2026-03-30 | 会议:文章详情 # 跳转关联小程序(如 MBTI时 path 追加 `phone`read.js 已实现 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合C 端默认不改接口,仅回归 | 已完成 |
| 2026-04-02 | 会议:工作进度与需求同步;全局 app/request 类改动需主路径冒烟;融合仍以回归为主 | 已完成 |
| 2026-04-13 | 会议:按功能同步开发文档;页面级记 miniprogram 接口与分享行为;无接口变更亦标注 | 进行中 |
| 2026-04-14 | 吸收沉淀:逆推闭环自检写入 evolutionLOOP 需求行 | 进行中 |
| 2026-04-15 | 业务补齐:`app.js``err.response`;导师详情微信支付;找伙伴 join 失败展示后端文案 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-02
**最后更新**2026-04-15

View File

@@ -36,7 +36,9 @@
| 2026-03-24 | 会议三端闭环评审API 绕过负例与双入口提现回归待契约落地后执行 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合;回归清单含排序/@/绑定/Webhook 边界 | 待续 |
| 2026-04-02 | 会议工作进度与需求同步融合清单待联调后执行deploy PR 增加 compose/health/smoke | 待续 |
| 2026-04-13 | 会议:按功能同步开发文档;以文档与索引为真源更新回归;契约未入文档可阻塞 | 进行中 |
| 2026-04-14 | 吸收沉淀LOOP 需求与逆推清单纳入回归裁剪依据 | 进行中 |
---
**最后更新**2026-04-02
**最后更新**2026-04-14

View File

@@ -48,11 +48,13 @@
| 2026-03-24 | 会议:三端闭环评审;管理端与小程序配套整体齐全;类型债择机修 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合UsersPage 表格加 @ 状态与跳转内容页 | 待续 |
| 2026-04-02 | 会议工作进度与需求同步UsersPage 待接口字段就绪后联调deploy 文档基址与后端对齐 | 进行中 |
| 2026-04-13 | 会议:按功能同步开发文档;页面粒度记 admin/db 依赖;类型债排期入文档 | 进行中 |
| 2026-04-14 | 吸收沉淀MAINT-ADMIN-TS 入需求表;配置入口与运营与变更联动 | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-02
**最后更新**2026-04-14
> soul-admin 构建仍有 DistributionPage Order.description 类型错误(与本次迁移无关),待修复。

View File

@@ -7,25 +7,40 @@
## 1. 经验索引(按角色)
- 助理橙子:`agent/开发助理/evolution/索引.md`
- 2026-04-15联网编程思维沉淀 + role-flow §1.3
- 2026-04-14各智能体吸收沉淀 + 开发文档骨架与 role-flow §1.2
- 2026-03-21MBTI 头像小程序全链路兜底 + 匹配接口回填
- 2026-03-16用户交互习惯分析基于 agent-transcripts
- 小程序开发工程师:`agent/小程序开发工程师/evolution/索引.md`
- 2026-04-14吸收沉淀闭环自检、邻页副作用
- 2026-04-13按功能同步开发文档页面级接口与分享、无接口变更标注
- 2026-03-30阅读页 `navigateToMiniProgram` 时 path 追加 `phone`
- 2026-03-17代付美团式流程与目录 loading/折叠
- 2026-03-16编辑资料页分享名片Canvas 封面)
- 管理端开发工程师:`agent/管理端开发工程师/evolution/索引.md`
- 2026-04-14吸收沉淀配置入口、MAINT-ADMIN-TS
- 2026-04-13按功能同步开发文档页面粒度 admin/db、字段对齐
- 2026-03-16链接人与事复制/删除 Dialog、planId/apiKey 列)
- 2026-03-10迁移 Mycontent-temp主导航收敛/Settings 承载)
- 2026-03-05文章详情@某人(编辑页插入 @ 用户)
- 后端工程师:`agent/后端工程师/evolution/索引.md`
- 2026-05-08存客宝计划 Key 列表与重推 apiKey 同源resolveLeadPlanAPIKeyForRetry
- 2026-04-14吸收沉淀接口与需求表、BACKEND-P0
- 2026-04-13按功能同步开发文档接口/迁移文档化、P0/P1
- 2026-03-24router 缺失四 handler 补齐BookRanking 等)
- 2026-03-17PayNotify 权益归属发起人
- 2026-03-14内容排名算法修正rank 分公式)
- 产品经理:`agent/产品经理/evolution/索引.md`
- 2026-04-14吸收沉淀需求汇总表、管理端必要性
- 2026-04-13按功能同步开发文档会议功能完成定义/验收真源)
- 2026-03-24开发进度同步会议文档同步原则/项目索引补齐)
- 2026-03-10需求同步管理端迁移入口策略
- 2026-03-05文章详情@某人(验收标准与待确认)
- 软件测试:`agent/软件测试/evolution/索引.md`
- 2026-05-07存客宝工作台「类型」join/match/lead 中文映射与开发文档入库
- 2026-05-06JWT 驱动 ckb-leads submitted 契约测试 Skill脚本 + 工作台字段验收)
- 2026-04-14吸收沉淀LOOP 逆推清单回归)
- 2026-04-13按功能同步开发文档文档为真源、契约缺失可阻塞
- 2026-03-16scripts/test/process 流程测试目录
- 2026-03-05文章详情@某人(用例、联调与回归点)
- 2026-03-10管理端迁移回归鉴权/跳转可达性)
@@ -33,6 +48,9 @@
- 2026-03-20挖矿病毒排查经验转化为 Skills
- 2026-03-20部署 API / 部署触发词(环境与脚本映射)
- 团队(跨角色共享):`agent/团队/evolution/索引.md`
- 2026-04-15联网编程思维垂直切片、追踪弹、验证优先、无责复盘
- 2026-04-14小程序优先闭环、写死vs配置化、需求真源路径
- 2026-04-13按功能同步开发文档实现+索引、跨端契约双写)
- 2026-03-16TipTap Mention 需 data-label
- 2026-03-17代付美团式流程与权益归属约定
- 2026-03-24文档同步原则实现变更后同步需求/运营/索引)
@@ -52,13 +70,15 @@
- 触发:需求分析、需求文档、验收、加个需求
- 测试(功能/回归/三端联调):`skills/testing/SKILL.md`
- 触发测试、测试用例、回归测试、QA
- 存客宝获客列表 submitted + JWT 回归:`skills/ckb-leads-api-test/SKILL.md`
- 触发JWT 测试存客宝、ckb-leads submitted、获客列表字段验收、发 jwt 给你测、「加入/匹配提交」API、工作台 phone/wechatId
- 助理橙子(文档同步/经验入库/会议结束/日志):`skills/assistant-doc-sync/SKILL.md`
- 触发:小橙/橙子/橙橙/🍊、讨论完毕、吸收经验、保存进度、会议结束、出日志
- 团队会议(多角色发言、决议、纪要):`skills/team-meeting/SKILL.md`
- 触发:开个会、团队会议、需求评审、方案讨论
- 变更关联检查(防漏改,改完必过):`skills/change-checklist/SKILL.md`
- 触发:变更完成、检查一下、准备提交
- 跨端协同流程(职责分工/联想补齐):`skills/role-flow-control/SKILL.md`
- 跨端协同流程(职责分工/联想补齐/小程序逆推闭环/智能体协作提要`skills/role-flow-control/SKILL.md`
- 触发:跨端功能开发、三端联动优化
- 三端架构分析/迁移拆解约束:`skills/three-tier-arch/SKILL.md`
- 触发:拆解、迁移、框架选择、三端架构
@@ -76,6 +96,8 @@
- 触发部署、服务器操作、SSH、devloy、master
- 卡若创业派对运营(报表/切片/分发/飞书视频文字下载):`skills/karuo-party/SKILL.md`
- 触发:运营报表、视频切片、多平台分发、飞书视频下载、派对运营
- 飞书/Lark 群自定义机器人 Webhook与 Soul 订单·获客推送契约一致,可迁移):`skills/feishu-webhook/SKILL.md`
- 触发:飞书 webhook、自定义机器人、群通知、Lark bot、从 Soul 迁移通知
---

View File

@@ -0,0 +1,6 @@
# 管理端开发工程师 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:以页面/菜单为粒度同步至运营与变更或项目索引;标注所用 `/api/admin/*``/api/db/*`;与小程序共用字段写明来源。
- **待办**DistributionPage 类型债是否纳入排期,在文档或索引中闭环。

View File

@@ -0,0 +1,6 @@
# 管理端开发工程师 经验记录 - 2026-04-14
## 吸收沉淀:配置入口与需求表
-**页面/菜单** 为粒度在 `运营与变更.md` 或项目索引记录所用 `/api/admin/*``/api/db/*`;与小程序对齐的字段写明来源。
- **MAINT-ADMIN-TS**`DistributionPage` 等类型债已进 `需求汇总.md`,排期修复后更新状态为已完成。

View File

@@ -8,3 +8,5 @@
| 2026-03-05 | 文章详情@某人:编辑页插入 @用户、保存约定 content 格式 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局主导航收敛、Settings Tab 承载 author/admin | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-16 | 链接人与事table 布局、planId/apiKey 列、复制图标、删除 Dialog 弹窗 | [2026-03-16.md](./2026-03-16.md) |
| 2026-04-13 | 按功能同步开发文档:页面粒度 admin/db 与字段对齐说明 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀:配置入口与 MAINT-ADMIN-TS 需求行 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,6 @@
# 软件测试 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:以更新后的开发文档与各角色项目索引为真源维护回归与联调清单;契约未入文档可标阻塞。
- **待办**依据各端索引更新融合与主路径用例deploy/health/smoke 与项目管理文档交叉引用。

View File

@@ -0,0 +1,6 @@
# 软件测试 经验记录 - 2026-04-14
## 吸收沉淀:以文档为真源的回归
- **LOOP-20260414**:按「小程序优先逆推」清单编写/裁剪用例;文档与索引未更新的契约项可标 **阻塞—待文档** 并指向纪要问题区。
- 主路径冒烟与 deploy/health/smoke 要求见 `运营与变更.md` 与历史纪要交叉引用。

View File

@@ -10,3 +10,7 @@
| 2026-03-16 | scripts/test/process 流程测试:跨端多接口串联 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | pytest 架构、配置从项目读取、运行前显示测试环境 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | 测试用例归档规则混合→process、纯端→miniapp/web需求变更时更新 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-04-13 | 按功能同步开发文档:以文档与索引为真源、契约缺失可阻塞 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀LOOP 需求行与逆推清单回归 | [2026-04-14.md](./2026-04-14.md) |
| 2026-05-08 | 获客列表计划Key与重推 apiKey 同源后可验收:同 id 下列展示与推送 key 一致 | [后端 2026-05-08](../../后端工程师/evolution/2026-05-08-存客宝计划Key列表与重推一致.md) |
| 2026-05-07 | 存客宝工作台线索类型join=加入、match=匹配、lead=留资;开发文档与 UI 中文映射 | [存客宝-工作台线索类型](../../../开发文档/6、后端/存客宝-工作台线索类型与中英文说明.md) |

View File

@@ -0,0 +1,108 @@
# 会议纪要 - 2026-04-13 | 按功能同步开发文档
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-04-13 10:00
- **议题**:各角色依据本人负责的开发功能,同步开发文档(需求汇总、运营与变更、项目索引、临时需求池与接口契约说明)
- **触发方式**:开个会 / 团队会议(文档同步专项)
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员、乘风(主持)、助理橙子(书记)
---
## 各角色发言
### 【产品经理】
需求侧以 `开发文档/1、需求/` 主需求与《以界面定需求》为验收基准。各端每完成一块可交付功能,须在 **需求清单/验收口径** 中对应更新状态;跨端规则(如 VIP、提现、@ 人)变更必须写清业务规则与优先级,避免「代码已上、文档仍旧」。
### 【后端开发】
每新增或变更接口,须在实现合并前同步:`路由组miniprogram/admin/db`、路径、请求/响应关键字段、错误码约定;涉及表结构或迁移的,在 `开发文档/8、部署/` 或临时需求池技术分析中留痕。历史待办(如 router/ossConfig、vip-members 聚合)在文档中标注 **P0/P1** 与依赖方。
### 【管理端开发工程师】
管理端功能以页面/菜单为粒度,在 `开发文档/10、项目管理/运营与变更.md` 或需求汇总中追加「做了什么、调了哪些 `/api/admin/*` `/api/db/*`」;与小程序字段对齐处(列表、详情、配置项)写明字段来源,便于测试写用例。
### 【小程序开发工程师】
C 端以页面与核心流程为粒度同步:涉及 `/api/miniprogram/*` 的变更、scene、支付/登录分支、分享 singlePage 行为,须在开发文档或角色项目索引中可追溯;仅改 UI 也需注明「无接口变更」以便测试裁剪回归范围。
### 【测试人员】
文档同步后,测试以 **文档与索引为真源** 更新回归清单与联调顺序;三端契约变更若文档未更新,测试有权将项标为「阻塞—待文档」并在问题与作答区登记。
---
## 讨论过程
- **【管理端开发工程师】回复【产品经理】**:界面定需求已存在,建议「功能完成」定义包含:代码合并 + 文档/索引一行摘要,避免遗漏。
- **【后端开发】回复【小程序开发工程师】**miniprogram 与 admin 路由隔离不变;文档中写清「仅供某端调用」可避免联调误用。
- **【测试人员】回复【全员】**deploy/health/smoke 与关键路径用例在 `开发文档/10、项目管理` 与测试索引中保持交叉引用,便于发版前自检。
---
## 会议决议
1. **按功能粒度同步**:以「可验收功能点」为最小单位,责任角色在合并前或合并当日更新:对应 `开发文档` 章节、`.cursor/agent/开发助理/项目索引/{角色}.md` 开发进度表一行(**必须含日期**)。
2. **跨端变更双写**:影响两端的字段或接口,除本角色文档外,须在 `agent/团队/evolution/` 或会议纪要中留架构/契约摘要,并在相关角色索引中各记一行。
3. **待确认项进表**:接口路径、错误码、验收口径未定的,一律落入本次会议「问题与作答区」,由责任角色补充作答后闭环。
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 产品经理 | 核对主需求与《以界面定需求》与各端近期实现差异,更新需求汇总状态 | 高 | 2026-04-20 |
| 后端开发 | 将 router/ossConfig、vip-members 等待办在文档中标注优先级与依赖 | 中 | 2026-04-20 |
| 管理端开发工程师 | soul-admin 近期页面/接口变更写入运营与变更或索引 | 中 | 2026-04-20 |
| 小程序开发工程师 | 阅读/支付/分享等主路径若有未记文档的改动,补索引与说明 | 中 | 2026-04-20 |
| 测试人员 | 依据更新后的文档刷新融合与主路径回归清单 | 中 | 2026-04-20 |
---
## 问题与作答区
> 会议中提出的待确认问题在此列出;作答区域供后续补充答案,便于追溯闭环。
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 各端「最近一次合并到主分支」的功能清单是否与项目索引 2026-04-02 之后实际代码一致? | 各角色负责人 | (待补充) |
| 2 | vip-members 聚合与超级个体/@ 融合一期验收口径是否已在需求文档中单点定稿? | 产品经理 | (待补充) |
| 3 | DistributionPage `Order.description` 类型债是否纳入本迭代文档与修复排期? | 管理端开发工程师 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 文档同步与验收基准绑定:功能完成 = 实现 + 文档/索引可追溯。
### 后端开发
- 接口与迁移变更必须标明路由组与优先级,避免管理端/小程序误调。
### 管理端开发工程师
- 以页面为粒度记录 admin/db 依赖,字段与小程序对齐处显式写出。
### 小程序开发工程师
- 无接口变更的纯 UI 迭代也应在索引中标注,便于测试裁剪范围。
### 测试人员
- 以更新后的文档与索引为真源维护回归清单;文档缺失可阻塞验收。
### 团队共享
- 延续 2026-03-24 文档同步原则:实现变更后同步需求、运营与变更、各角色项目索引;跨端契约写入团队 evolution 或会议纪要。
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-04-13.md`*

View File

@@ -85,3 +85,4 @@ YYYY-MM-DD_会议主题.md
| 2026-03-30 | 文章详情 # 标签跳转 MBTI 小程序传手机号 | 产品、后端、管理端、小程序、测试 | [2026-03-30_文章详情跳转MBTI小程序传手机号.md](2026-03-30_文章详情跳转MBTI小程序传手机号.md) |
| 2026-03-31 | 超级个体列表与 @ 列表融合方案 | 产品、后端、管理端、小程序、测试、团队 | [2026-03-31_超级个体与@列表融合.md](2026-03-31_超级个体与@列表融合.md) |
| 2026-04-02 | 工作进度与需求同步会 | 产品、后端、管理端、小程序、测试、团队 | [2026-04-02_工作进度与需求同步会.md](2026-04-02_工作进度与需求同步会.md) |
| 2026-04-13 | 按功能同步开发文档 | 产品、后端、管理端、小程序、测试、团队 | [2026-04-13_按功能同步开发文档.md](2026-04-13_按功能同步开发文档.md) |

View File

@@ -66,6 +66,7 @@ alwaysApply: true
| 小橙、橙子、橙橙、🍊、讨论完毕、记录一下、记录、同步文档 | `.cursor/skills/assistant-doc-sync/SKILL.md` |
| 吸收经验、升级 skills、记录经验、保存开发进度、更新项目索引、记录开发进度、任务完成、搞定了、完成了 | `.cursor/skills/assistant-doc-sync/SKILL.md` |
| 跨端功能开发 | `.cursor/skills/role-flow-control/SKILL.md` |
| JWT 测试存客宝获客列表、ckb-leads submitted、发 jwt 测接口、工作台「加入/匹配提交」字段验收 | `.cursor/skills/ckb-leads-api-test/SKILL.md` |
| 变更完成、检查一下、准备提交 | `.cursor/skills/change-checklist/SKILL.md` |
| 开个会、开会、团队会议、乘风开会、需求评审、方案讨论、大家一起讨论 | `.cursor/skills/team-meeting/SKILL.md`(老板分身/乘风主持) |
| 会议结束、散会、会开完了 | `.cursor/skills/assistant-doc-sync/SKILL.md`(会议收尾) |

View File

@@ -112,6 +112,7 @@ description: Trigger when 编辑 soul-api、后端、API、Go、Gin、GORM、新
| 新增表/字段后未执行迁移 | 线上报错、字段缺失 | 编写 scripts/add-xxx.sql → 执行 `node .cursor/scripts/db-exec/run.js -f soul-api/scripts/add-xxx.sql` |
| 新增 model 未 AutoMigrate | 表不存在、启动失败 | 在 `internal/database/database.go``AutoMigrate(&model.Xxx{})` |
| 业务逻辑直接 `os.Getenv` | 配置分散、难维护 | 仅通过 `internal/config``Load()` 读环境变量 |
| 管理端展示的「计划 Key / 状态」与再次调用第三方用的密钥 **分两处手写解析** | 线上列表对的、重推错的;排障永远对不上抓包 | **单一真源函数**:如存客宝线索 `resolveLeadPlanAPIKeyForRetry` 同时服务 `GET .../ckb-leads?mode=contact``planApiKey``retryOneLeadRecord`,禁止列表与重推各写一套顺序 |
---

View File

@@ -157,13 +157,13 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
## 文档结构速查
```
开发文档/
开发文档/ # 仓库根目录下;若尚未克隆外部大文档,可先维护本骨架与摘要
├── 1、需求/需求汇总.md # 需求清单、业务需求
├── 8、部署/ # 技术方案、部署说明
├── 8、部署/ # 技术方案、部署说明(按需创建)
├── 10、项目管理/
│ ├── 项目落地推进表.md # 里程碑、永平落地
│ ├── 项目落地推进表.md # 里程碑、永平落地(按需创建)
│ └── 运营与变更.md # 近期更新、变更记录
临时需求池/ # 需求分析、技术分析、待办
临时需求池/ # 需求分析、技术分析、待办(按需创建)
.cursor/agent/ # 每角色 evolution 经验池
├── 开发助理/evolution/、项目索引/{小程序,管理端,后端,产品,测试,助理橙子,团队}.md
├── 小程序开发工程师/evolution/

View File

@@ -6,6 +6,19 @@
---
## 2026-04-15
- 吸收经验(联网):编程思维沉淀至 `agent/团队/evolution/2026-04-15.md`;升级 `role-flow-control` §1.3`经验清单`、总索引、团队/助理橙子项目索引、sync-log 已更新
- 业务补齐:`app.request` 失败体挂 `err.response`;导师咨询 `mentor_consultation` 支付全链路;`CKBJoin` investor 假成功改为显式失败;`需求汇总` 更新
## 2026-04-14
- 吸收经验:各智能体沉淀 + 同步开发需求;新建 `开发文档/1、需求/需求汇总.md``开发文档/10、项目管理/运营与变更.md``开发文档/README.md`;各角色 `evolution/2026-04-14.md` 与索引;`经验清单``智能体-技能-经验总索引`、项目索引七处;升级 `role-flow-control` §1.2、`assistant-doc-sync` 文档树说明
## 2026-04-13
- 会议收尾:按功能同步开发文档;纪要 `.cursor/meeting/2026-04-13_按功能同步开发文档.md`;各角色 evolution、项目索引产品/后端/管理端/小程序/测试/团队、meeting/README 已更新
## 2026-04-07
- 文档同步:开发文档索引、主需求 2026-03-20、需求索引、10 项目管理 README 与《闭环与文档同步-2026-04-07》交叉引用项目索引助理橙子/团队追加进度

View File

@@ -77,6 +77,9 @@ description: Soul 创业派对变更关联检查。miniprogram/soul-admin/soul-a
| 找伙伴/匹配 | 匹配、展示 | match、ckb 等 miniprogram 接口 | 匹配配置、开关 |
| 配置项 | 仅读取 | miniprogram/config 或 db/config | 配置编辑admin/db |
| VIP/超级个体 | 展示列表、详情 | miniprogram/vip/membersdb/users PUT、db/vip-roles | 用户列表「设置 VIP」弹窗、VIP 角色管理页 |
| 超级个体发文UGC+AI | `super-article-editor``super-article-detail` | 表 `super_articles``GET/POST` `/api/miniprogram/super/articles*``/super/articles/generate` | 当前无独立超管文章列表为常态时勿只挂 admin**勿**与 book/章节阅读接口混改或合并列表 |
**专项检查**:若本次变更涉及 `super_articles`、**`super-article-*` 页面** 或 `super/articles` 接口,再读 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md),确认未与书籍/章节、MBTI 的 `soul_articles` 混表或混接口。
每次做「某个功能」的变更时,问:

View File

@@ -0,0 +1,91 @@
---
name: soul-ckb-leads-api-test
description: JWT 驱动的存客宝获客列表 API 回归。用户粘贴管理端 JWT 时立即执行脚本验证 GET /api/db/ckb-leads mode=submitted 是否返回 phone/wechatId。Use when JWT 测试存客宝、ckb-leads submitted、获客列表接口回归、工作台加入匹配提交字段验收、发 jwt 给你测一下。
---
# 存客宝获客列表 submitted 接口 · JWT 一键测试
当用户**发来管理端 JWT**(或使用「发 JWT 给你测」「跑存客宝 submitted 接口」等意图)时:**先读本 Skill再立刻执行下列测试流程**。无需反问「要不要测」。
---
## 角色
**软件测试**。只验证 API 契约与脚本结果,不替你改业务代码(若失败再转后端/前端)。
---
## 触发话术(任选)
- 「这是 JWT`eyJ…`」「发 jwt 给你」「用我的 token 测一下」
- 「存客宝工作台 submitted」「ckb-leads 接口回归」「手机号列 API 字段验收」
**信号**:消息里出现 **Bearer token 形态**`eyJ` 开头的三段 JWT或用户明确说已复制 **admin_token**
---
## 立即执行的测试流程
1. **解析环境**
- `SOUL_API_BASE`:优先用户消息中的 URL否则读 `soul-admin/.env.development``VITE_API_BASE_URL`;再否则 `http://localhost:8080`
- 去掉末尾 `/`
2. **提取 JWT**
- 若用户粘贴 `Bearer xxx`,只取 `xxx`
- 整段为 `eyJ...` 则原样使用。
3. **运行脚本**(仓库根目录,需已 `pip install -r scripts/test/requirements-test.txt`
```bash
# PowerShell 示例
$env:ADMIN_TOKEN="<JWT>"
$env:SOUL_API_BASE="<与上一步一致>"
python scripts/test/soul_api/test_ckb_leads_submitted.py --page-size 20
```
- 解释退出码:**0** = 本页每条记录均含 `phone`、`wechatId` 键;**1** = 401、HTTP 错误或缺键(多为**未部署**含该字段的后端)。
4. **回复用户(中文)**
- **环境**:用一句话写清测的是哪个 BASE URL不写全 JWT
- **结果**:通过 / 失败 + 摘录脚本输出要点(前几行样本、缺失字段条数)。
- **建议**:失败且为缺键 → 提示部署含 `phone`/`wechatId` 的 `soul-api`401 → token 失效或环境问题。
5. **安全**
- **不要在回复中完整复述用户 JWT**。
- 日志里如需引用,仅保留 `eyJ` 后前 **8** 字符 + ``。
- JWT 等价密码,勿写入 git、勿贴公开场合。
---
## 脚本真源与验收点
| 项目 | 说明 |
|------|------|
| 脚本路径 | `scripts/test/soul_api/test_ckb_leads_submitted.py` |
| 请求 | `GET /api/db/ckb-leads?mode=submitted&page=1&pageSize=…`,头 `Authorization: Bearer <ADMIN_TOKEN>` |
| 断言 | `records[]` **每条**均含键 `phone`、`wechatId`(值可为空) |
| 背景 | 存客宝工作台「加入/匹配提交」表依赖这两字段;旧 API 未返回则全列「—」 |
---
## Gotchas必看
| 陷阱 | 后果 | 正确做法 |
|------|------|----------|
| 未设 `SOUL_API_BASE`,依赖 `scripts/test/config.py` 解析到正式域名 | **误测生产** | 显式与用户确认或读 `soul-admin/.env.development`,本地测必须指向本地端口 |
| 把 JWT 写进会议纪要/需求文档 | **账号泄露** | 仅口头/私聊粘贴;复盘里写「已用临时 token 测过」不写原文 |
| 脚本失败就认为一定是小程序 Bug | **误判** | 先区分「接口未返回字段」vs「库里真的无手机号」——前者是后端/部署,后者才可能追 C 端 |
| 工作台「类型」列 join/match | **混淆** | join=**加入**ckb/joinmatch=**匹配**ckb/match留资为 lead见 `开发文档/6、后端/存客宝-工作台线索类型与中英文说明.md` |
| **获客列表「计划Key」与重推 URL 里 `apiKey` 不一致** | 界面是人物/快照兜底后的展示,重推曾误用全局 `CKB_LEAD_API_KEY`报「无效的apiKey」 | 后端已统一:`resolveLeadPlanAPIKeyForRetry` 同时用于 `mode=contact` 的 `planApiKey` 与 `POST .../ckb-leads/retry` 内部推送;验收时对比**同一条 id** 下列 key 与(临时)服务端日志/抓包是否同源 |
| 控制台中文乱码 | 难以读输出 | Windows 可先 `chcp 65001` 或设 `PYTHONIOENCODING=utf-8` |
---
## 与其它 Skill 的关系
- 通用测试原则、环境横幅:`.cursor/skills/testing/SKILL.md`
- 本 Skill 专治 **JWT + submitted 获客列表契约**;扩展更多 CKB 用例可走 `scripts/test/process/` pytest。
---
## 会话收尾
若项目要求卡若复盘块:本 Skill **不豁免**每条助手回复末尾的复盘格式;测试结果写入复盘「过程/总结」即可。

View File

@@ -0,0 +1,112 @@
---
name: feishu-custom-bot-webhook
description: 飞书(及 Lark 国际版)群自定义机器人 Webhook 对接。与 Soul 订单/获客推送同契约;迁移到其他项目时复用 JSON 格式与分支规则。Use when 飞书 webhook、自定义机器人、群通知、Lark bot、从 Soul 迁移通知.
---
# 飞书自定义机器人 Webhook可迁移 Skill
本 Skill 描述 **HTTP 自定义机器人** 向飞书群发消息的契约与落地方式。Soul 后端在「非企业微信 URL」时统一走 **飞书/Lark 文本消息** 格式;复制到其他语言或项目时,只需保留 **URL 判别 + JSON 结构 + POST application/json**
**与 Open API 的区别**:自定义机器人只需 Webhook 地址与固定 JSON**不需要** `app_id`/`app_secret`/tenant token。若你要调飞书开放平台 API发卡片、@人、多维表格),不在本 Skill 范围。
---
## 1. 消息体契约(文本)
### 1.1 飞书 / Lark 自定义机器人
- **Content-Type**`application/json`
- **结构**
```json
{
"msg_type": "text",
"content": {
"text": "第一行\n第二行"
}
}
```
- **Webhook URL 特征**:常见为 `https://open.feishu.cn/open-apis/bot/v2/hook/...``https://open.larksuite.com/open-apis/bot/v2/hook/...`(以飞书控制台生成的为准)。
### 1.2 企业微信群机器人Soul 并存)
若 URL 包含 `qyapi.weixin.qq.com`Soul 使用 **企业微信** 格式(字段名不同):
```json
{
"msgtype": "text",
"text": {
"content": "第一行\n第二行"
}
}
```
**迁移时的分支规则**:用 URL 是否包含 `qyapi.weixin.qq.com` 区分两套 payload其余一律按飞书格式发送与 Soul `order_webhook.go``ckb.go` 一致)。
---
## 2. Soul 项目中的参考实现(迁移源码锚点)
| 能力 | 文件 | 说明 |
|------|------|------|
| 支付成功订单推送 + 失败重试 | `soul-api/internal/handler/order_webhook.go` | `pushPaidOrderWebhook``RetryPendingPaidOrderWebhooks`;订单表字段 `webhook_push_*` |
| 获客线索推送 | `soul-api/internal/handler/ckb.go` | `sendLeadWebhook``loadLeadWebhookURL`1h 内同用户去重 |
| 定时补偿 | `soul-api/internal/handler/cron.go` | `CronRetryOrderWebhooks``/api/cron/retry-order-webhooks` |
| 按超级个体配置 Webhook | `soul-api/internal/handler/vip_members_admin.go` | `DBVipMemberWebhookSet`;配置键 `super_individual_webhook_map` |
### 2.1 配置存储(`system_config`
| `config_key` | 用途 |
|--------------|------|
| `order_paid_webhook_url` | 支付成功推送主地址JSON 字符串存 URL |
| `ckb_lead_webhook_url` | 全局获客 Webhook订单推送无主地址时的 fallback |
| `super_individual_webhook_map` | `userId → webhookUrl` 的 JSON 对象,获客按对接人路由 |
### 2.2 小程序配置 JSON上麦打赏等
- 路径:`system_config``mp_config``mpUi.homePage.liveMicSchedule.paidWebhookUrl`
- 仅当订单类型为 `link_karuo_tip``product_id` 形如 `live_mic|...` 时,**优先**使用该地址(见 `loadLiveMicTipPaidWebhookURL`)。
---
## 3. 迁移到其他项目的检查清单
1. **在飞书群添加自定义机器人**,复制 Webhook URL仅保存在服务端配置或密钥管理**不要**写进前端或公开仓库。
2. **实现分支**`qyapi` → 企微 JSON否则 → 飞书 `msg_type` + `content.text`
3. **HTTP**`POST``Content-Type: application/json`body 为 UTF-8 JSON检查响应状态码Soul 对 `>=400` 视为失败)。
4. **可靠性(可选)**:对关键业务(如支付通知)做「失败落库 + 定时重试」,避免仅 `go` 异步发送不感知失败。
5. **限流与重复**:飞书对机器人有频率限制;获客类通知可按「用户 + 业务键」做短时间去重Soul 为内存缓存 1h进程重启会清空——迁移时可改为 Redis
---
## 4. 最小验证curl
`YOUR_HOOK_URL` 换成真实 Webhook勿提交到 git
```bash
curl -sS -X POST "YOUR_HOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"msg_type\":\"text\",\"content\":{\"text\":\"迁移验证 $(date +%Y-%m-%d\\ %H:%M)\"}}"
```
企微机器人请改用 `msgtype` / `text.content` 字段。
---
## 5. Gotchas踩坑
| 陷阱 | 后果 | 正确做法 |
|------|------|----------|
| 把企微的 `msgtype` 发到飞书 | 飞书返回错误,群无消息 | 严格按 URL 分支选 JSON |
| 把飞书的 `msg_type` 发到企微 | 同上 | 同上 |
| 误以为要飞书 Open API 凭证 | 过度设计 | 自定义机器人只需 Webhook |
| 日志打印完整 Webhook URL | 泄露群推送密钥 | 只打域名或脱敏 |
| 正文过长 | 可能被截断或拒收 | 控制单行长度;必要时分多条或改用卡片(需查飞书卡片文档,格式不同) |
| 仅依赖异步 `go func` 且无重试 | 支付通知丢失 | 关键路径参考 Soul`markOrderWebhookResult` + cron 补偿 |
---
## 6. 与 `karuo-party` Skill 的关系
`karuo-party` 侧重运营侧(报表、飞书**云文档/视频**下载等,多依赖 Token。本 Skill 仅覆盖 **群机器人 Webhook 文本推送**,与 Soul 订单/获客逻辑对齐,便于抽到任意后端项目。

View File

@@ -47,6 +47,7 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
- **支付**:下单/查单走 `POST/GET /api/miniprogram/pay`、回调由 soul-api 处理;支付前必须已有 openId通过登录或 getOpenId 获得)。
- **提现**:申请/记录/确认等走 `/api/miniprogram/withdraw/*`;订阅消息模板 ID 在 `app.globalData.withdrawSubscribeTmplId`
- **scene 编解码**:海报与扫码统一用 `utils/scene.js``buildScene`/`parseScene`,支持 mid、id、ref分隔符与后端生成一致`_`)。
- **超级个体 AI 写文章**:数据落在 soul-api 表 `super_articles`,接口为 `/api/miniprogram/super/articles`(生成/发布/列表/详情),页面 `super-article-editor``super-article-detail`**勿与书籍/章节阅读流混为同一数据模型或接口**。详见 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md)。
---
@@ -114,5 +115,6 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
- 做个人中心、设置页布局时(遵循 §7卡片区边距 16rpx
- 做阅读、文章等需长按复制的文本时(遵循 §9text 加 user-select
- 做编辑资料页分享名片时(遵循 §10
- 做超级个体 **AI 写文章**`super-article-editor` / `super-article-detail` 时(遵循 §4 最后一条与 [super-individual-article/SKILL.md](../super-individual-article/SKILL.md))。
遵循本 Skill 可保证小程序只与 soul-api 的 miniprogram 路由组对接,避免与管理端或 next-project 接口混用。

View File

@@ -1,6 +1,6 @@
---
name: soul-role-workflow
description: Soul 创业派对开发协同流程。小程序开发工程师、管理端开发工程师、后端开发职责与协同。跨端功能开发、新增/优化时使用。Use when 跨端协同, 角色流程, 功能开发协作.
description: Soul 创业派对开发协同流程。小程序/管理端/后端职责、垂直切片与追踪弹、智能体协作提要。跨端功能开发、新增/优化、AI 多角色执行时使用。Use when 跨端协同, 角色流程, 功能开发协作, 智能体编程思维.
---
# Soul 创业派对 - 角色流程控制 Skill
@@ -27,6 +27,30 @@ description: Soul 创业派对开发协同流程。小程序开发工程师、
- 小程序:是否按接口/配置渲染而非硬编码。
- 默认交付口径:**可配置、可运营、可联调、可验收**,否则视为未完成。
### 1.2 从小程序逆推闭环(文档 / 需求同步)
评审或收尾时优先从 **C 端页面** 逆推,避免「只有样式、无接口/无配置」被误判为完成:
1. **用户路径**:入口、前置条件(登录 / VIP / 资料)、失败提示是否可验收。
2. **数据来源**:写死常量、`app.request`、本地缓存;若无后端/配置真源,须在 `开发文档/1、需求/需求汇总.md` 或当日项目索引标注「无接口/待配置」。
3. **路由与契约**:仅调 `/api/miniprogram/*`;跨端字段变更须同步 **后端契约 + 管理端(若需运营配置)+ 需求表一行**
4. **配置化判断**:运营是否可能在线变更、是否涉款/风控/合规;需要运行时灰度/熔断的采用 **Feature Toggle** 思路并规划退役,避免永久 `if(flag)`
5. **回归影响**支付、scene、分享 singlePage、邻页 `onShow`;测试以 **文档与索引为真源**,契约未入库可标阻塞。
详见:`agent/团队/evolution/2026-04-14.md`,需求清单:`开发文档/1、需求/需求汇总.md`
### 1.3 智能体协作:联网沉淀的编程思维(提要)
> 外部资料作**思想库**;执行仍须遵守本仓库 **boundary** 与 **miniprogram / admin / db** 路由隔离。
1. **垂直切片vertical slice**:以「可演示的用户价值」为单位打通 UI→API→持久化/配置,避免长期在单一层级堆半成品(敏捷实践中对 vertical slicing 的共识:更快用事实替代猜测)。
2. **追踪弹tracer bullets《The Pragmatic Programmer》**:先落地**薄而端到端**、可进主分支的骨架路径,用真实反馈校准架构;与「用完即扔」的原型不同,追踪弹是后续扩展的基座。用于约束多智能体/大模型:**小步打通再扩面**,控制一次性大 diff业界亦用该比喻讨论 AI 生成代码的验证节奏,如 aihero.dev 对 tracer bullets 与 AI slop 的论述)。
3. **先验证再堆量verify before build**:上下文与约束写清 → 分块实现 → **测试或最小复现** 再合并;可用 AI 辅助生成边界/对抗用例人做最终把关多篇「AI coding best practices」强调 context-first 与系统化验证)。
4. **无责复盘blameless postmortem**:故障与重大返工后,复盘聚焦**系统为何允许失败**、时间线与可验证改进行动,而非个人追责(参见 Google *Site Reliability Engineering*Postmortem Culture — `https://sre.google/sre-book/postmortem-culture/`)。
5. **简单性**:优先可读、可预测实现;临时开关/实验分支需**退役计划**,避免永久 `if(flag)` 分叉。
团队详述与引用:`agent/团队/evolution/2026-04-15.md`
---
## 2. 协同流程总览(线框图)

View File

@@ -0,0 +1,86 @@
---
name: soul-super-individual-article
description: 超级个体 AI 写文章。super_articles 表、/api/miniprogram/super/articles、super-article-editor、与书籍/阅读及 MBTI soul_articles 不混用。Use when 超级个体, AI 写文章, super-article, super_articles, 超级个体发文.
---
# Soul 创业派对 - 超级个体 AI 写文章 Skill
**soul-api**、**miniprogram** 中编辑「超级个体发布文章」「AI 一键生成文章草稿」或 **`super_articles` 表**时必读。与 [soul-miniprogram-dev](../miniprogram-dev/SKILL.md)、[soul-api-dev](../api-dev/SKILL.md) 配合使用。
---
## 1. 产品边界
- **谁能用**`handler` 中通过 `miniprogramSuperIndividualPerson` 判断——**有效 VIP 且**在 `persons` 表已绑定人物(`persons.user_id`)视为超级个体,方可 **AI 生成、发布、列表/详情**(与客资/轨迹逻辑同源,见 `soul-api/internal/handler/miniprogram_my_super_crm.go`)。
- **谁能看**:列表/详情仅对「作者本身是超级个体」的稿件可见;非超级个体用户发布的记录不应存在(发布接口已拦截)。
---
## 2. 数据层:独立表,禁止与书籍/阅读混用
| 表名 | 用途 |
|------|------|
| **`super_articles`** | 超级个体在小程序中发布的 UGC 文章(含 AI 辅助生成的标题/正文落库后同等存储) |
| 书籍/章节/ stitch 内容 | `read` 页、book/chapter 接口,**不写入** `super_articles` |
- **Model**[soul-api/internal/model/super_article.go](soul-api/internal/model/super_article.go)(字段:`id`, `user_id`, `title`, `content`, `created_at`, `updated_at`)。
- **AutoMigrate**[soul-api/internal/database/database.go](soul-api/internal/database/database.go) 已注册 `SuperArticle`
- **禁止**:在章节列表、全站搜索、阅读进度等查询中把 `super_articles` 与书籍章节**合并**为同一套「文章」列表,除非产品明确要做一个「统一 Feed」当前未做则在代码与接口层保持隔离
### 2.1 与 MBTI_wang 的 `soul_articles` 区别(不要合并设计)
| 维度 | MBTI_wang子项目 / PHP | Soul 主项目(本功能) |
|------|---------------------------|------------------------|
| 表/服务 | `soul_articles` + `SoulArticleService`Soul 侧同步/推荐位 | **`super_articles`**Go仅超级个体 UGC + OpenAI 生成 |
| 接口 | `/api/ai/articles/*`(如 recommended | **`/api/miniprogram/super/articles*`** |
| 用途 | 神仙 AI 引流、运营选品 | 个人发文、名片/会员详情跳转 |
两栈**不要**在 soul-api 里把 `soul_articles``super_articles` 混为一张表或一个 handler。
---
## 3. API 清单soul-api仅 miniprogram 组)
路由注册见 [soul-api/internal/router/router.go](soul-api/internal/router/router.go)`miniprogram` 组)。
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/miniprogram/super/articles/generate` | 根据描述 + 可选图片 URL 调用大模型,返回 `title`/`content` 草稿;需配置 OpenAI 兼容的 Key/模型 |
| POST | `/api/miniprogram/super/articles` | 发布body`userId`, `title`, `content`;失败时可能带 `forbidden: true` |
| GET | `/api/miniprogram/super/articles?authorUserId=&page=&pageSize=` | 作者为超级个体时返回 ta 的文章列表(含 preview |
| GET | `/api/miniprogram/super/articles/:id` | 详情(作者须为超级个体,否则不可见) |
- **H5 分享页**(非 miniprogram 前缀):`GET /s/:id`,与小程序详情页展示需一致,见 [h5_super_article.go](soul-api/internal/handler/h5_super_article.go)。
---
## 4. 小程序miniprogram
| 页面/逻辑 | 路径 |
|-----------|------|
| AI 写文 + 发布 | [miniprogram/pages/super-article-editor/](miniprogram/pages/super-article-editor/):生成 `POST .../super/articles/generate`,发布 `POST .../super/articles`;图片走 `wx.uploadFile``/api/upload``formData.folder = article-images` |
| 详情 | [miniprogram/pages/super-article-detail/](miniprogram/pages/super-article-detail/) |
| 入口 | [miniprogram/pages/my/my.js](miniprogram/pages/my/my.js) 中 `superArticle``showSuperArticleEntry`;进编辑器前会调 `GET /api/miniprogram/my/super-stats` 校验 `isSuperIndividual` |
- 所有请求**必须**走 `app.request`**`/api/miniprogram/...` 前缀**(与 miniprogram-dev 一致)。
---
## 5. 管理端soul-admin
- **当前**:无针对 `super_articles` 的独立超管「文章 CRUD」列表为常态能力时**不要**为发文功能误把接口只挂在 `admin/db` 而小程序又未挂 miniprogram 组。
- 若以后增加运营审核/下架,应新增 **admin/db 专用接口 + 表字段**(如 `status`),仍保持与 book 内容表分离。
---
## 6. 变更时联调
- 改 handler、model、小程序任一端时**[change-checklist](../change-checklist/SKILL.md)**:涉及 `super_articles``super-article-*` 时,确认未误改 **book/章节/阅读** 相关接口与列表逻辑。
- 若新增字段:同步 **model、迁移脚本、miniprogram 展示/提交、H5 页** 是否需要一起改。
---
## 7. 何时激活本 Skill
- 改「超级个体」「AI 写文章」「super-article-editor」「super-article-detail」或 `super_articles`
- 需区分 **本书章节**、**MBTI 引流文** 与 **超级个体发文** 时。
- 设计草稿/审核/定时发等新能力前,先读本 Skill §2 与 §5避免与现有表职责混淆。

View File

@@ -68,6 +68,12 @@ description: Soul 创业派对测试人员。功能测试、回归测试、三
---
## 5.1 专题JWT 驱动 · 存客宝 submitted 获客列表契约
用户**粘贴管理端 JWT**并要求验证「加入/匹配提交」数据来源时:**必读** `.cursor/skills/ckb-leads-api-test/SKILL.md`,并执行脚本 `scripts/test/soul_api/test_ckb_leads_submitted.py``ADMIN_TOKEN` + `SOUL_API_BASE`)。
---
## 6. 产出与协同
| 产出 | 说明 |

View File

@@ -11,10 +11,12 @@ const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
// 线上正式 API真机/体验版/正式版)。永平仓库默认正式域;部署前核对见 .cursor/skills/security-server-ops/SKILL.md
const API_BASE_URL_PROD = 'https://soulapi.quwanzhi.com'
// const API_BASE_URL_PROD = 'https://soulapi.quwanzhi.com'
const API_BASE_URL_PROD = 'http://127.0.0.1:9100'
// 开发者工具默认与正式一致;若需连测服可改为 https://souldev.quwanzhi.com 或本机 http://127.0.0.1:9100
const API_BASE_URL_DEV = 'https://soulapi.quwanzhi.com'
// const API_BASE_URL_DEV = 'https://soulapi.quwanzhi.com'
const API_BASE_URL_DEV ='http://127.0.0.1:9100'
// 运行时自动判断:非开发工具(真机/体验版/正式版)强制用 PROD避免 localhost 打进上传包导致白屏
const _isDevTools = (typeof __wxConfig !== 'undefined' && __wxConfig.platform === 'devtools')
@@ -33,6 +35,7 @@ App({
appDisplayVersion: APP_DISPLAY_VERSION,
// API仅使用代码常量手动切换不再使用运行时开关
// baseUrl: API_BASE_URL,
baseUrl: API_BASE_URL,
// 小程序配置 - 真实AppID
appId: DEFAULT_APP_ID,
@@ -1026,8 +1029,11 @@ App({
},
success: (res) => {
const data = res.data
const rejectWithBody = (message) => {
reject(new Error(message))
/** 业务失败时挂 err.response供页面读取 needBindWechat / errorCode 等(与 miniprogram-dev Skill 一致) */
const rejectWithBody = (message, body) => {
const err = new Error(message)
if (body && typeof body === 'object') err.response = body
reject(err)
}
if (res.statusCode === 200) {
if (data && data.success === false) {
@@ -1036,7 +1042,7 @@ App({
this.logout()
}
showError(msg)
rejectWithBody(msg)
rejectWithBody(msg, data)
return
}
resolve(data)
@@ -1050,7 +1056,7 @@ App({
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
rejectWithBody(msg)
rejectWithBody(msg, data && typeof data === 'object' ? data : undefined)
},
fail: (err) => {
const msg = (err && err.errMsg)
@@ -1090,8 +1096,8 @@ App({
}
const promise = this._requestOnce(url, options, silent).catch((err) => {
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw new Error(msg)
if (err && err.message) throw err
throw new Error('网络异常,请重试')
})
if (method === 'GET') {

View File

@@ -7,6 +7,7 @@
"pages/index/index",
"pages/chapters/chapters",
"pages/match/match",
"pages/super-moments/super-moments",
"pages/my/my",
"pages/read/read",
"pages/link-preview/link-preview",
@@ -22,6 +23,9 @@
"pages/withdraw-records/withdraw-records",
"pages/wallet/wallet",
"pages/super-crm/super-crm",
"pages/super-article-editor/super-article-editor",
"pages/super-article-detail/super-article-detail",
"pages/super-article-mine/super-article-mine",
"pages/vip/vip",
"pages/member-detail/member-detail",
"pages/mentors/mentors",
@@ -62,6 +66,10 @@
"pagePath": "pages/match/match",
"text": "找伙伴"
},
{
"pagePath": "pages/super-moments/super-moments",
"text": "动态"
},
{
"pagePath": "pages/my/my",
"text": "我的"

View File

@@ -1,5 +1,11 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 8l4 4-4 4" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="q5" x1="2" y1="3" x2="22" y2="21" gradientUnits="userSpaceOnUse">
<stop stop-color="#5EEAD4"/>
<stop offset="1" stop-color="#14B8A6"/>
</linearGradient>
</defs>
<path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H12v20H6.5A2.5 2.5 0 0 1 4 19.5v-15z" fill="url(#q5)" fill-opacity="0.35" stroke="url(#q5)" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M12 4.5A2.5 2.5 0 0 1 14.5 2H20v14a2 2 0 0 1-2 2h-6V4.5z" fill="url(#q5)" fill-opacity="0.95" stroke="url(#q5)" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M15 9l2.5 2.5L15 14" stroke="#0D1F1C" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" opacity="0.5"/>
</svg>

Before

Width:  |  Height:  |  Size: 463 B

After

Width:  |  Height:  |  Size: 765 B

View File

@@ -1,8 +1,10 @@
<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"/>
<defs>
<linearGradient id="q1" x1="4" y1="3" x2="20" y2="21" gradientUnits="userSpaceOnUse">
<stop stop-color="#5EEAD4"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
<path d="M7 3h10a2 2 0 0 1 2 2v15l-2.5-1.5L14 20l-2.5-1.5L9 20l-2.5-1.5L4 20V5a2 2 0 0 1 2-2z" fill="url(#q1)" fill-opacity="0.92"/>
<path d="M8 8h8M8 12h6M8 16h5" stroke="#0D1F1C" stroke-width="1.5" stroke-linecap="round" opacity="0.45"/>
</svg>

Before

Width:  |  Height:  |  Size: 814 B

After

Width:  |  Height:  |  Size: 536 B

View File

@@ -1,7 +1,12 @@
<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"/>
<defs>
<linearGradient id="q2" x1="3" y1="4" x2="21" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#5EEAD4"/>
<stop offset="1" stop-color="#14B8A6"/>
</linearGradient>
</defs>
<circle cx="18" cy="5" r="3" fill="url(#q2)"/>
<circle cx="6" cy="12" r="3" fill="url(#q2)" fill-opacity="0.88"/>
<circle cx="18" cy="19" r="3" fill="url(#q2)" fill-opacity="0.78"/>
<path d="M8.6 10.5L15.4 6.5M15.4 17.5L8.6 13.5" stroke="url(#q2)" stroke-width="2.15" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 690 B

After

Width:  |  Height:  |  Size: 593 B

View File

@@ -1,6 +1,12 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="7" r="4" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75" stroke="#4FD1C5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<defs>
<linearGradient id="q4" x1="2" y1="3" x2="22" y2="21" gradientUnits="userSpaceOnUse">
<stop stop-color="#5EEAD4"/>
<stop offset="1" stop-color="#2DD4BF"/>
</linearGradient>
</defs>
<circle cx="9" cy="7" r="3.8" fill="url(#q4)"/>
<path d="M2 21v-1.8a4.5 4.5 0 0 1 4.5-4.5h4A4.5 4.5 0 0 1 15 19.2V21H2z" fill="url(#q4)" fill-opacity="0.9"/>
<circle cx="17.5" cy="6.5" r="2.6" fill="url(#q4)" fill-opacity="0.75"/>
<path d="M22 21v-1.3a3.6 3.6 0 0 0-2.6-3.45" stroke="url(#q4)" stroke-width="1.85" stroke-linecap="round" fill="none" opacity="0.9"/>
</svg>

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 665 B

View File

@@ -1,4 +1,11 @@
<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"/>
<defs>
<linearGradient id="q3" x1="2" y1="5" x2="22" y2="19" gradientUnits="userSpaceOnUse">
<stop stop-color="#5EEAD4"/>
<stop offset="1" stop-color="#0D9488"/>
</linearGradient>
</defs>
<path d="M3 10V8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v2" stroke="url(#q3)" stroke-width="1.65" stroke-linecap="round" fill="url(#q3)" fill-opacity="0.2"/>
<rect x="2.25" y="9.75" width="19.5" height="11.5" rx="2.75" fill="url(#q3)" fill-opacity="0.95" stroke="url(#q3)" stroke-width="1.25"/>
<rect x="14.5" y="12.8" width="5.2" height="3.6" rx="0.85" fill="#0D1F1C" opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 419 B

After

Width:  |  Height:  |  Size: 675 B

View File

@@ -5,17 +5,7 @@
<view class="login-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<text class="login-title">登录 卡若创业派对</text>
<text class="login-desc">{{desc}}</text>
<button id="agree-phone-btn" class="btn-login {{agreeProtocol ? '' : 'btn-login-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}" hover-class="btn-login-hover">
<text class="btn-login-icon">📱</text>
<text>{{isLoggingIn ? '登录中...' : '手机号快捷登录'}}</text>
</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy" hover-class="privacy-agree-btn-hover">同意</button>
</view>
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
<view class="login-agree-row" catchtap="onToggleAgree">
<view class="agree-checkbox {{agreeProtocol ? 'agree-checked' : ''}}"><icon wx:if="{{agreeProtocol}}" name="check" size="24" color="#34C759"></icon></view>
<text class="agree-text">我已阅读并同意</text>
@@ -23,5 +13,16 @@
<text class="agree-text">和</text>
<text class="agree-link" catchtap="onOpenPrivacy">《隐私政策》</text>
</view>
<button id="agree-phone-btn" class="btn-login {{agreeProtocol ? '' : 'btn-login-disabled'}}" open-type="getPhoneNumber|agreePrivacyAuthorization" bindgetphonenumber="onPhoneLogin" bindagreeprivacyauthorization="onAgreePrivacy" disabled="{{isLoggingIn || !agreeProtocol}}" hover-class="btn-login-hover">
<icon class="btn-login-icon" name="smartphone" size="36" color="#ffffff"></icon>
<text>{{isLoggingIn ? '登录中...' : '手机号快捷登录'}}</text>
</button>
<view class="privacy-wechat-row" wx:if="{{showPrivacyModal}}">
<text class="privacy-wechat-desc">为获取手机号,请先同意《用户隐私保护指引》</text>
<button id="agree-privacy-btn" class="privacy-agree-btn" open-type="agreePrivacyAuthorization" bindagreeprivacyauthorization="onAgreePrivacy" hover-class="privacy-agree-btn-hover">同意</button>
</view>
<view class="login-modal-cancel" wx:if="{{showCancel}}" bindtap="onClose">取消</view>
</view>
</view>

View File

@@ -13,8 +13,9 @@
padding: 48rpx;
}
.modal-content {
position: relative;
width: 100%;
max-width: 600rpx;
max-width: 560rpx;
background: #1c1c1e;
border-radius: 32rpx;
overflow: hidden;
@@ -25,6 +26,10 @@
right: 24rpx;
z-index: 1;
padding: 16rpx;
margin: 0;
background: transparent;
border: none;
line-height: 0;
}
.login-modal {
padding: 48rpx 32rpx;
@@ -41,18 +46,30 @@
color: #ffffff;
display: block;
margin-bottom: 16rpx;
text-shadow: none;
-webkit-text-fill-color: #ffffff;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
display: block;
margin-bottom: 48rpx;
margin-bottom: 32rpx;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-login {
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
width: 100%;
box-sizing: border-box;
padding: 28rpx;
background: linear-gradient(135deg, #00CED1, #00B4D8);
color: #ffffff;
@@ -61,19 +78,31 @@
border-radius: 24rpx;
margin-bottom: 20rpx;
border: none;
line-height: 1.2;
}
.btn-login::after { border: none; }
.btn-login-icon {
font-size: 36rpx;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-login-disabled { opacity: 0.55; }
.btn-login[disabled] {
opacity: 0.55;
}
.btn-login-disabled { opacity: 0.6; }
.login-modal-cancel {
margin-top: 24rpx;
padding: 24rpx;
display: block;
width: 100%;
margin-top: 8rpx;
padding: 20rpx 24rpx 8rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.5);
text-align: center;
background: transparent;
border: none;
box-sizing: border-box;
cursor: pointer;
}
.privacy-wechat-row {
margin: 24rpx 0;
@@ -100,11 +129,16 @@
.login-agree-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
margin-top: 32rpx;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
box-sizing: border-box;
margin-top: 0;
margin-bottom: 32rpx;
padding: 0 8rpx;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
text-align: left;
}
.agree-checkbox {
width: 32rpx;
@@ -127,6 +161,11 @@
color: #00CED1;
text-decoration: underline;
padding: 0 4rpx;
margin: 0;
background: transparent;
border: none;
font-size: inherit;
line-height: inherit;
}
/* 显式 hover 类名,避免基础库 3.x 报 hoverClass / hoverClassDisable 类型非法 */

View File

@@ -1,157 +1,181 @@
/**
* Soul创业实验 - 自定义TabBar组件
* 根据后台配置动态显示/隐藏"找伙伴"按钮
* Soul创业实验 - 自定义 TabBar
* 支持:首页 / 目录 / [找伙伴] / [动态,审核模式隐藏] / 我的
*/
console.log('[TabBar] ===== 组件文件开始加载 =====')
const app = getApp()
console.log('[TabBar] App 对象:', app)
Component({
data: {
selected: 0,
color: '#8e8e93',
selectedColor: '#00CED1',
matchEnabled: false, // 找伙伴功能开关,默认关闭
list: [
matchEnabled: false,
tabList: [
{
pagePath: '/pages/index/index',
text: '首页',
iconType: 'home'
iconPath: '/assets/icons/home.svg',
isSpecial: false,
},
{
pagePath: '/pages/chapters/chapters',
text: '目录',
iconType: 'list'
iconPath: '/assets/icons/list.svg',
isSpecial: false,
},
{
pagePath: '/pages/match/match',
text: '找伙伴',
iconType: 'match',
isSpecial: true
pagePath: '/pages/super-moments/super-moments',
text: '动态',
iconPath: '/assets/icons/sparkles.svg',
isSpecial: false,
},
{
pagePath: '/pages/my/my',
text: '我的',
iconType: 'user'
}
]
iconPath: '/assets/icons/user.svg',
isSpecial: false,
},
],
},
lifetimes: {
attached() {
console.log('[TabBar] Component attached 生命周期触发')
this.loadFeatureConfig()
},
ready() {
console.log('[TabBar] Component ready 生命周期触发')
// 如果 attached 中没有成功加载,在 ready 中再次尝试
if (this.data.matchEnabled === undefined || this.data.matchEnabled === null) {
console.log('[TabBar] 在 ready 中重新加载配置')
this.loadFeatureConfig()
}
}
},
// 页面加载时也调用(兼容性更好)
attached() {
console.log('[TabBar] attached() 方法触发')
this.loadFeatureConfig()
},
},
methods: {
// 加载功能配置
buildTabList(matchEnabled, tabUi, auditMode) {
const audit = !!auditMode
const ui = tabUi || {}
const h = ui.home ? String(ui.home) : '首页'
const ch = ui.chapters ? String(ui.chapters) : '目录'
const ma = ui.match ? String(ui.match) : '找伙伴'
const mo = ui.moments ? String(ui.moments) : ui.feed ? String(ui.feed) : '动态'
const my = ui.my ? String(ui.my) : '我的'
const tabs = [
{
pagePath: '/pages/index/index',
text: h,
iconPath: '/assets/icons/home.svg',
isSpecial: false,
},
{
pagePath: '/pages/chapters/chapters',
text: ch,
iconPath: '/assets/icons/list.svg',
isSpecial: false,
},
]
if (matchEnabled) {
tabs.push({
pagePath: '/pages/match/match',
text: ma,
iconPath: '/assets/icons/partners.svg',
isSpecial: true,
})
}
if (!audit) {
tabs.push({
pagePath: '/pages/super-moments/super-moments',
text: mo,
iconPath: '/assets/icons/sparkles.svg',
isSpecial: false,
})
}
tabs.push({
pagePath: '/pages/my/my',
text: my,
iconPath: '/assets/icons/user.svg',
isSpecial: false,
})
return tabs
},
async loadFeatureConfig() {
try {
console.log('[TabBar] 开始加载功能配置...')
console.log('[TabBar] API地址:', app.globalData.baseUrl + '/api/miniprogram/config')
const res = await app.getConfig()
// 兼容两种返回格式
let matchEnabled = false
if (res && res.success && res.features) {
console.log('[TabBar] features配置:', JSON.stringify(res.features))
matchEnabled = res.features.matchEnabled === true
console.log('[TabBar] matchEnabled值:', matchEnabled)
} else if (res && res.configs && res.configs.feature_config) {
// 备用格式:从 configs.feature_config 读取
console.log('[TabBar] 使用备用格式从configs读取')
matchEnabled = res.configs.feature_config.matchEnabled === true
console.log('[TabBar] matchEnabled值:', matchEnabled)
} else {
console.log('[TabBar] ⚠️ 未找到features配置使用默认值false')
console.log('[TabBar] res对象keys:', Object.keys(res || {}))
}
const tabUi = app.globalData.configCache?.mpConfig?.mpUi?.tabBar || {}
const list = [...this.data.list]
if (tabUi.home) list[0] = { ...list[0], text: String(tabUi.home) }
if (tabUi.chapters) list[1] = { ...list[1], text: String(tabUi.chapters) }
if (tabUi.match) list[2] = { ...list[2], text: String(tabUi.match) }
if (tabUi.my) list[3] = { ...list[3], text: String(tabUi.my) }
this.setData({ matchEnabled, list }, () => {
console.log('[TabBar] ✅ matchEnabled已设置为:', this.data.matchEnabled)
// 配置加载完成后,根据当前路由设置选中状态
let matchEnabled = false
if (res && res.success && res.features) {
matchEnabled = res.features.matchEnabled === true
} else if (res && res.configs && res.configs.feature_config) {
matchEnabled = res.configs.feature_config.matchEnabled === true
}
const tabUi = app.globalData.configCache?.mpConfig?.mpUi?.tabBar || {}
try {
await app.getAuditMode()
} catch (_) {}
const auditMode = !!app.globalData.auditMode
const tabList = this.buildTabList(matchEnabled, tabUi, auditMode)
this.setData({ matchEnabled, tabList }, () => {
this.updateSelected()
})
// 如果当前在找伙伴页面,但功能已关闭,跳转到首页
if (!matchEnabled) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/match/match') {
console.log('[TabBar] 找伙伴功能已关闭从match页面跳转到首页')
wx.switchTab({ url: '/pages/index/index' })
}
}
} catch (error) {
console.log('[TabBar] ❌ 加载功能配置失败:', error)
console.log('[TabBar] 错误详情:', error.message || error)
// 默认关闭找伙伴功能
this.setData({ matchEnabled: false }, () => {
if (auditMode) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/super-moments/super-moments') {
wx.switchTab({ url: '/pages/index/index' })
}
}
} catch (_) {
try {
await app.getAuditMode()
} catch (__) {}
const auditMode = !!app.globalData.auditMode
const tabList = this.buildTabList(false, {}, auditMode)
this.setData({ matchEnabled: false, tabList }, () => {
this.updateSelected()
})
if (auditMode) {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
if (currentPage && currentPage.route === 'pages/super-moments/super-moments') {
wx.switchTab({ url: '/pages/index/index' })
}
}
}
},
// 根据当前路由更新选中状态
updateSelected() {
const pages = getCurrentPages()
if (pages.length === 0) return
const currentPage = pages[pages.length - 1]
const route = currentPage.route
const route = pages[pages.length - 1].route
const tabList = this.data.tabList || []
let selected = 0
const { matchEnabled } = this.data
// 根据路由匹配对应的索引
if (route === 'pages/index/index') {
selected = 0
} else if (route === 'pages/chapters/chapters') {
selected = 1
} else if (route === 'pages/match/match') {
selected = 2
} else if (route === 'pages/my/my') {
selected = matchEnabled ? 3 : 2
for (let i = 0; i < tabList.length; i++) {
const p = tabList[i].pagePath.replace(/^\//, '')
if (route === p) {
selected = i
break
}
}
this.setData({ selected })
},
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path
const index = data.index
const url = e.currentTarget.dataset.path
const index = Number(e.currentTarget.dataset.index)
if (Number.isNaN(index)) return
if (this.data.selected === index) return
wx.switchTab({ url })
}
}
},
},
})

View File

@@ -1,47 +1,36 @@
<!--custom-tab-bar/index.wxml-->
<view class="tab-bar {{matchEnabled ? 'tab-bar-four' : 'tab-bar-three'}}">
<view class="tab-bar" wx:if="{{tabList.length}}">
<view class="tab-bar-border"></view>
<!-- 首页 -->
<view class="tab-bar-item" data-path="{{list[0].pagePath}}" data-index="0" bindtap="switchTab">
<view class="icon-wrapper">
<image class="tab-icon {{selected === 0 ? 'icon-active' : ''}}"
src="/assets/icons/home.svg"
mode="aspectFit"
style="color: {{selected === 0 ? selectedColor : color}}"></image>
</view>
<view class="tab-bar-text" style="color: {{selected === 0 ? selectedColor : color}}">{{list[0].text}}</view>
</view>
<!-- 目录 -->
<view class="tab-bar-item" data-path="{{list[1].pagePath}}" data-index="1" bindtap="switchTab">
<view class="icon-wrapper">
<image class="tab-icon {{selected === 1 ? 'icon-active' : ''}}"
src="/assets/icons/list.svg"
mode="aspectFit"
style="color: {{selected === 1 ? selectedColor : color}}"></image>
<block wx:for="{{tabList}}" wx:key="pagePath">
<view
wx:if="{{item.isSpecial}}"
class="tab-bar-item special-item"
data-path="{{item.pagePath}}"
data-index="{{index}}"
bindtap="switchTab"
>
<view class="special-button {{selected === index ? 'special-active' : ''}}">
<image class="special-icon" src="{{item.iconPath}}" mode="aspectFit"></image>
</view>
<view class="tab-bar-text special-text" style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
<view class="tab-bar-text" style="color: {{selected === 1 ? selectedColor : color}}">{{list[1].text}}</view>
</view>
<!-- 找伙伴 - 中间突出按钮(根据配置显示) -->
<view class="tab-bar-item special-item" wx:if="{{matchEnabled}}" data-path="{{list[2].pagePath}}" data-index="2" bindtap="switchTab">
<view class="special-button {{selected === 2 ? 'special-active' : ''}}">
<image class="special-icon"
src="/assets/icons/partners.svg"
mode="aspectFit"></image>
<view
wx:else
class="tab-bar-item"
data-path="{{item.pagePath}}"
data-index="{{index}}"
bindtap="switchTab"
>
<view class="icon-wrapper">
<image
class="tab-icon {{selected === index ? 'icon-active' : ''}}"
src="{{item.iconPath}}"
mode="aspectFit"
></image>
</view>
<view class="tab-bar-text" style="color: {{selected === index ? selectedColor : color}}">{{item.text}}</view>
</view>
<view class="tab-bar-text special-text" style="color: {{selected === 2 ? selectedColor : color}}">{{list[2].text}}</view>
</view>
<!-- 我的 -->
<view class="tab-bar-item" data-path="{{list[3].pagePath}}" data-index="{{matchEnabled ? 3 : 2}}" bindtap="switchTab">
<view class="icon-wrapper">
<image class="tab-icon {{(matchEnabled && selected === 3) || (!matchEnabled && selected === 2) ? 'icon-active' : ''}}"
src="/assets/icons/user.svg"
mode="aspectFit"
style="color: {{(matchEnabled && selected === 3) || (!matchEnabled && selected === 2) ? selectedColor : color}}"></image>
</view>
<view class="tab-bar-text" style="color: {{(matchEnabled && selected === 3) || (!matchEnabled && selected === 2) ? selectedColor : color}}">{{list[3].text}}</view>
</view>
</block>
</view>

View File

@@ -5,6 +5,8 @@
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
const { resolveChooseAvatarFilePath, pickLocalAvatarImagePath } = require('../../utils/util.js')
const { uploadByApi } = require('../../utils/miniprogramUpload.js')
function isPlaceholderNickname(n) {
const s = (n || '').trim()
@@ -133,34 +135,38 @@ Page({
fail: () => {},
})
},
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
onTapPickAvatar() {
trackClick('avatar_nickname', 'btn_click', '选择头像')
await this.uploadAndSaveAvatar(tempAvatarUrl)
const run = () => {
pickLocalAvatarImagePath()
.then((p) => this.uploadAndSaveAvatar(p))
.catch((err) => {
if (err && err.cancelled) return
wx.showToast({ title: (err && err.message) || '选择图片失败', icon: 'none' })
})
}
if (typeof wx.requirePrivacyAuthorize === 'function') {
wx.requirePrivacyAuthorize({
success: run,
fail: () => wx.showToast({ title: '需同意隐私指引后再换头像', icon: 'none' }),
})
} else {
run()
}
},
async uploadAndSaveAvatar(tempPath) {
async uploadAndSaveAvatar(tempUrl) {
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempPath,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const data = JSON.parse(r.data)
if (data.success) resolve(data)
else reject(new Error(data.error || '上传失败'))
} catch {
reject(new Error('解析失败'))
}
},
fail: reject,
})
})
let filePath
try {
filePath = await resolveChooseAvatarFilePath(tempUrl)
} catch (readErr) {
wx.hideLoading()
wx.showToast({ title: readErr.message || '无法读取所选图片', icon: 'none' })
return
}
const uploadRes = await uploadByApi({ filePath, folder: 'avatars' })
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl

View File

@@ -18,19 +18,18 @@
<text class="guide-desc guide-desc-sub" wx:if="{{fromNewUser}}">完成后即可继续使用小程序。</text>
</view>
<!-- 头像:点击直接弹出微信原生选择器;新用户必填 -->
<!-- 头像:相册/相机选图后上传(避免开发者工具 chooseAvatar + http://tmp 渲染报错) -->
<view class="avatar-section {{uiFocus === 'avatar' ? 'section-focus' : ''}}">
<text class="form-label form-label-row" wx:if="{{fromNewUser}}">头像<text class="required-mark">*</text></text>
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap-btn" hover-class="none" bindtap="onTapPickAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill"/>
<!-- 无头像时用不透明层遮住微信在 chooseAvatar 按钮内可能绘制的默认头像(非 data 回显) -->
<view wx:if="{{!avatar}}" class="avatar-placeholder avatar-native-mask">?</view>
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
</button>
</view>
</view>
<!-- 昵称:隐私授权由 app.js onNeedPrivacyAuthorization 走 wx.requirePrivacyAuthorize 原生流程 -->

View File

@@ -99,14 +99,13 @@
border-color: rgba(94, 234, 212, 0.55);
box-shadow: 0 0 0 2rpx rgba(94, 234, 212, 0.25);
}
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
/* 头像点击区view + chooseMedia原 chooseAvatar button */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;
padding: 0; margin: 0; background: transparent; border: none;
padding: 0; margin: 0; background: transparent;
width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible;
flex-shrink: 0;
}
.avatar-wrap-btn::after { border: none; }
.avatar-wrap {
position: relative;
width: 192rpx;
@@ -138,7 +137,7 @@
color: #5EEAD4;
background: rgba(94, 234, 212, 0.2);
}
/* 盖住 chooseAvatar 原生层默认头像,避免误以为是业务「回显」 */
/* 无头像时占位层,避免圆内误显为「已有头像」 */
.avatar-native-mask {
position: absolute;
left: 0;

View File

@@ -452,6 +452,9 @@
font-size: 20rpx;
color: rgba(255, 255, 255, 0.35);
margin-top: 4rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.part-right {

View File

@@ -41,12 +41,17 @@ Page({
wx.showToast({ title: '请输入11位手机号', icon: 'none' })
return
}
const pwd = String(password || '').trim()
if (pwd.length < 6) {
wx.showToast({ title: '密码至少6位', icon: 'none' })
return
}
this.setData({ loading: true })
try {
const res = await app.request('/api/miniprogram/dev/login-by-phone', {
method: 'POST',
data: { phone, password: password || '' }
data: { phone, password: pwd }
})
if (res.success && res.data) {

View File

@@ -14,7 +14,19 @@
.form-card { background: #1c1c1e; border-radius: 32rpx; padding: 32rpx; border: 2rpx solid rgba(0,206,209,0.2); }
.form-item { margin-bottom: 32rpx; }
.form-item:last-of-type { margin-bottom: 48rpx; }
.form-item:last-of-type { margin-bottom: 24rpx; }
.login-forgot-link {
display: block;
width: 100%;
text-align: right;
font-size: 26rpx;
color: #00ced1;
text-decoration: underline;
margin: 0 0 32rpx;
background: transparent;
border: none;
padding: 0;
}
.form-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; margin-bottom: 16rpx; }
.form-input-wrap { padding: 16rpx 24rpx; background: #1F2937; border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; }
.form-input-inner { width: 100%; font-size: 28rpx; background: transparent; color: #fff; }

View File

@@ -23,12 +23,21 @@ Page({
// 创建态
isCreateMode: false,
giftQuantity: 1,
unitPrice: 0
unitPrice: 0,
auditMode: false,
},
onLoad(options) {
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
},
async onLoad(options) {
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
try {
await app.getAuditMode()
} catch (e) {}
this.setData({ auditMode: !!app.globalData.auditMode })
if (options?.ref || options?.referralCode) {
app.handleReferralCode({ query: { ref: options.ref || options.referralCode } })
}
@@ -112,6 +121,10 @@ Page({
// 发起人支付(改造后:我帮别人付款)
async doInitiatorPay() {
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持代付支付', icon: 'none' })
return
}
if (this.data.isSinglePageMode) {
wx.showModal({
title: '朋友圈单页',

View File

@@ -114,8 +114,11 @@
<view wx:if="{{isSinglePageMode}}" class="footer-tip-single">
<text>请点击底部「前往小程序」进入完整版后再操作</text>
</view>
<view wx:elif="{{auditMode && (isCreateMode || (isInitiator && detail.action === 'pay'))}}" class="footer-tip-single">
<text>审核中,暂不支持代付支付</text>
</view>
<!-- 发起人 创建态 或 action=pay去支付 -->
<button wx:elif="{{(isCreateMode || (isInitiator && detail.action === 'pay'))}}" class="footer-btn pay-btn" bindtap="doInitiatorPay" disabled="{{paying}}">
<button wx:elif="{{!auditMode && (isCreateMode || (isInitiator && detail.action === 'pay'))}}" class="footer-btn pay-btn" bindtap="doInitiatorPay" disabled="{{paying}}">
<image class="btn-icon" src="/assets/icons/wallet.svg" mode="aspectFit"/>
<text>{{paying ? '支付中...' : (isCreateMode ? '去支付' : '立即支付')}}</text>
</button>

View File

@@ -10,11 +10,21 @@ Page({
loading: false
},
onLoad(options) {
async onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
if (options && (options.ref || options.referralCode)) {
app.handleReferralCode({ query: { ref: options.ref || options.referralCode } })
}
try {
await app.getAuditMode()
} catch (e) {}
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
setTimeout(() => {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
}, 400)
return
}
this.loadData()
},

View File

@@ -12,8 +12,18 @@ Page({
remaining: 0
},
onLoad(options) {
async onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
try {
await app.getAuditMode()
} catch (e) {}
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
setTimeout(() => {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
}, 400)
return
}
const requestSn = (options.requestSn || '').trim()
if (!requestSn) {
wx.showToast({ title: '链接无效', icon: 'none' })

View File

@@ -283,6 +283,12 @@ Page({
micDefaultGiftId: '',
/** 右上角唯一入口:由置顶超级个体决定 */
headerCornerAction: { show: false, kind: '', label: '', emoji: '', avatar: '' },
/** 文旅/落地体验链:未登录时弹一键登录,成功后继续打赏/上麦/留资 */
showLoginModal: false,
showPrivacyModal: false,
loginModalDesc: '登录后可参与打赏、上麦与链接嘉宾',
pendingAfterLogin: null,
},
onLoad(options) {
@@ -742,18 +748,67 @@ Page({
this._openHorizontalGiftModal(a.kind === 'mic' ? 'live_mic' : 'home_reward')
},
/**
* 体验环节未登录:弹公用 login-modal单页模式仍引导「前往小程序」
* @param {{ type: 'tip', tipSource: string } | { type: 'link_karuo' }} pending
*/
_showHomeLoginModal(pending, desc) {
try {
const sys = wx.getSystemInfoSync()
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
if (isSinglePage) {
wx.showModal({
title: '请前往完整小程序',
content: '当前为单页模式,登录与支付需点击底部「前往小程序」进入完整版。',
showCancel: false,
confirmText: '我知道了',
})
return
}
} catch (e) {
console.warn('[Index] 检测单页模式失败,回退为登录弹窗:', e)
}
this.setData({
pendingAfterLogin: pending,
loginModalDesc: desc || '登录后可参与打赏、上麦与链接嘉宾',
showLoginModal: true,
})
},
onLoginModalClose() {
this.setData({ showLoginModal: false, showPrivacyModal: false, pendingAfterLogin: null })
},
onLoginModalPrivacyAgree() {
this.setData({ showPrivacyModal: false })
},
onLoginModalSuccess() {
const pending = this.data.pendingAfterLogin
this.setData({ showLoginModal: false, pendingAfterLogin: null })
this.updateUserStatus()
wx.showToast({ title: '登录成功', icon: 'success' })
if (!pending) return
const run = (fn) => {
if (typeof wx.nextTick === 'function') wx.nextTick(fn)
else setTimeout(fn, 0)
}
if (pending.type === 'tip' && pending.tipSource) {
run(() => this._openHorizontalGiftModal(pending.tipSource))
} else if (pending.type === 'link_karuo') {
run(() => this.onLinkKaruo())
}
},
/** 打开横排礼物弹窗(上麦/打赏统一入口) */
_openHorizontalGiftModal(tipSource) {
if (app.globalData.auditMode) return
const label = tipSource === 'live_mic' ? '申请上麦' : '打赏支持'
trackClick('home', tipSource === 'live_mic' ? 'live_mic_request' : 'btn_click', label)
if (!app.globalData.isLoggedIn) {
wx.showModal({
title: '提示',
content: `请先登录后再${label}`,
confirmText: '登录',
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) },
})
const desc =
tipSource === 'live_mic'
? '登录后可申请上麦并完成礼遇支付'
: '登录后可打赏支持'
this._showHomeLoginModal({ type: 'tip', tipSource }, desc)
return
}
const sch = this.data.liveMicSchedule
@@ -887,6 +942,10 @@ Page({
},
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
trackClick('home', 'btn_click', '加入创业派对')
wx.navigateTo({ url: '/pages/vip/vip' })
},
@@ -895,6 +954,10 @@ Page({
const pinned = this.data.homePinnedPerson
if (!pinned || !pinned.token) return
trackClick('home', 'btn_click', '置顶@人物留资')
if (!app.globalData.isLoggedIn) {
this._showHomeLoginModal({ type: 'link_karuo' }, '登录后可向嘉宾留下联系方式')
return
}
await submitCkbLead(getApp(), {
targetUserId: pinned.token,
targetNickname: pinned.name,

View File

@@ -223,6 +223,17 @@
</view>
</view>
<!-- 体验环节一键登录(与阅读页/我的共用组件) -->
<login-modal
show="{{showLoginModal}}"
desc="{{loginModalDesc}}"
showPrivacyModal="{{showPrivacyModal}}"
showCancel="{{true}}"
bind:close="onLoginModalClose"
bind:success="onLoginModalSuccess"
bind:privacyagree="onLoginModalPrivacyAgree"
/>
<!-- 底部留白 -->
<view class="bottom-space"></view>
</view>

View File

@@ -1,5 +1,35 @@
const app = getApp()
function safeDecode(value) {
try {
return decodeURIComponent(value || '')
} catch (_) {
return String(value || '')
}
}
function normalizeHttpUrl(raw) {
let s = String(raw || '').trim()
if (!s) return ''
if (/^\/\//.test(s)) return 'https:' + s
if (!/^https?:\/\//i.test(s)) s = 'https://' + s.replace(/^\/+/, '')
return s
}
// 为外链补充移动端提示参数:目标站支持时按手机网页输出
function appendMobileHints(rawUrl) {
const url = normalizeHttpUrl(rawUrl)
if (!url) return ''
const hasQuery = url.indexOf('?') >= 0
const hasFrom = /(?:\?|&)from=/i.test(url)
const hasView = /(?:\?|&)view=/i.test(url)
const extras = []
if (!hasFrom) extras.push('from=miniprogram')
if (!hasView) extras.push('view=mobile')
if (!extras.length) return url
return url + (hasQuery ? '&' : '?') + extras.join('&')
}
Page({
data: {
url: '',
@@ -10,8 +40,9 @@ Page({
},
onLoad(options) {
const url = decodeURIComponent(options.url || '')
const title = options.title ? decodeURIComponent(options.title) : '链接预览'
const rawUrl = safeDecode(options.url || '')
const url = appendMobileHints(rawUrl)
const title = options.title ? safeDecode(options.title) : '链接预览'
this.setData({
url,
title,

View File

@@ -692,7 +692,11 @@ Page({
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
}
} catch (e) {
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
const resp = e && e.response
wx.showToast({
title: (resp && (resp.message || resp.error)) || (e && e.message) || '网络异常,请重试',
icon: 'none',
})
} finally {
this.setData({ isJoining: false })
}
@@ -717,6 +721,11 @@ Page({
// 购买匹配次数
async buyMatchCount() {
this.setData({ showUnlockModal: false })
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持购买', icon: 'none' })
return
}
try {
const openId = await app.ensurePayOpenId()

View File

@@ -17,8 +17,67 @@ const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 从「我的」登录成功后 reLaunch 回本页并自动继续链接流程 */
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
const THUMB_MAX = 3
function parseImagesField(images) {
if (images == null) return []
if (typeof images === 'string') {
const s = images.trim()
if (!s) return []
try {
const p = JSON.parse(s)
return Array.isArray(p) ? p : []
} catch (_) {
return []
}
}
return Array.isArray(images) ? images : []
}
function normalizeArticleImageSrc(raw) {
const u = String(raw || '').trim()
if (!u || u === 'undefined' || u === 'null') return ''
if (/^https?:\/\//i.test(u)) return u
if (u.startsWith('//')) return `https:${u}`
if (u.startsWith('wxfile://') || u.startsWith('cloud://')) return u
const base = String((app.globalData && app.globalData.baseUrl) || '').replace(/\/$/, '')
if (u.startsWith('/') && base) return `${base}${u}`
return u
}
function buildArticleThumbSlots(imagesField) {
const rawList = parseImagesField(imagesField)
const n = Math.min(rawList.length, THUMB_MAX)
const thumbSlots = []
for (let i = 0; i < n; i++) {
const srcNorm = normalizeArticleImageSrc(String(rawList[i] || '').trim())
const ok =
!!srcNorm &&
(/^https?:\/\//i.test(srcNorm) ||
srcNorm.startsWith('wxfile://') ||
srcNorm.startsWith('cloud://'))
thumbSlots.push({ slotIdx: i, src: ok ? srcNorm : '', loadFailed: false })
}
return {
showThumbRow: rawList.length > 0,
thumbSlots,
thumbMoreCount: rawList.length > THUMB_MAX ? rawList.length - THUMB_MAX : 0,
}
}
Page({
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false, auditMode: false },
data: {
statusBarHeight: 44,
navBarTotalPx: 88,
member: null,
loading: true,
isOwnProfile: false,
auditMode: false,
articleList: [],
articleLoading: false,
memberBizTab: 'profile',
showMemberBizTabs: false,
},
onLoad(options) {
wx.showShareMenu({ withShareTimeline: true })
@@ -33,6 +92,10 @@ Page({
if (options.id) this.loadMember(options.id)
},
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
},
/** 朋友圈单页等受限环境 */
_isSinglePageMode() {
try {
@@ -136,6 +199,8 @@ Page({
}),
loading: false
})
this._syncMemberBizTabs()
this.loadMemberArticles(String(id))
this._scheduleResumeLinkFlowIfNeeded()
return
}
@@ -148,6 +213,8 @@ Page({
const d = Array.isArray(res.data) ? res.data[0] : res.data
if (d) {
this.setData({ member: this.enrichAndFormat(d), loading: false })
this._syncMemberBizTabs()
this.loadMemberArticles(String(d.id || id))
this._scheduleResumeLinkFlowIfNeeded()
return
}
@@ -174,12 +241,15 @@ Page({
helpNeed: u.helpNeed || u.help_need,
ckbLeadToken: u.ckbLeadToken || u.ckb_lead_token,
}), loading: false })
this._syncMemberBizTabs()
this.loadMemberArticles(String(u.id || id))
this._scheduleResumeLinkFlowIfNeeded()
return
}
}
} catch (e) {}
this.setData({ loading: false })
this._syncMemberBizTabs()
this._resumeLinkAfterLoad = false
},
@@ -285,7 +355,14 @@ Page({
content: '您的免费解锁次数已用完开通VIP会员¥1980/年)可无限解锁',
confirmText: '去开通',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/vip/vip' }) }
success: (res) => {
if (!res.confirm) return
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持购买', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/vip/vip' })
}
})
return false
},
@@ -597,7 +674,117 @@ Page({
},
goToMatch() { wx.switchTab({ url: '/pages/match/match' }) },
goToVip() { wx.navigateTo({ url: '/pages/vip/vip' }) },
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/vip/vip' })
},
goToSuperArticleDetail(e) {
const id = e.currentTarget?.dataset?.id
if (!id) return
wx.navigateTo({ url: `/pages/read/read?superArticleId=${encodeURIComponent(String(id))}` })
},
/** 有档案块(职业画像/擅长/故事/互助)或动态(含加载中)时展示 Tab 区 */
_syncMemberBizTabs() {
const m = this.data.member
if (!m) {
this.setData({ showMemberBizTabs: false })
return
}
const hasCareer = !!(m.industry || m.position || m.businessScale)
const hasProfileExtra = !!(
m.skills ||
m.bestMonth ||
m.achievement ||
m.turningPoint ||
m.canHelp ||
m.needHelp
)
const profileOk = hasCareer || hasProfileExtra
const hasArticles = (this.data.articleList || []).length > 0
const loading = !!this.data.articleLoading
const show = profileOk || hasArticles || loading
if (!show) {
this.setData({ showMemberBizTabs: false })
return
}
let tab = this.data.memberBizTab || 'profile'
if (tab !== 'profile' && tab !== 'dynamics') tab = 'profile'
const dynamicsOk = hasArticles || loading
if (tab === 'profile' && !profileOk && dynamicsOk) tab = 'dynamics'
else if (tab === 'dynamics' && !dynamicsOk && profileOk) tab = 'profile'
this.setData({ showMemberBizTabs: true, memberBizTab: tab })
},
switchMemberBizTab(e) {
const tab = e.currentTarget?.dataset?.tab
if (tab !== 'dynamics' && tab !== 'profile') return
this.setData({ memberBizTab: tab })
},
onMemberArticleThumbError(e) {
const rowIdx = Number(e.currentTarget.dataset.rowidx)
const slotIdx = Number(e.currentTarget.dataset.slotidx)
if (Number.isNaN(rowIdx) || Number.isNaN(slotIdx)) return
this.setData({ [`articleList[${rowIdx}].thumbSlots[${slotIdx}].loadFailed`]: true })
},
previewMemberArticleImages(e) {
const rowIdx = Number(e.currentTarget.dataset.rowidx)
if (Number.isNaN(rowIdx)) return
const row = this.data.articleList[rowIdx]
if (!row) return
const urls = parseImagesField(row.images)
.map((u) => normalizeArticleImageSrc(String(u || '').trim()))
.filter((u) => /^https?:\/\//i.test(u))
if (!urls.length) return
const current = String(e.currentTarget.dataset.current || '').trim() || urls[0]
wx.previewImage({ urls, current })
},
async loadMemberArticles(memberId) {
if (!memberId) return
this.setData({ articleLoading: true, articleList: [] })
this._syncMemberBizTabs()
try {
const uid = app.globalData.userInfo?.id
const mine = !!(this.data.isOwnProfile && uid && String(memberId) === String(uid))
const url = mine
? `/api/miniprogram/super/articles/mine?userId=${encodeURIComponent(String(memberId))}&page=1&pageSize=10`
: `/api/miniprogram/super/articles?authorUserId=${encodeURIComponent(String(memberId))}&page=1&pageSize=10`
const res = await app.request({
url,
silent: true
})
const listRaw = Array.isArray(res?.list) ? res.list : []
const list = listRaw.map((it) => {
const st = String(it.auditStatus || 'approved').toLowerCase()
let auditLabel = ''
if (mine) {
if (st === 'pending') auditLabel = '审核中'
else if (st === 'rejected') auditLabel = '已驳回'
else auditLabel = '已发布'
}
const norm = st === 'pending' || st === 'rejected' ? st : 'approved'
const thumb = buildArticleThumbSlots(it.images)
return Object.assign({}, it, {
auditStatus: mine ? norm : '',
auditLabel,
showThumbRow: thumb.showThumbRow,
thumbSlots: thumb.thumbSlots,
thumbMoreCount: thumb.thumbMoreCount,
})
})
this.setData({ articleList: list, articleLoading: false })
this._syncMemberBizTabs()
} catch (_) {
this.setData({ articleLoading: false, articleList: [] })
this._syncMemberBizTabs()
}
},
goBack() { getApp().goBackOrToHome() },
/**

View File

@@ -110,86 +110,158 @@
</view>
</view>
<!-- 一体化信息区(单卡片内分区 -->
<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>
<!-- 档案 | 动态Tab档案含职业画像/擅长/故事/互助 -->
<view class="article-spotlight member-biz-tab-card" wx:if="{{showMemberBizTabs}}">
<view class="mdb-tab-bar">
<view
class="mdb-tab {{memberBizTab === 'profile' ? 'mdb-tab-active' : ''}}"
data-tab="profile"
bindtap="switchMemberBizTab"
>
<text>{{isOwnProfile ? '我的档案' : 'TA档案'}}</text>
</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 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
class="mdb-tab {{memberBizTab === 'dynamics' ? 'mdb-tab-active' : ''}}"
data-tab="dynamics"
bindtap="switchMemberBizTab"
>
<text>{{isOwnProfile ? '我的动态' : 'TA动态'}}</text>
<text class="mdb-tab-badge" wx:if="{{articleList.length}}">{{articleList.length}}</text>
</view>
</view>
<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="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="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="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 class="mdb-panel mdb-panel-profile" wx:if="{{memberBizTab === 'profile'}}">
<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="help-tile help-need" wx:if="{{member.needHelp}}">
<text class="help-tile-tag need">我需要</text>
<text class="help-tile-txt">{{member.needHelp}}</text>
<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 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
class="mono-divider mdb-inner-divider"
wx:if="{{(member.industry || member.position || member.businessScale) && (member.skills || member.bestMonth || member.achievement || member.turningPoint || member.canHelp || member.needHelp)}}"
></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="mono-divider mdb-inner-divider"
wx:if="{{member.skills && (member.bestMonth || member.achievement || member.turningPoint || member.canHelp || member.needHelp)}}"
></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="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="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 mdb-inner-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
wx:if="{{!(member.industry || member.position || member.businessScale) && !member.skills && !(member.bestMonth || member.achievement || member.turningPoint) && !(member.canHelp || member.needHelp)}}"
class="article-spotlight-empty mdb-panel-hint"
>
<text>暂未填写档案资料</text>
</view>
</view>
<view class="mono-divider" wx:if="{{(member.canHelp || member.needHelp) && member.project}}"></view>
<view class="mdb-panel" wx:if="{{memberBizTab === 'dynamics'}}">
<view wx:if="{{articleLoading}}" class="article-spotlight-empty mdb-panel-hint"><text>加载中…</text></view>
<view wx:elif="{{articleList.length > 0}}" class="article-spotlight-list">
<view
class="article-spotlight-item"
wx:for="{{articleList}}"
wx:for-index="rowIdx"
wx:key="id"
data-id="{{item.id}}"
bindtap="goToSuperArticleDetail"
>
<view wx:if="{{isOwnProfile && item.auditStatus}}" class="article-audit-pill article-audit-{{item.auditStatus}}">{{item.auditLabel}}</view>
<text class="article-spotlight-item-title">{{item.title}}</text>
<text class="article-spotlight-item-preview">{{item.preview || item.content}}</text>
<view class="mdb-thumb-row" wx:if="{{item.showThumbRow}}" catchtap="">
<view class="mdb-thumb-cell" wx:for="{{item.thumbSlots}}" wx:for-item="th" wx:key="slotIdx">
<block wx:if="{{th.src && !th.loadFailed}}">
<image
class="mdb-thumb-img"
mode="aspectFill"
src="{{th.src}}"
data-rowidx="{{rowIdx}}"
data-slotidx="{{index}}"
data-current="{{th.src}}"
binderror="onMemberArticleThumbError"
bindtap="previewMemberArticleImages"
/>
</block>
<view wx:else class="mdb-thumb-ph">
<text class="mdb-thumb-ph-text">{{th.loadFailed ? '加载失败' : '无效'}}</text>
</view>
</view>
<view wx:if="{{item.thumbMoreCount > 0}}" class="mdb-thumb-more">
<text>+{{item.thumbMoreCount}}</text>
</view>
</view>
</view>
</view>
<view wx:else class="article-spotlight-empty mdb-panel-hint"><text>暂无动态</text></view>
</view>
</view>
<view class="mono-sec mono-sec-tight" wx:if="{{member.project}}">
<!-- 项目介绍(单独卡片) -->
<view class="mono-card mono-card-compact" wx:if="{{member.project}}">
<view class="mono-sec mono-sec-tight">
<view class="mono-sec-head mono-sec-head-tight">
<text class="mono-sec-title">项目介绍</text>
</view>
@@ -200,7 +272,7 @@
<!-- 底部:分享 + 双入口(同一视觉块) -->
<view class="footer-panel">
<view class="footer-pills">
<view class="pill pill-gold" bindtap="goToVip">
<view class="pill pill-gold" wx:if="{{!auditMode}}" bindtap="goToVip">
<icon name="sparkles" size="30" color="#FBBF24" customClass="pill-ic"></icon>
<text class="pill-txt">成为超级个体</text>
</view>

View File

@@ -417,6 +417,188 @@
font-weight: 700;
color: #f8fafc;
}
.mono-sec-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.article-spotlight {
margin: 20rpx 24rpx 0;
padding: 22rpx 20rpx;
border-radius: 22rpx;
border: 1rpx solid rgba(34, 211, 238, 0.45);
background: linear-gradient(135deg, rgba(8, 47, 73, 0.9), rgba(15, 23, 42, 0.95));
box-shadow: 0 10rpx 28rpx rgba(6, 182, 212, 0.2);
}
.article-spotlight-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.article-spotlight-title {
font-size: 30rpx;
font-weight: 700;
color: #67e8f9;
letter-spacing: 2rpx;
}
.article-spotlight-count {
padding: 6rpx 14rpx;
border-radius: 999rpx;
font-size: 20rpx;
color: #cffafe;
border: 1rpx solid rgba(103, 232, 249, 0.45);
background: rgba(14, 116, 144, 0.35);
}
/* Tab动态 | 职业画像 */
.member-biz-tab-card {
padding-top: 18rpx;
}
.mdb-tab-bar {
display: flex;
flex-direction: row;
gap: 12rpx;
margin-bottom: 18rpx;
padding: 0 4rpx;
}
.mdb-tab {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 16rpx 12rpx;
border-radius: 16rpx;
font-size: 26rpx;
font-weight: 600;
color: rgba(203, 213, 225, 0.75);
background: rgba(2, 6, 23, 0.35);
border: 1rpx solid rgba(148, 163, 184, 0.18);
}
.mdb-tab-active {
color: #67e8f9;
border-color: rgba(103, 232, 249, 0.45);
background: rgba(14, 116, 144, 0.28);
}
.mdb-tab-badge {
padding: 4rpx 12rpx;
border-radius: 999rpx;
font-size: 20rpx;
font-weight: 600;
color: #cffafe;
border: 1rpx solid rgba(103, 232, 249, 0.35);
background: rgba(14, 116, 144, 0.35);
}
.mdb-panel-hint {
padding-top: 8rpx;
}
.mdb-inner-divider {
margin: 14rpx 4rpx;
}
.mdb-thumb-row {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 14rpx;
}
.mdb-thumb-cell {
width: 144rpx;
height: 144rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
background: rgba(15, 23, 42, 0.55);
}
.mdb-thumb-img {
width: 100%;
height: 100%;
display: block;
box-sizing: border-box;
border: 2rpx solid rgba(34, 211, 238, 0.32);
}
.mdb-thumb-ph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx dashed rgba(148, 163, 184, 0.35);
box-sizing: border-box;
}
.mdb-thumb-ph-text {
font-size: 20rpx;
color: #64748b;
}
.mdb-thumb-more {
font-size: 26rpx;
font-weight: 600;
color: rgba(34, 211, 238, 0.88);
padding: 0 12rpx;
}
.article-spotlight-list {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.article-spotlight-empty {
padding: 16rpx 14rpx 8rpx;
color: rgba(203, 213, 225, 0.8);
font-size: 22rpx;
}
.article-spotlight-item {
padding: 14rpx 14rpx;
border-radius: 14rpx;
background: rgba(2, 6, 23, 0.45);
border: 1rpx solid rgba(148, 163, 184, 0.2);
}
.article-audit-pill {
display: inline-block;
margin-bottom: 8rpx;
padding: 4rpx 14rpx;
border-radius: 999rpx;
font-size: 18rpx;
border: 1rpx solid rgba(148, 163, 184, 0.25);
color: rgba(226, 232, 240, 0.85);
}
.article-audit-pill.article-audit-approved {
color: #5eead4;
border-color: rgba(94, 234, 212, 0.35);
}
.article-audit-pill.article-audit-pending {
color: #fcd34d;
border-color: rgba(252, 211, 77, 0.35);
}
.article-audit-pill.article-audit-rejected {
color: #fca5a5;
border-color: rgba(252, 165, 165, 0.35);
}
.article-spotlight-item-title {
display: block;
color: #f8fafc;
font-size: 25rpx;
font-weight: 600;
}
.article-spotlight-item-preview {
display: block;
margin-top: 8rpx;
color: rgba(203, 213, 225, 0.86);
font-size: 21rpx;
line-height: 1.45;
}
.member-article-tag {
padding: 6rpx 14rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(94, 234, 212, 0.35);
background: rgba(45, 212, 191, 0.08);
color: #67e8f9;
font-size: 20rpx;
}
.mono-divider {
height: 1rpx;
margin: 4rpx 28rpx;
@@ -566,6 +748,34 @@
font-size: 26rpx;
line-height: 1.55;
}
.member-article-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.member-article-item {
padding: 20rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.03);
border: 1rpx solid rgba(148, 163, 184, 0.12);
}
.member-article-title {
display: block;
color: #f8fafc;
font-size: 26rpx;
font-weight: 600;
}
.member-article-preview {
display: block;
margin-top: 10rpx;
color: rgba(203, 213, 225, 0.85);
font-size: 22rpx;
line-height: 1.5;
}
.member-article-empty {
color: rgba(148, 163, 184, 0.9);
font-size: 24rpx;
}
/* —— 底栏:分享 + 双入口 —— */
.footer-panel {

View File

@@ -3,6 +3,7 @@
* 联系导师按钮 → 弹出 v2 弹窗(选择咨询项目)
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge')
Page({
data: {
@@ -14,13 +15,21 @@ Page({
selectedType: '',
selectedAmount: 0,
creating: false,
auditMode: false,
},
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
auditMode: !!app.globalData.auditMode,
})
if (options.id) this.loadDetail(options.id)
},
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
},
async loadDetail(id) {
this.setData({ loading: true })
try {
@@ -59,6 +68,10 @@ Page({
},
onContactTap() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法支付', icon: 'none' })
return
}
if (!this.data.mentor || this.data.consultOptions.length === 0) {
wx.showToast({ title: '暂无咨询项目', icon: 'none' })
return
@@ -79,7 +92,17 @@ Page({
this.setData({ selectedType: item.type, selectedAmount: item.price })
},
_consultationTypeLabel(t) {
const m = { single: '单次咨询', half_year: '半年咨询', year: '年度咨询' }
return m[t] || '咨询'
},
async onConfirmConsult() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法支付', icon: 'none' })
this.setData({ showConsultModal: false, creating: false })
return
}
const { mentor, selectedType } = this.data
const userId = app.globalData.userInfo?.id
if (!userId) {
@@ -94,22 +117,57 @@ Page({
method: 'POST',
data: { userId, consultationType: selectedType },
})
if (res?.success && res.data) {
this.setData({ showConsultModal: false, creating: false })
wx.showToast({ title: '预约创建成功', icon: 'success' })
// TODO: 调起支付 productType: mentor_consultation, productId: res.data.id
if (!res?.success || !res.data) {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
return
}
this.setData({ showConsultModal: false })
const consult = res.data
const openId = await app.ensurePayOpenId()
if (!openId) {
wx.showModal({
title: '预约成功',
content: '请联系客服完成后续对接',
title: '无法拉起支付',
content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。',
showCancel: false,
})
} else {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
return
}
wx.showLoading({ title: '正在拉起支付...', mask: true })
try {
const label = this._consultationTypeLabel(selectedType)
const payRes = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
openId,
userId,
productType: 'mentor_consultation',
productId: String(consult.id),
amount: Number(consult.amount),
description: `导师咨询 · ${(mentor && mentor.name) || '导师'}${label}`,
referralCode: soulBridge.getReferralCodeForPay(app) || undefined,
},
})
if (payRes?.success && payRes.data?.payParams) {
try {
await soulBridge.requestWxJsapiPayment(payRes.data.payParams)
await soulBridge.syncOrderStatusQuery(app, payRes.data.orderSn)
wx.showToast({ title: '支付成功', icon: 'success' })
} catch (pe) {
const msg = pe && pe.errMsg ? String(pe.errMsg) : ''
if (msg.indexOf('cancel') !== -1) wx.showToast({ title: '支付取消', icon: 'none' })
else wx.showToast({ title: '支付未完成', icon: 'none' })
}
} else {
wx.showToast({ title: payRes?.error || '获取支付参数失败', icon: 'none' })
}
} finally {
wx.hideLoading()
}
} catch (e) {
wx.showToast({ title: '创建失败', icon: 'none' })
wx.showToast({ title: (e && e.message) || '操作失败', icon: 'none' })
} finally {
this.setData({ creating: false })
}
this.setData({ creating: false })
},
goBack() {

View File

@@ -33,7 +33,7 @@
<text class="block-text">{{mentor.offering}}</text>
</view>
<view class="block" wx:if="{{mentor.priceSingle || mentor.priceHalfYear || mentor.priceYear}}">
<view class="block" wx:if="{{(mentor.priceSingle || mentor.priceHalfYear || mentor.priceYear) && !auditMode}}">
<view class="block-header"><text class="block-num">03</text><text class="block-title">收费标准</text></view>
<view class="price-table">
<view class="price-thead">
@@ -72,7 +72,7 @@
</view>
</view>
<view class="bottom-btn-area">
<view class="bottom-btn-area" wx:if="{{!auditMode}}">
<view class="contact-btn" bindtap="onContactTap">
<icon name="message-circle" size="40" color="#00CED1" customClass="contact-icon"></icon>
<text>联系导师</text>

View File

@@ -10,6 +10,11 @@ const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser.js')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { appendQueryToPath, ensureLoggedInUserPhone } = require('../../utils/linkedMiniprogramNavigate.js')
/** MBTI 测试小程序(写死;须与 app.json navigateToMiniProgramAppIdList 一致) */
const MBTI_MP_APP_ID = 'wx3d15ed02e98b04e3'
const MBTI_MP_PATH = 'pages/index/index'
Page({
data: {
@@ -86,8 +91,13 @@ Page({
// 我的余额
walletBalanceText: '--',
// 超级个体:快捷入口「客资 / 链接轨迹」
// 超级个体:快捷入口「链接轨迹」
showSuperCrmEntry: false,
// 超级个体:发动态入口
showSuperArticleEntry: false,
/** 快捷入口网格列数:与 WXML 四个 wx:if 一致≤4 时等于入口数(三项铺满)、否则为 4自动换行 */
quickEntryGridCols: 3,
// mp_config.mpUi.myPage后台可改文案/跳转)
mpUiCardLabel: '名片',
@@ -95,6 +105,8 @@ Page({
mpUiVipLabelGuest: '成为会员',
mpUiReadStatLabel: '已读章节',
mpUiRecentTitle: '最近阅读',
/** 资料区 MBTI 标签文案已测为四字母大写未测为「MBTI」 */
mbtiTagText: '',
},
onLoad() {
@@ -112,7 +124,7 @@ Page({
},
onShow() {
this.setData({ auditMode: app.globalData.auditMode || false })
this.setData({ auditMode: app.globalData.auditMode || false }, () => this._syncQuickEntryGridCols())
// 设置TabBar选中状态根据 matchEnabled 动态设置)
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
const tabBar = this.getTabBar()
@@ -147,6 +159,27 @@ Page({
})
},
/** 快捷入口网格:与 WXML 中各 stat-box 的 wx:if 一致≤4 列时等于入口数(三项铺满一行),否则 4 列自动换行 */
_syncQuickEntryGridCols() {
const auditMode = !!this.data.auditMode
const showSuperCrmEntry = !!this.data.showSuperCrmEntry
const showSuperArticleEntry = !!this.data.showSuperArticleEntry
let n = 1
if (!auditMode) n += 1
if (showSuperCrmEntry) n += 1
if (showSuperArticleEntry && !auditMode) n += 1
const quickEntryGridCols = n <= 4 ? n : 4
if (this.data.quickEntryGridCols !== quickEntryGridCols) {
this.setData({ quickEntryGridCols })
}
},
_formatMbtiTagText(user) {
const raw = String(user && user.mbti != null ? user.mbti : '').trim()
if (!raw) return 'MBTI'
return raw.toUpperCase()
},
async _refreshMyAvatarDisplay(safeUser) {
if (!safeUser || !app.globalData.isLoggedIn) return
try {
@@ -157,6 +190,33 @@ Page({
this.setData({ profileAvatarDisplay: url || '' })
},
async refreshUserProfileForMbti() {
const userId = app.globalData.userInfo?.id
if (!userId || !app.globalData.isLoggedIn) return
try {
const res = await app.request({
url: `/api/miniprogram/user/profile?userId=${encodeURIComponent(userId)}`,
silent: true
})
if (!res?.success || !res.data) return
const profile = res.data
const current = this.data.userInfo || app.globalData.userInfo || {}
const merged = {
...current,
...(profile.mbti != null ? { mbti: profile.mbti } : {})
}
app.globalData.userInfo = merged
try {
wx.setStorageSync('userInfo', merged)
} catch (_) {}
this.setData({
userInfo: merged,
mbtiTagText: this._formatMbtiTagText(merged)
})
this._refreshMyAvatarDisplay(merged)
} catch (_) {}
},
async loadFeatureConfig() {
try {
const res = await app.getConfig()
@@ -169,11 +229,11 @@ Page({
await app.getAuditMode()
const auditMode = app.globalData.auditMode || false
app.globalData.features = { matchEnabled, referralEnabled, searchEnabled }
this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode })
this.setData({ matchEnabled, referralEnabled, searchEnabled, auditMode }, () => this._syncQuickEntryGridCols())
this._applyMyMpUiLabels()
} catch (error) {
console.log('加载功能配置失败:', error)
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true })
this.setData({ matchEnabled: false, referralEnabled: true, searchEnabled: true }, () => this._syncQuickEntryGridCols())
this._applyMyMpUiLabels()
}
},
@@ -198,6 +258,7 @@ Page({
isLoggedIn: true,
userInfo: safeUser,
profileAvatarDisplay: '',
mbtiTagText: this._formatMbtiTagText(safeUser),
userIdShort,
userWechat,
readCount: 0,
@@ -211,22 +272,25 @@ Page({
readCountText: '0',
totalReadTimeText: '0',
matchHistoryText: '0'
})
}, () => this._syncQuickEntryGridCols())
this.loadDashboardStats()
this.loadMyEarnings()
this.loadPendingConfirm()
this.loadVipStatus()
this.loadSuperCrmEntry()
this.loadWalletBalance()
this.refreshUserProfileForMbti()
this._refreshMyAvatarDisplay(safeUser)
} else {
this.setData({ showSuperCrmEntry: false })
const guestReadCount = app.getReadCount()
const guestRecent = this._mergeRecentChaptersFromLocal([])
this.setData({
showSuperCrmEntry: false,
showSuperArticleEntry: false,
isLoggedIn: false,
userInfo: null,
profileAvatarDisplay: '',
mbtiTagText: '',
userIdShort: '',
readCount: guestReadCount,
readCountText: formatStatNum(guestReadCount),
@@ -239,7 +303,7 @@ Page({
matchHistory: 0,
totalReadTimeText: '0',
matchHistoryText: '0'
})
}, () => this._syncQuickEntryGridCols())
}
},
@@ -628,75 +692,6 @@ Page({
trackClick('my', 'btn_click', '资料编辑')
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1&wizard=0' })
},
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
wx.showLoading({ title: '上传中...', mask: true })
try {
// 1. 先上传图片到服务器
console.log('[My] 开始上传头像:', 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: (res) => {
try {
const data = JSON.parse(res.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('[My] 头像上传成功:', avatarUrl)
// 3. 更新本地头像
const userInfo = this.data.userInfo
userInfo.avatar = avatarUrl
this.setData({ userInfo })
this._refreshMyAvatarDisplay(userInfo)
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 4. 同步到服务器数据库(只保存路径,不含域名)
await app.request('/api/miniprogram/user/update', {
method: 'POST',
data: { userId: userInfo.id, avatar: avatarUrl }
})
wx.hideLoading()
wx.showToast({ title: '头像更新成功', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.error('[My] 上传头像失败:', e)
wx.showToast({
title: e.message || '上传失败,请重试',
icon: 'none'
})
}
},
// 微信原生获取昵称回调(针对 input type="nickname" 的 bindblur 或 bindchange
async handleNicknameChange(nickname) {
@@ -898,14 +893,21 @@ Page({
this.showLogin()
return
}
const auditBlockedIds = { giftPay: true, wallet: true, withdrawRecords: true, superArticleMine: true }
if (app.globalData.auditMode && auditBlockedIds[id]) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
const routes = {
orders: '/pages/purchases/purchases',
giftPay: '/pages/gift-pay/list',
giftPay: '/pages/purchases/purchases',
referral: '/pages/referral/referral',
withdrawRecords: '/pages/withdraw-records/withdraw-records',
wallet: '/pages/wallet/wallet',
superCrm: '/pages/super-crm/super-crm',
superArticleMine: '/pages/super-article-mine/super-article-mine',
settings: '/pages/settings/settings'
}
@@ -997,7 +999,7 @@ Page({
async loadSuperCrmEntry() {
const userId = app.globalData.userInfo?.id
if (!userId) {
this.setData({ showSuperCrmEntry: false })
this.setData({ showSuperCrmEntry: false, showSuperArticleEntry: false }, () => this._syncQuickEntryGridCols())
return
}
try {
@@ -1006,9 +1008,9 @@ Page({
silent: true
})
const show = !!(res && res.success && res.data && res.data.isSuperIndividual === true)
this.setData({ showSuperCrmEntry: show })
this.setData({ showSuperCrmEntry: show, showSuperArticleEntry: show }, () => this._syncQuickEntryGridCols())
} catch (_) {
this.setData({ showSuperCrmEntry: false })
this.setData({ showSuperCrmEntry: false, showSuperArticleEntry: false }, () => this._syncQuickEntryGridCols())
}
},
@@ -1047,6 +1049,10 @@ Page({
},
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
trackClick('my', 'btn_click', '会员中心')
if (!this.data.isLoggedIn) { this.showLogin(); return }
const p = String(this._getMyPageUi().vipPath || '').trim()
@@ -1065,6 +1071,26 @@ Page({
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` })
},
/** MBTI 测试小程序:写死 appId + pathpath 上带 phone有则传与阅读页一致 */
async onMbtiTagTap() {
trackClick('my', 'btn_click', 'MBTI标签')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const phone = await ensureLoggedInUserPhone()
let path = MBTI_MP_PATH.trim().replace(/^\/+/, '')
if (phone) path = appendQueryToPath(path, 'phone', phone)
wx.navigateToMiniProgram({
appId: MBTI_MP_APP_ID,
path,
envVersion: 'release',
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
},
goToProfileEdit() {
trackClick('my', 'nav_click', '资料编辑')
if (!this.data.isLoggedIn) { this.showLogin(); return }
@@ -1079,6 +1105,10 @@ Page({
},
async handleWithdraw() {
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持提现', icon: 'none' })
return
}
if (!this.data.isLoggedIn) { this.showLogin(); return }
const amount = parseFloat(this.data.pendingEarnings)
if (isNaN(amount) || amount <= 0) {

View File

@@ -36,6 +36,7 @@
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
<view class="profile-mbti-tag" catchtap="onMbtiTagTap">{{mbtiTagText}}</view>
</view>
</view>
<view class="profile-settings-hit" catchtap="goToProfileEdit" hover-class="profile-settings-hit-active" aria-label="编辑资料">
@@ -88,37 +89,41 @@
</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>
</view>
<view class="stats-grid {{showSuperCrmEntry ? 'stats-grid-4' : ''}}">
<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 class="stats-grid" style="grid-template-columns: repeat({{quickEntryGridCols}}, minmax(0, 1fr));">
<view class="stat-box" hover-class="stat-box-hover" bindtap="handleMenuTap" data-id="orders">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/list-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-label">订单与代付</text>
</view>
<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="handleMenuTap" data-id="wallet">
<image class="stat-icon-img" src="/assets/icons/wallet-teal.svg" mode="aspectFit"/>
<text class="stat-num">{{walletBalanceText}}</text>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{!auditMode}}" bindtap="handleMenuTap" data-id="wallet">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/wallet-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-label">我的余额</text>
</view>
<view class="stat-box" wx:if="{{showSuperCrmEntry}}" bindtap="handleMenuTap" data-id="superCrm">
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
<text class="stat-num">客资</text>
<text class="stat-label">链接与轨迹</text>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{showSuperCrmEntry}}" bindtap="handleMenuTap" data-id="superCrm">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/users-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-label">链接轨迹</text>
</view>
<view class="stat-box" hover-class="stat-box-hover" wx:if="{{showSuperArticleEntry && !auditMode}}" bindtap="handleMenuTap" data-id="superArticleMine">
<view class="stat-icon-wrap">
<image class="stat-icon-img" src="/assets/icons/list-teal.svg" mode="aspectFit"/>
</view>
<text class="stat-label">我的动态</text>
</view>
</view>
</view>
<!-- 已解锁/充值/代付等流水已迁至「我的订单」页 -->
<!-- 已解锁/充值/代付等流水在「订单与代付」统一列表 -->
<!-- 最近阅读 -->
<view class="card recent-card">

View File

@@ -105,6 +105,26 @@
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
}
.profile-action-btn:active { opacity: 0.75; }
/* MBTI高亮胶囊实心青渐变 + 白字 + 外发光),与描边按钮区分 */
.profile-mbti-tag {
padding: 12rpx 26rpx;
border-radius: 40rpx;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 0.06em;
color: #fff;
background: linear-gradient(135deg, #5eead4 0%, #4FD1C5 45%, #2dd4bf 100%);
border: 1rpx solid rgba(255, 255, 255, 0.35);
box-shadow:
0 4rpx 16rpx rgba(79, 209, 197, 0.45),
0 0 0 1rpx rgba(79, 209, 197, 0.25) inset;
white-space: nowrap;
flex-shrink: 0;
}
.profile-mbti-tag:active {
opacity: 0.88;
transform: scale(0.98);
}
.user-wechat { font-size: 26rpx; color: #6B7280; }
.super-card-entry {
position: relative;
@@ -167,6 +187,11 @@
.card-icon-img { width: 40rpx; height: 40rpx; flex-shrink: 0; }
.card-title { font-size: 32rpx; font-weight: bold; color: #fff; }
/* 快捷入口:略减卡片底边距,避免第二行下方大块留白 */
.stats-card {
padding-bottom: 24rpx;
}
/* ===== 一键收款卡片 ===== */
.receive-card { padding: 28rpx 32rpx; }
.receive-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
@@ -196,23 +221,47 @@
.earnings-val.primary { color: #4FD1C5; }
.earnings-label { display: block; font-size: 24rpx; color: #6B7280; margin-top: 8rpx; }
/* 阅读统计 - 统一高度避免真机错位 */
/* 快捷入口:列数由页面 quickEntryGridCols 绑定≤4 铺满一行;>4 时为 4 列换行) */
.stats-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 24rpx;
display: grid;
gap: 20rpx 16rpx;
align-items: stretch;
}
.stats-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
.stat-box {
background: #252525; border-radius: 20rpx; padding: 24rpx;
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 140rpx;
background: linear-gradient(155deg, rgba(42, 42, 42, 0.98) 0%, rgba(28, 35, 34, 0.99) 100%);
border-radius: 22rpx;
padding: 18rpx 12rpx 16rpx;
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
min-height: 128rpx;
border: 1rpx solid rgba(79, 209, 197, 0.22);
box-shadow:
0 6rpx 22rpx rgba(0, 0, 0, 0.35),
0 0 0 1rpx rgba(79, 209, 197, 0.06) inset,
0 0 32rpx rgba(79, 209, 197, 0.05);
}
.stat-box-hover {
opacity: 0.9;
transform: scale(0.98);
}
.stat-icon { font-size: 40rpx; margin-bottom: 8rpx; color: #4FD1C5; flex-shrink: 0; }
.stat-icon-img { width: 44rpx; height: 44rpx; margin-bottom: 8rpx; flex-shrink: 0; display: block; }
.stat-num { font-size: 36rpx; font-weight: bold; color: #fff; line-height: 1.2; }
.stat-label { font-size: 20rpx; color: #6B7280; margin-top: 4rpx; line-height: 1.2; }
.stat-icon-wrap {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 8rpx;
background: linear-gradient(145deg, rgba(79, 209, 197, 0.28) 0%, rgba(79, 209, 197, 0.06) 55%, rgba(20, 60, 55, 0.35) 100%);
border: 1rpx solid rgba(79, 209, 197, 0.38);
box-shadow:
0 0 24rpx rgba(79, 209, 197, 0.28),
0 4rpx 12rpx rgba(0, 0, 0, 0.25),
inset 0 1rpx 0 rgba(255, 255, 255, 0.12);
}
.stat-icon-img { width: 48rpx; height: 48rpx; flex-shrink: 0; display: block; }
.stat-label { font-size: 22rpx; color: #8B939E; margin-top: 4rpx; line-height: 1.25; text-align: center; }
/* 最近阅读 */
.recent-list { display: flex; flex-direction: column; gap: 24rpx; }

View File

@@ -9,10 +9,20 @@
* 表单展示:普通用户仅展示 温馨提示、头像、昵称、MBTI、地区、行业、业务体量、职位、核心联系方式VIP 展示全部
*/
const app = getApp()
const { toAvatarPath } = require('../../utils/util.js')
const { toAvatarPath, pickLocalAvatarImagePath } = require('../../utils/util.js')
const { uploadByApi } = require('../../utils/miniprogramUpload.js')
const MBTI_OPTIONS = ['INTJ', 'INFP', 'INTP', 'ENTP', 'ENFP', 'ENTJ', 'ENFJ', 'INFJ', 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', 'ISTP', 'ISFP', 'ESTP', 'ESFP']
/** 补齐小程序可用的绝对 URL本地/OSS 相对路径拼 baseUrl */
function ensureAbsMediaUrl(u, base) {
const s = String(u || '').trim()
if (!s) return ''
if (/^https?:\/\//i.test(s)) return s
const b = String(base || '').replace(/\/$/, '')
return b ? b + (s.startsWith('/') ? s : '/' + s) : s
}
/** 首次分步完善完成后写入;与手机号+昵称齐全时自动写入,老用户免向导 */
const PROFILE_WIZARD_DONE_KEY = 'profile_wizard_v1_done'
@@ -408,47 +418,91 @@ Page({
}
},
// 微信原生 chooseAvatar 回调(点击头像直接弹出原生选择器:用微信头像/从相册选择/拍照)
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
/** 将接口返回的 profile data 写入 globalData.userInfo、storage可选同步本页展示字段头像上传时用 */
_syncUserCacheFromProfileData(d, patchPage) {
if (!d || typeof d !== 'object') return
const base = app.globalData.baseUrl || ''
const ensureAbs = (u) => ensureAbsMediaUrl(u, base)
if (patchPage) {
const patch = {}
if (d.avatar != null && String(d.avatar).trim() !== '') patch.avatar = ensureAbs(d.avatar)
if (d.nickname != null) patch.nickname = String(d.nickname)
if (d.phone != null) patch.phone = String(d.phone)
if (d.wechatId != null) patch.wechatId = String(d.wechatId)
if (d.mbti != null) {
patch.mbti = String(d.mbti)
const idx = MBTI_OPTIONS.indexOf(patch.mbti)
if (idx >= 0) patch.mbtiIndex = idx
}
if (Object.keys(patch).length) {
this.setData(patch, () => this._syncAvatarPreview())
} else {
this._syncAvatarPreview()
}
}
const u = app.globalData.userInfo
if (!u) return
if (d.nickname != null && String(d.nickname).trim() !== '') u.nickname = String(d.nickname).trim()
if (d.avatar != null && String(d.avatar).trim() !== '') u.avatar = ensureAbs(d.avatar)
if (d.phone != null && String(d.phone).trim() !== '') {
u.phone = String(d.phone).trim()
wx.setStorageSync('user_phone', u.phone)
}
if (d.wechatId != null) {
const w = String(d.wechatId).trim()
u.wechatId = w
u.wechat = w
u.wechat_id = w
if (w) wx.setStorageSync('user_wechat', w)
}
if (d.mbti != null && String(d.mbti).trim() !== '') u.mbti = String(d.mbti).trim()
wx.setStorageSync('userInfo', u)
},
/** 点击头像:相册/相机选图(不使用 open-type=chooseAvatar避免 Windows 开发者工具 http://tmp 渲染报错) */
onTapPickAvatar() {
const run = () => {
pickLocalAvatarImagePath()
.then((filePath) => this._uploadChosenAvatar(filePath))
.catch((err) => {
if (err && err.cancelled) return
wx.showToast({ title: (err && err.message) || '选择图片失败', icon: 'none' })
})
}
if (typeof wx.requirePrivacyAuthorize === 'function') {
wx.requirePrivacyAuthorize({
success: run,
fail: () => wx.showToast({ title: '需同意隐私指引后再换头像', icon: 'none' }),
})
} else {
run()
}
},
async _uploadChosenAvatar(filePath) {
if (!filePath) return
wx.showLoading({ title: '上传中...', mask: true })
try {
const uploadRes = await new Promise((resolve, reject) => {
wx.uploadFile({
url: app.globalData.baseUrl + '/api/miniprogram/upload',
filePath: tempAvatarUrl,
name: 'file',
formData: { folder: 'avatars' },
success: (r) => {
try {
const data = JSON.parse(r.data)
if (data.success) resolve(data)
else reject(new Error(data.error || '上传失败'))
} catch {
reject(new Error('解析失败'))
}
},
fail: reject,
})
})
const uploadRes = await uploadByApi({ filePath, folder: 'avatars' })
let avatarUrl = uploadRes.data?.url || uploadRes.url
if (avatarUrl && !avatarUrl.startsWith('http')) {
avatarUrl = app.globalData.baseUrl + avatarUrl
if (avatarUrl && !/^https?:\/\//i.test(avatarUrl)) {
avatarUrl = ensureAbsMediaUrl(avatarUrl, app.globalData.baseUrl)
}
this.setData({ avatar: avatarUrl }, () => this._syncAvatarPreview())
const avatarToSave = toAvatarPath(avatarUrl)
await app.request({
const profileRes = await app.request({
url: '/api/miniprogram/user/profile',
method: 'POST',
data: { userId: app.globalData.userInfo?.id, avatar: avatarToSave },
})
if (app.globalData.userInfo) {
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
if (!profileRes?.success) {
throw new Error(profileRes?.error || profileRes?.message || '保存头像失败')
}
this._syncUserCacheFromProfileData(profileRes.data, true)
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
setTimeout(() => this.generateShareCard(), 200)
@@ -521,12 +575,7 @@ Page({
data: payload,
})
wx.showToast({ title: '保存成功', icon: 'success' })
if (app.globalData.userInfo) {
if (payload.nickname) app.globalData.userInfo.nickname = payload.nickname
if (res?.data?.avatar) app.globalData.userInfo.avatar = res.data.avatar
if (payload.phone) app.globalData.userInfo.phone = payload.phone
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
if (res?.data) this._syncUserCacheFromProfileData(res.data, false)
if (wizardComplete) {
wx.setStorageSync(PROFILE_WIZARD_DONE_KEY, '1')
this.setData({ wizardMode: false, saving: false })

View File

@@ -31,7 +31,7 @@
<text class="tip-text">第 1 步:先设置对外展示的头像与昵称</text>
</view>
<view class="avatar-section">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap-btn" hover-class="none" bindtap="onTapPickAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
@@ -39,7 +39,7 @@
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
</button>
</view>
</view>
<view class="section section-wizard">
<view class="form-row">
@@ -181,7 +181,7 @@
</view>
<view class="avatar-section">
<button class="avatar-wrap-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar-wrap-btn" hover-class="none" bindtap="onTapPickAvatar">
<view class="avatar-wrap">
<view class="avatar-inner">
<image wx:if="{{avatarPreviewUrl}}" class="avatar-img" src="{{avatarPreviewUrl}}" mode="aspectFill"/>
@@ -189,7 +189,7 @@
</view>
<view class="avatar-camera"><icon name="camera" size="48" color="#ffffff"></icon></view>
</view>
</button>
</view>
</view>
<view class="section">

View File

@@ -41,13 +41,12 @@
.tip-text { font-size: 26rpx; color: rgba(94,234,212,0.95); line-height: 1.6; }
.avatar-section { display: flex; flex-direction: column; align-items: center; margin-bottom: 48rpx; }
/* 头像按钮:透明无边框,点击直接弹出微信原生选择器 */
/* 头像点击区(原 chooseAvatar button现为 view + chooseMedia */
.avatar-wrap-btn {
display: flex; align-items: center; justify-content: center;
padding: 0; margin: 0; background: transparent; border: none;
padding: 0; margin: 0; background: transparent;
width: 192rpx; height: 192rpx; border-radius: 50%; overflow: visible;
}
.avatar-wrap-btn::after { border: none; }
.avatar-wrap {
position: relative; width: 192rpx; height: 192rpx; border-radius: 50%;
border: 4rpx solid #5EEAD4; box-shadow: 0 0 30rpx rgba(94,234,212,0.3);

View File

@@ -20,6 +20,7 @@ Page({
},
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
if (this.data.profile) this.loadProfile()
},
@@ -100,6 +101,10 @@ Page({
},
goToVip() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/vip/vip' })
},
})

View File

@@ -165,8 +165,8 @@
<view class="scroll-pad"></view>
</scroll-view>
<!-- 底部按钮 -->
<view class="bottom-bar" wx:if="{{!loading}}">
<!-- 底部按钮(审核模式隐藏会员入口,与读页/钱包一致) -->
<view class="bottom-bar" wx:if="{{!loading && !auditMode}}">
<view class="vip-btn" bindtap="goToVip">
<text>成为超级个体</text>
<icon name="chevron-right" size="32" color="#0f172a"></icon>

View File

@@ -1,5 +1,5 @@
/**
* Soul创业实验 - 订单页(已支付消费流水:章节/VIP/余额/代付等
* Soul创业派对 - 订单与代付(消费流水 + 我发起的代付,按时间合并
*/
const app = getApp()
const { cleanSingleLineField } = require('../../utils/contentParser.js')
@@ -25,6 +25,46 @@ function midForSection(sectionId, bookFlat) {
return row?.mid ?? row?.MID ?? 0
}
function giftPayStatusLabel(status) {
const s = String(status || '').toLowerCase()
if (s === 'pending' || s === 'pending_pay') return '待支付'
if (s === 'paid') return '已支付'
if (s === 'refunded') return '已退款'
if (s === 'cancelled') return '已取消'
if (s === 'expired') return '已过期'
return String(status || '').trim() || '--'
}
function parseIsoMs(iso) {
const t = new Date(iso || 0).getTime()
return Number.isFinite(t) ? t : 0
}
function mapGiftPayToRow(item) {
const requestSn = String(item.requestSn || '').trim()
if (!requestSn) return null
const t = parseIsoMs(item.createdAt)
const amt = Number(item.amount)
const amountStr = Number.isFinite(amt) ? amt.toFixed(2) : '--'
const titleRaw = cleanSingleLineField(String(item.description || '').trim())
const title = titleRaw || '代付'
const st = String(item.status || '').toLowerCase()
const stLabel = giftPayStatusLabel(item.status)
const gpPending = st === 'pending' || st === 'pending_pay'
const gpPaid = st === 'paid'
return {
rowKey: `gp_${requestSn}`,
kind: 'giftPay',
requestSn,
title,
subLine: `¥${amountStr} · ${stLabel} · ${formatShortDate(t)}`,
actionLabel: '查看',
gpPending,
gpPaid,
_sortMs: t,
}
}
function classifyNav(productType, productId, mid) {
const pt = String(productType || '').toLowerCase()
if (pt === 'section' && productId) {
@@ -43,7 +83,7 @@ function classifyNav(productType, productId, mid) {
return { kind: 'page', path: '/pages/wallet/wallet', label: '余额' }
}
if (pt === 'gift_pay' || pt === 'gift_pay_batch') {
return { kind: 'page', path: '/pages/gift-pay/list', label: '代付记录' }
return { kind: 'page', path: '/pages/purchases/purchases', label: '查看' }
}
if (productId && (/^\d+\.\d+/.test(productId) || productId.length > 0)) {
return { kind: 'read', id: productId, mid: mid || 0, label: '阅读' }
@@ -72,6 +112,7 @@ function mapApiOrderToRow(item, bookFlat) {
return {
rowKey: String(item.order_sn || item.id || `o_${t}`),
kind: 'order',
title,
subLine: `¥${amountStr} · ${formatShortDate(t)}`,
actionLabel: nav.label,
@@ -120,40 +161,55 @@ Page({
const userId = app.globalData.userInfo?.id
try {
let orderRows = []
if (userId) {
const res = await app.request({
const orderRes = await app.request({
url: `/api/miniprogram/orders?userId=${encodeURIComponent(userId)}`,
silent: true
})
if (res && res.success && Array.isArray(res.data)) {
const rows = res.data
.map((item) => mapApiOrderToRow(item, bookFlat))
.filter(Boolean)
.sort((a, b) => b._sortMs - a._sortMs)
.map(({ _sortMs, ...rest }) => rest)
this.setData({ allRows: rows, loading: false })
this.applyDisplay(false)
return
silent: true,
}).catch(() => null)
if (orderRes && orderRes.success && Array.isArray(orderRes.data)) {
orderRows = orderRes.data.map((item) => mapApiOrderToRow(item, bookFlat)).filter(Boolean)
}
}
const ids = [...(app.globalData.purchasedSections || [])].reverse()
const rows = ids.map((id, index) => {
const mid = midForSection(id, bookFlat)
const row = bookFlat.find((s) => s.id === id)
const title =
cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
const t = Date.now() - index * 86400000
return {
rowKey: `p_${id}_${index}`,
title,
subLine: `已解锁 · ${formatShortDate(t)}`,
actionLabel: '阅读',
nav: { kind: 'read', id, mid, label: '阅读' }
if (!orderRows.length) {
const ids = [...(app.globalData.purchasedSections || [])].reverse()
orderRows = ids.map((id, index) => {
const mid = midForSection(id, bookFlat)
const row = bookFlat.find((s) => s.id === id)
const title =
cleanSingleLineField(
row?.sectionTitle || row?.section_title || row?.title || row?.chapterTitle || ''
) || `章节 ${id}`
const t = Date.now() - index * 86400000
return {
rowKey: `p_${id}_${index}`,
kind: 'order',
title,
subLine: `已解锁 · ${formatShortDate(t)}`,
actionLabel: '阅读',
nav: { kind: 'read', id, mid, label: '阅读' },
_sortMs: t,
}
})
}
let giftRows = []
if (userId && !app.globalData.auditMode) {
const giftRes = await app
.request(`/api/miniprogram/gift-pay/my-requests?userId=${encodeURIComponent(userId)}`, {
silent: true,
})
.catch(() => null)
if (giftRes && giftRes.success && Array.isArray(giftRes.list)) {
giftRows = giftRes.list.map(mapGiftPayToRow).filter(Boolean)
}
})
this.setData({ allRows: rows, loading: false })
}
const merged = [...orderRows, ...giftRows].sort((a, b) => b._sortMs - a._sortMs)
const stripped = merged.map(({ _sortMs, ...rest }) => rest)
this.setData({ allRows: stripped, loading: false })
this.applyDisplay(false)
} catch (e) {
console.error('加载订单失败:', e)
@@ -162,10 +218,17 @@ Page({
}
},
onOrderRowTap(e) {
onUnifiedRowTap(e) {
const index = e.currentTarget.dataset.index
const row = (this.data.displayRows || [])[index]
if (!row || !row.nav) return
if (!row) return
if (row.kind === 'giftPay' && row.requestSn) {
wx.navigateTo({
url: `/pages/gift-pay/redemption-detail?requestSn=${encodeURIComponent(String(row.requestSn))}`,
})
return
}
if (!row.nav) return
const { nav } = row
if (nav.kind === 'read' && nav.id) {
const q = nav.mid ? `mid=${nav.mid}` : `id=${nav.id}`
@@ -173,6 +236,13 @@ Page({
return
}
if (nav.kind === 'page' && nav.path) {
if (app.globalData.auditMode) {
const block = { '/pages/vip/vip': true, '/pages/wallet/wallet': true }
if (block[nav.path]) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
}
wx.navigateTo({ url: nav.path })
return
}
@@ -181,6 +251,40 @@ Page({
}
},
async onGiftPayCancel(e) {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
const requestSn = e?.currentTarget?.dataset?.sn
if (!requestSn) return
const ok = await new Promise((r) => {
wx.showModal({ title: '取消代付', content: '确定取消该代付请求?', success: (res) => r(res.confirm) })
})
if (!ok) return
try {
const res = await app.request({
url: '/api/miniprogram/gift-pay/cancel',
method: 'POST',
data: { requestSn, userId: app.globalData.userInfo?.id },
})
if (res && res.success) {
wx.showToast({ title: '已取消', icon: 'success' })
this.loadOrders()
} else {
wx.showToast({ title: res?.error || '取消失败', icon: 'none' })
}
} catch (_) {
wx.showToast({ title: '取消失败', icon: 'none' })
}
},
onGiftPayShare(e) {
if (e && typeof e.stopPropagation === 'function') e.stopPropagation()
const requestSn = e?.currentTarget?.dataset?.sn
if (!requestSn) return
wx.navigateTo({
url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(String(requestSn))}`,
})
},
goBack() {
getApp().goBackOrToHome()
},
@@ -188,13 +292,13 @@ Page({
onShareAppMessage() {
const ref = app.getMyReferralCode()
return {
title: '卡若创业派对 - 购买记录',
title: '卡若创业派对 - 订单与代付',
path: ref ? `/pages/purchases/purchases?ref=${ref}` : '/pages/purchases/purchases'
}
},
onShareTimeline() {
const ref = app.getMyReferralCode()
return { title: '卡若创业派对 - 购买记录', query: ref ? `ref=${ref}` : '' }
return { title: '卡若创业派对 - 订单与代付', query: ref ? `ref=${ref}` : '' }
}
})

View File

@@ -2,7 +2,7 @@
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><icon name="chevron-left" size="44" color="rgba(255,255,255,0.8)" customClass="back-icon"></icon></view>
<text class="nav-title">我的订单</text>
<text class="nav-title">订单与代付</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
@@ -19,21 +19,23 @@
<image class="order-history-icon" src="/assets/icons/unlock-muted-teal.svg" mode="aspectFit"/>
</view>
<view class="oh-list">
<view
class="oh-row"
wx:for="{{displayRows}}"
wx:key="rowKey"
bindtap="onOrderRowTap"
data-index="{{index}}"
>
<view class="oh-left">
<text class="oh-index">{{index + 1}}</text>
<view class="oh-text-wrap">
<text class="oh-title">{{item.title}}</text>
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
<view class="oh-block" wx:for="{{displayRows}}" wx:key="rowKey">
<view class="oh-row" bindtap="onUnifiedRowTap" data-index="{{index}}">
<view class="oh-left">
<text class="oh-kind {{item.kind === 'giftPay' ? 'oh-kind-gift' : 'oh-kind-order'}}">{{item.kind === 'giftPay' ? '代付' : '订单'}}</text>
<view class="oh-text-wrap">
<text class="oh-title">{{item.title}}</text>
<text class="oh-sub" wx:if="{{item.subLine}}">{{item.subLine}}</text>
</view>
</view>
<text class="oh-link">{{item.actionLabel}}</text>
</view>
<view class="oh-gift-tools" wx:if="{{item.kind === 'giftPay' && item.gpPending}}" catchtap="">
<text class="oh-tool oh-tool-cancel" catchtap="onGiftPayCancel" data-sn="{{item.requestSn}}">取消</text>
</view>
<view class="oh-gift-tools" wx:if="{{item.kind === 'giftPay' && item.gpPaid}}" catchtap="">
<text class="oh-tool" catchtap="onGiftPayShare" data-sn="{{item.requestSn}}">分享</text>
</view>
<text class="oh-link">{{item.actionLabel}}</text>
</view>
</view>
<view
@@ -49,7 +51,7 @@
<view class="empty" wx:else>
<icon name="package" size="80" color="#3a3a3c" customClass="empty-icon"></icon>
<text class="empty-text">暂无订单</text>
<text class="empty-text">暂无订单与代付记录</text>
</view>
</view>
</view>

View File

@@ -10,15 +10,54 @@
.order-history-card { background: #1c1c1e; border-radius: 24rpx; padding: 28rpx 24rpx 16rpx; }
.order-history-head { padding: 0 8rpx 16rpx 8rpx; }
.order-history-icon { width: 40rpx; height: 40rpx; opacity: 0.92; }
.oh-list { display: flex; flex-direction: column; gap: 16rpx; }
.oh-list { display: flex; flex-direction: column; gap: 0; }
.oh-block {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-bottom: 16rpx;
}
.oh-block:last-child {
margin-bottom: 0;
}
.oh-row {
display: flex; align-items: center; justify-content: space-between;
padding: 24rpx; background: #252525; border-radius: 20rpx;
}
.oh-row:active { opacity: 0.92; }
.oh-left { display: flex; align-items: center; gap: 24rpx; overflow: hidden; min-width: 0; flex: 1; }
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; }
.oh-index { font-size: 28rpx; color: #6B7280; font-family: monospace; flex-shrink: 0; }
.oh-left { display: flex; align-items: flex-start; gap: 16rpx; overflow: hidden; min-width: 0; flex: 1; }
.oh-text-wrap { display: flex; flex-direction: column; gap: 6rpx; min-width: 0; flex: 1; padding-top: 4rpx; }
.oh-kind {
font-size: 20rpx;
padding: 6rpx 14rpx;
border-radius: 10rpx;
flex-shrink: 0;
font-weight: 600;
}
.oh-kind-order {
color: rgba(0, 206, 209, 0.92);
border: 1rpx solid rgba(0, 206, 209, 0.35);
background: rgba(0, 206, 209, 0.08);
}
.oh-kind-gift {
color: #fcd34d;
border: 1rpx solid rgba(252, 211, 77, 0.35);
background: rgba(252, 211, 77, 0.08);
}
.oh-gift-tools {
padding: 0 8rpx 4rpx 8rpx;
display: flex;
justify-content: flex-end;
gap: 32rpx;
}
.oh-tool {
font-size: 24rpx;
color: #00CED1;
font-weight: 500;
}
.oh-tool-cancel {
color: rgba(248, 113, 113, 0.95);
}
.oh-title { font-size: 28rpx; color: #E5E7EB; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.oh-sub { font-size: 22rpx; color: rgba(255,255,255,0.4); }
.oh-link { font-size: 24rpx; color: #00CED1; font-weight: 500; flex-shrink: 0; margin-left: 16rpx; }

View File

@@ -17,9 +17,10 @@ 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 { trackClick, trackViewChapter } = require('../../utils/trackClick')
const { checkAndExecute, executeRule, getChapterReadMarketingRule } = require('../../utils/ruleEngine')
const soulBridge = require('../../utils/soulBridge.js')
const superArticleDetailUtil = require('../../utils/superArticleDetail.js')
const app = getApp()
const mpPagePopups = require('../../utils/mpPagePopups.js')
@@ -218,7 +219,17 @@ Page({
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
/** chapter书籍章节super_article超级个体动态与独立动态详情合并为本页 */
pageMode: 'chapter',
superArticle: null,
superArticleId: '',
superGalleryItems: [],
superLoading: true,
superAuditTip: '',
superAuditRejectReason: '',
superShowAuditEdit: false,
// 章节信息
sectionId: '',
section: null,
@@ -460,15 +471,109 @@ Page({
onShow() {
const sp = this._detectReadSinglePage()
const audit = app.globalData.auditMode || false
this.setData({
auditMode: app.globalData.auditMode || false,
auditMode: audit,
readSinglePageMode: sp,
...(sp ? {} : { momentsPaywallExpanded: false, showMomentsLaunchGuideModal: false }),
...(audit && this.data.showGiftModal ? { showGiftModal: false } : {}),
})
this._refreshAccessOnShow()
if (!sp) {
this.refreshPay365MarketingFromRules()
}
if (this.data.pageMode === 'super_article' && this.data.superArticleId) {
this._superArticleShowCount = (this._superArticleShowCount || 0) + 1
if (this._superArticleShowCount > 1) {
this.loadSuperArticleDetail(this.data.superArticleId)
}
}
},
/**
* 超级个体动态与章节阅读共用本页query superArticleId / saId
*/
async _bootstrapSuperArticlePage(idStr) {
this._superArticleShowCount = 0
this.setData({
pageMode: 'super_article',
superArticleId: String(idStr),
superLoading: true,
loading: false,
accessState: 'unknown',
sectionId: '',
sectionMid: null,
superArticle: null,
superGalleryItems: [],
superAuditTip: '',
superAuditRejectReason: '',
superShowAuditEdit: false,
statusBarHeight: app.globalData.statusBarHeight || 44,
navBarHeight: app.globalData.navBarHeight || 88,
})
await this.loadSuperArticleDetail(idStr)
},
async loadSuperArticleDetail(id) {
this.setData({ superLoading: true })
const baseUrl = String((app.globalData && app.globalData.baseUrl) || '').replace(/\/$/, '')
try {
const viewerId = app.globalData.userInfo?.id
const q =
viewerId != null && String(viewerId).trim() !== ''
? `?viewerUserId=${encodeURIComponent(String(viewerId).trim())}`
: ''
const res = await app.request({
url: `/api/miniprogram/super/articles/${encodeURIComponent(String(id))}${q}`,
silent: true,
})
const article = res?.data
if (!article) {
this.setData({ superLoading: false, superArticle: null, superGalleryItems: [] })
return
}
const audit = superArticleDetailUtil.buildSuperArticleAuditMeta(article, app.globalData.userInfo?.id)
const galleryItems = superArticleDetailUtil.buildGalleryItems(article.images, baseUrl)
this.setData({
superArticle: Object.assign({}, article, {
createdAtText: superArticleDetailUtil.formatSuperArticleTime(article.createdAt),
contentHtml: superArticleDetailUtil.renderSuperArticleHtml(article.content),
}),
superGalleryItems: galleryItems,
superAuditTip: audit.auditTip,
superAuditRejectReason: audit.auditRejectReason,
superShowAuditEdit: audit.showAuditEdit,
superLoading: false,
})
} catch (_) {
this.setData({ superLoading: false, superArticle: null, superGalleryItems: [] })
}
},
goSuperArticleEdit() {
const id = this.data.superArticleId
if (!id) return
wx.navigateTo({
url: `/pages/super-article-editor/super-article-editor?id=${encodeURIComponent(String(id))}`,
})
},
onSuperGalleryImgError(e) {
const idx = Number(e.currentTarget.dataset.idx)
if (Number.isNaN(idx)) return
this.setData({ [`superGalleryItems[${idx}].loadFailed`]: true })
},
previewSuperGallery(e) {
const current = e.currentTarget.dataset.src || ''
const urls = (this.data.superGalleryItems || [])
.filter((x) => x.src && !x.loadFailed)
.map((x) => x.src)
if (!urls.length) return
wx.previewImage({
urls,
current: current || urls[0],
})
},
/**
@@ -476,6 +581,7 @@ Page({
* 修复:管理端取消超级个体后,若仍用订单兜底 VIP需配合后端 isVipEffective同时刷新端上 showPaywall/全文裁切。
*/
async _refreshAccessOnShow() {
if (this.data.pageMode === 'super_article') return
const id = this.data.sectionId
if (!id || this.data.loading || this.data.readSinglePageMode) return
const uid = app.globalData.userInfo?.id
@@ -519,6 +625,13 @@ Page({
async onLoad(options) {
wx.showShareMenu({ menus: ['shareAppMessage', 'shareTimeline'] })
const superRaw = (options && (options.superArticleId || options.saId)) || ''
const superArticleCandidate = String(superRaw).trim()
if (superArticleCandidate && /^\d+$/.test(superArticleCandidate)) {
await this._bootstrapSuperArticlePage(superArticleCandidate)
return
}
// 预加载core+auditModegetConfig+ read-extras 懒加载linkTags、linkedMiniprograms
Promise.all([
app.getConfig(),
@@ -581,6 +694,7 @@ Page({
return
}
const readSinglePageMode = this._detectReadSinglePage()
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
@@ -589,7 +703,7 @@ Page({
loading: true,
accessState: 'unknown',
pendingGiftRequestSn: giftRequestSn || '',
readSinglePageMode: this._detectReadSinglePage(),
readSinglePageMode,
momentsPaywallExpanded: false,
showMomentsLaunchGuideModal: false,
pendingAutoSectionPay: parseDatasetBool(options && options.openPay),
@@ -601,6 +715,7 @@ Page({
app.handleReferralCode({ query: { ref } })
}
let viewChapterPvAccessState = 'unknown'
try {
const config = await accessManager.fetchLatestConfig()
const purchasedSections = app.globalData.purchasedSections || []
@@ -618,7 +733,8 @@ Page({
const chapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true })
let accessState = await accessManager.determineAccessState(id, chapterRes)
let canAccess = accessManager.canAccessFullContent(accessState)
viewChapterPvAccessState = accessState
this.setData({
accessState,
canAccess,
@@ -638,6 +754,7 @@ Page({
const freshChapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true })
accessState = await accessManager.determineAccessState(id, freshChapterRes)
canAccess = accessManager.canAccessFullContent(accessState)
viewChapterPvAccessState = accessState
this.setData({ accessState, canAccess, showPaywall: !canAccess, pendingGiftRequestSn: '' })
if (canAccess) {
await this.loadContent(id, accessState, freshChapterRes)
@@ -662,8 +779,17 @@ Page({
} catch (e) {
console.error('[Read] 初始化失败:', e)
wx.showToast({ title: '加载失败,请重试', icon: 'none' })
viewChapterPvAccessState = 'error'
this.setData({ accessState: 'error', loading: false })
} finally {
// 章节浏览 PV放在 finally避免 loadContent/初始化抛错导致「有付款却无点击」的统计缺口(与 reading_progress 无关)
if (id) {
trackViewChapter(id, {
singlePage: !!this.data.readSinglePageMode,
accessState: viewChapterPvAccessState,
sectionMid: this.data.sectionMid != null ? this.data.sectionMid : (mid || null),
})
}
this.setData({ loading: false })
}
},
@@ -1257,6 +1383,22 @@ Page({
wx.showToast({ title: '暂无跳转地址', icon: 'none' })
},
// 点击正文普通超链接:用小程序 web-view 打开(微信内置浏览器)
onArticleLinkTap(e) {
const url = String((e.currentTarget.dataset.url || '')).trim()
if (!url) {
wx.showToast({ title: '链接地址为空', icon: 'none' })
return
}
const text = String((e.currentTarget.dataset.text || '链接预览')).trim() || '链接预览'
wx.navigateTo({
url: `/pages/link-preview/link-preview?url=${encodeURIComponent(url)}&title=${encodeURIComponent(text)}`,
fail: (err) => {
wx.showToast({ title: err.errMsg || '打开失败', icon: 'none' })
},
})
},
// 点击正文图片 → 全屏预览
onImageTap(e) {
const src = e.currentTarget.dataset.src
@@ -1302,6 +1444,10 @@ Page({
// 代付分享:直接跳转代付页,在代付页输入数量并支付(简化流程)
showGiftShareModal() {
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持代付', icon: 'none' })
return
}
if (!app.globalData.userInfo?.id) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
@@ -1334,6 +1480,10 @@ Page({
async confirmGiftPay() {
if (this.data.giftPaying) return
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持代付', icon: 'none' })
return
}
// 朋友圈单页模式禁止支付
try {
const sys = wx.getSystemInfoSync()
@@ -1471,6 +1621,16 @@ Page({
// 分享到微信 - 自动带分享人ID
onShareAppMessage(e) {
if (this.data.pageMode === 'super_article') {
const a = this.data.superArticle
const sid = this.data.superArticleId
const q = sid ? `superArticleId=${encodeURIComponent(String(sid))}` : ''
return {
title: a && a.title ? a.title : '动态',
path: sid ? `/pages/read/read?${q}` : '/pages/read/read',
}
}
trackClick('read', 'btn_click', '分享_' + this.data.sectionId)
const { section, sectionId, sectionMid } = this.data
const ref = app.getMyReferralCode()
@@ -1533,6 +1693,16 @@ Page({
// 分享到朋友圈:带文章标题,过长时截断
onShareTimeline() {
if (this.data.pageMode === 'super_article') {
const a = this.data.superArticle
const sid = this.data.superArticleId
const query = sid ? `superArticleId=${encodeURIComponent(String(sid))}` : ''
return {
title: a && a.title ? a.title : '动态',
query,
}
}
const { section, sectionId, sectionMid, chapterTitle, readUi } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
@@ -1705,6 +1875,14 @@ Page({
async processPayment(type, sectionId, amount) {
console.log('[Pay] processPayment开始:', { type, sectionId, amount })
if (app.globalData.auditMode) {
try {
wx.hideLoading()
} catch (e) {}
wx.showToast({ title: '审核中,暂不支持购买', icon: 'none' })
return
}
if (this.data.readSinglePageMode || this._detectReadSinglePage()) {
try {
wx.hideLoading()

View File

@@ -1,6 +1,7 @@
<!--pages/read/read.wxml-->
<!--卡若创业派对 - 阅读页-->
<!--卡若创业派对 - 阅读页(章节 / 超级个体动态共用动态query superArticleId-->
<view class="page">
<block wx:if="{{pageMode === 'chapter'}}">
<!-- 阅读进度条 -->
<view class="progress-bar-fixed" style="top: {{statusBarHeight}}px;">
<view class="progress-fill" style="width: {{readingProgress}}%;"></view>
@@ -78,14 +79,14 @@
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph">
<image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image>
</view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap">
<video class="content-video" src="{{item[0].src}}" controls show-center-play-btn object-fit="contain"></video>
<view wx:elif="{{item.length === 1 && item[0].type === 'video' && !auditMode}}" class="paragraph content-video-wrap">
<video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
</view>
<view wx:else class="paragraph">
<text user-select><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>
<text user-select><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><text wx:elif="{{seg.type === 'link'}}" class="article-link" bindtap="onArticleLinkTap" data-url="{{seg.url}}" data-text="{{seg.text}}">{{seg.text}}</text></block></text>
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<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>
<video wx:elif="{{seg.type === 'video'}}" class="content-video" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video>
<video wx:elif="{{seg.type === 'video' && !auditMode}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
</block>
</view>
</block>
@@ -157,8 +158,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><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"><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><video wx:elif="{{seg.type === 'video'}}" class="content-video" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video></block></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video' && !auditMode}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><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><text wx:elif="{{seg.type === 'link'}}" class="article-link" bindtap="onArticleLinkTap" data-url="{{seg.url}}" data-text="{{seg.text}}">{{seg.text}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><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><video wx:elif="{{seg.type === 'video' && !auditMode}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
</block>
</view>
<view class="fade-mask"></view>
@@ -250,8 +251,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><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"><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><video wx:elif="{{seg.type === 'video'}}" class="content-video" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video></block></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video' && !auditMode}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><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><text wx:elif="{{seg.type === 'link'}}" class="article-link" bindtap="onArticleLinkTap" data-url="{{seg.url}}" data-text="{{seg.text}}">{{seg.text}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><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><video wx:elif="{{seg.type === 'video' && !auditMode}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
</block>
</view>
<view class="fade-mask"></view>
@@ -512,4 +513,56 @@
<text class="pay365-anchor-float-text">{{pay365AnchorLabel}}</text>
<text class="pay365-anchor-float-chevron"></text>
</view>
</block>
<block wx:elif="{{pageMode === 'super_article'}}">
<view class="read-super-nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="read-super-nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.85)"></icon>
</view>
<text class="read-super-nav-title">文章详情</text>
<view class="read-super-nav-placeholder"></view>
</view>
<view class="read-super-nav-offset" style="height: {{statusBarHeight + 44}}px;"></view>
<view class="read-super-loading" wx:if="{{superLoading}}">
<text class="read-super-loading-text">加载中…</text>
</view>
<view class="read-super-body" wx:elif="{{superArticle}}">
<view class="read-super-audit-banner" wx:if="{{superAuditTip}}">
<text class="read-super-audit-main">{{superAuditTip}}</text>
<text class="read-super-audit-sub" wx:if="{{superAuditRejectReason}}">{{superAuditRejectReason}}</text>
<view wx:if="{{superShowAuditEdit}}" class="read-super-audit-edit-btn" bindtap="goSuperArticleEdit">重新编辑并提交</view>
</view>
<text class="read-super-title" wx:if="{{superArticle.title}}">{{superArticle.title}}</text>
<text class="read-super-title read-super-title-muted" wx:else>动态</text>
<text class="read-super-meta">{{superArticle.authorNickname || '超级个体'}} · {{superArticle.createdAtText}}</text>
<view class="read-super-gallery" wx:if="{{superGalleryItems.length}}">
<view class="read-super-gallery-cell" wx:for="{{superGalleryItems}}" wx:key="idx">
<block wx:if="{{item.src && !item.loadFailed}}">
<image
class="read-super-gallery-img"
mode="widthFix"
src="{{item.src}}"
bindtap="previewSuperGallery"
data-src="{{item.src}}"
binderror="onSuperGalleryImgError"
data-idx="{{index}}"
/>
</block>
<view wx:else class="read-super-gallery-ph">
<text class="read-super-gallery-ph-text">{{item.src ? '图片加载失败' : '配图缺失或地址无效'}}</text>
</view>
</view>
</view>
<rich-text class="read-super-content" nodes="{{superArticle.contentHtml}}"></rich-text>
</view>
<view class="read-super-empty" wx:elif="{{!superLoading}}">
<text>动态不存在或已下线</text>
</view>
</block>
</view>

View File

@@ -282,6 +282,14 @@
padding: 0 4rpx;
}
/* 正文内普通超链接(管理端插入 a 标签) */
.paragraph .article-link {
color: #67e8f9;
text-decoration: underline;
text-underline-offset: 4rpx;
font-weight: 500;
}
/* 正文内图片 */
.content-image {
width: 100%;
@@ -290,6 +298,22 @@
display: block;
}
.content-video-wrap {
width: 100%;
margin: 24rpx 0;
}
.content-video {
width: 100%;
display: block;
border-radius: 12rpx;
background: #111;
}
.content-video--inline {
margin: 24rpx 0;
}
/* 正文内表格 */
.table-scroll {
margin: 24rpx 0 36rpx;
@@ -1940,3 +1964,140 @@ button.action-share-native {
color: #38bdac;
margin-left: 12rpx;
}
/* ---------- 超级个体动态(与章节共用阅读页 pageMode=super_article ---------- */
.read-super-nav-bar {
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 10;
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16rpx;
background: rgba(5, 11, 20, 0.92);
border-bottom: 1rpx solid rgba(34, 211, 238, 0.12);
}
.read-super-nav-back,
.read-super-nav-placeholder {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.read-super-nav-title {
font-size: 32rpx;
font-weight: 700;
color: #fff;
}
.read-super-nav-offset {
width: 100%;
}
.read-super-loading {
padding-top: 120rpx;
text-align: center;
}
.read-super-loading-text {
font-size: 26rpx;
color: #94a3b8;
}
.read-super-body {
padding: 8rpx 24rpx 48rpx;
}
.read-super-audit-banner {
margin-bottom: 20rpx;
padding: 20rpx 22rpx;
border-radius: 16rpx;
background: rgba(251, 191, 36, 0.08);
border: 1rpx solid rgba(251, 191, 36, 0.28);
}
.read-super-audit-main {
display: block;
font-size: 26rpx;
color: #fcd34d;
font-weight: 600;
}
.read-super-audit-sub {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: rgba(226, 232, 240, 0.88);
line-height: 1.45;
}
.read-super-audit-edit-btn {
margin-top: 18rpx;
display: inline-block;
padding: 14rpx 28rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 600;
color: #0b1220;
background: linear-gradient(135deg, #00ced1, #20b2aa);
}
.read-super-title {
font-size: 36rpx;
font-weight: 700;
line-height: 1.4;
display: block;
color: #fff;
}
.read-super-title-muted {
color: #94a3b8;
font-weight: 600;
}
.read-super-meta {
margin-top: 12rpx;
font-size: 22rpx;
color: #94a3b8;
display: block;
}
.read-super-gallery {
margin-top: 20rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.read-super-gallery-cell {
width: 100%;
}
.read-super-gallery-img {
width: 100%;
display: block;
border-radius: 12rpx;
box-sizing: border-box;
border: 2rpx solid rgba(34, 211, 238, 0.32);
background: rgba(15, 23, 42, 0.6);
}
.read-super-gallery-ph {
min-height: 220rpx;
border-radius: 12rpx;
border: 2rpx dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx;
box-sizing: border-box;
}
.read-super-gallery-ph-text {
font-size: 24rpx;
color: #64748b;
text-align: center;
}
.read-super-content {
margin-top: 24rpx;
font-size: 28rpx;
line-height: 1.75;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-word;
}
.read-super-empty {
margin-top: 200rpx;
text-align: center;
color: #94a3b8;
font-size: 26rpx;
}

View File

@@ -648,6 +648,10 @@ Page({
// 提现 - 直接到微信零钱
async handleWithdraw() {
trackClick('referral', 'btn_click', '申请提现')
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持提现', icon: 'none' })
return
}
const availableEarnings = this.data.availableEarningsNum || 0
const minWithdrawAmount = this.data.minWithdrawAmount || 10
const hasWechatId = this.data.hasWechatId
@@ -695,11 +699,19 @@ Page({
// 跳转提现记录页
goToWithdrawRecords() {
trackClick('referral', 'nav_click', '提现记录')
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/withdraw-records/withdraw-records' })
},
// 执行提现success:false 时 app.request 会 reject 并挂 e.response需 catch 里处理 needBindWechat
async doWithdraw(amount) {
if (app.globalData.auditMode) {
wx.showToast({ title: '审核中,暂不支持提现', icon: 'none' })
return
}
wx.showLoading({ title: '提现中...' })
try {

View File

@@ -0,0 +1,16 @@
/**
* Soul创业派对 - 兼容入口:动态详情已并入阅读页 pages/read/readsuperArticleId
* 旧链接 / 分享路径仍可能指向本页,此处统一 redirect。
*/
Page({
onLoad(options) {
const id = options?.id != null ? String(options.id).trim() : ''
if (!id || !/^\d+$/.test(id)) {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/index/index' }) })
return
}
wx.redirectTo({
url: `/pages/read/read?superArticleId=${encodeURIComponent(id)}`,
})
},
})

View File

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

View File

@@ -0,0 +1 @@
<view class="super-article-detail-redirect"></view>

View File

@@ -0,0 +1,4 @@
.super-article-detail-redirect {
min-height: 100vh;
background: #0b1220;
}

View File

@@ -0,0 +1,545 @@
/**
* Soul创业派对 - 超级个体发动态AI 辅助 + 朋友圈式编辑)
*/
const app = getApp()
const { uploadByApi } = require('../../utils/miniprogramUpload')
const TEXT_EXTS = ['.txt', '.md', '.json', '.csv', '.log', '.yaml', '.yml']
const IMAGE_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
const MAX_TEXT_READ_BYTES = 1024 * 1024
const MAX_REFERENCE_SEND = 28000
function newAttachId() {
return `f_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
Page({
data: {
/** 临时隐藏「AI 写文章」卡片;需上线时再设为 true */
showAiWriteSection: false,
statusBarHeight: 44,
auditMode: false,
title: '',
content: '',
bodyImages: [],
saving: false,
meNickname: '',
aiPrompt: '',
attachedFiles: [],
generating: false,
editArticleId: '',
},
/** 遮挡层拦截滚动穿透 */
preventMove() {},
syncAuditMode() {
const audit = !!app.globalData.auditMode
this.setData({ auditMode: audit })
return audit
},
onLoad(options) {
const editId = options && options.id != null ? String(options.id).trim() : ''
if (editId) {
this._pendingEditId = editId
this.setData({ editArticleId: editId })
}
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
meNickname: String(app.globalData.userInfo?.nickname || '我').trim() || '我',
})
this.syncAuditMode()
try {
app.getAuditMode && app.getAuditMode().catch(() => {})
} catch (_) {}
this.ensureSuperIdentity()
},
onShow() {
this.syncAuditMode()
try {
app.getAuditMode && app.getAuditMode().catch(() => {})
} catch (_) {}
},
async ensureSuperIdentity() {
const userId = app.globalData.userInfo?.id
if (!app.globalData.isLoggedIn || !userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => wx.navigateBack(), 400)
return
}
try {
const res = await app.request({
url: `/api/miniprogram/my/super-stats?userId=${encodeURIComponent(userId)}`,
silent: true,
})
if (!(res?.success && res.data?.isSuperIndividual === true)) {
wx.showToast({ title: '仅超级个体可发动态', icon: 'none' })
setTimeout(() => wx.navigateBack(), 400)
return
}
const pid = this._pendingEditId ? String(this._pendingEditId).trim() : ''
if (pid) {
this._pendingEditId = ''
await this.loadArticleForEdit(pid)
}
} catch (_) {
wx.showToast({ title: '身份校验失败', icon: 'none' })
setTimeout(() => wx.navigateBack(), 400)
}
},
async loadArticleForEdit(id) {
const userId = app.globalData.userInfo?.id
if (!userId) return
wx.showLoading({ title: '加载中…', mask: true })
try {
const res = await app.request({
url: `/api/miniprogram/super/articles/${encodeURIComponent(String(id))}?viewerUserId=${encodeURIComponent(String(userId))}`,
silent: true,
})
const article = res?.data
if (!article) throw new Error('加载失败')
if (String(article.userId) !== String(userId)) throw new Error('无权编辑')
const st = String(article.auditStatus || '').toLowerCase()
if (st !== 'pending' && st !== 'rejected') throw new Error('仅待审核或已驳回的动态可编辑')
const imgs = Array.isArray(article.images)
? article.images.map((u) => String(u || '').trim()).filter(Boolean)
: []
const bodyImages = imgs.map((url) => ({ id: newAttachId(), url, uploading: false }))
this.setData({
editArticleId: String(id),
title: '',
content: String(article.content || ''),
bodyImages,
})
} catch (e) {
wx.showToast({ title: (e && e.message) || '加载失败', icon: 'none' })
setTimeout(() => wx.navigateBack(), 500)
} finally {
try {
wx.hideLoading()
} catch (_) {}
}
},
onAiPromptInput(e) {
this.setData({ aiPrompt: e.detail.value || '' })
},
onContentInput(e) {
this.setData({ content: e.detail.value || '' })
},
insertMentionSelf() {
if (this.data.auditMode) return
const nick = this.data.meNickname || '我'
this.setData({ content: `${this.data.content}@${nick} ` })
},
insertHashLink() {
if (this.data.auditMode) return
this.setData({ content: `${this.data.content}#链接(https://)` })
},
/** 编辑区配图(发布后展示在正文前),最多 9 张 */
chooseArticleImages() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
wx.showToast({ title: '审核模式下不可用', icon: 'none' })
return
}
const max = 9
const cur = this.data.bodyImages.length
if (cur >= max) {
wx.showToast({ title: '最多 9 张配图', icon: 'none' })
return
}
const pickCount = max - cur
const mapFromChooseMedia = (res) => {
const files = res.tempFiles || []
return files.map((f, i) => ({
path: f.tempFilePath,
name: `配图_${Date.now()}_${i}.jpg`,
size: f.size || 0,
}))
}
const mapFromChooseImage = (res) => {
const paths = res.tempFilePaths || []
const t = Date.now()
return paths.map((path, i) => ({
path,
name: `配图_${t}_${i}.jpg`,
size: 0,
}))
}
const runUpload = async (tempFiles) => {
for (const f of tempFiles) {
if (this.data.bodyImages.length >= max) break
await this._uploadOneArticleBodyImage(f)
}
}
if (typeof wx.chooseMedia === 'function') {
wx.chooseMedia({
count: pickCount,
mediaType: ['image'],
sourceType: ['album', 'camera'],
sizeType: ['compressed'],
success: (res) => {
void runUpload(mapFromChooseMedia(res))
},
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
if (typeof wx.chooseImage === 'function') {
wx.chooseImage({
count: pickCount,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
void runUpload(mapFromChooseImage(res))
},
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
wx.showToast({ title: '当前环境不支持选图片', icon: 'none' })
},
async _uploadOneArticleBodyImage(f) {
const filePath = f.path
const name = String(f.name || '配图').trim() || '配图'
const size = f.size || 0
if (!filePath) return
if (size > 29 * 1024 * 1024) {
wx.showToast({ title: '单张请勿超过 30MB', icon: 'none' })
return
}
const id = newAttachId()
this.setData({
bodyImages: this.data.bodyImages.concat([{ id, url: '', uploading: true }]),
})
try {
const up = await uploadByApi({ filePath, folder: 'super-article-images' })
const url = this._pickUploadUrl(up)
if (!url) throw new Error('no url')
const next = this.data.bodyImages.map((x) =>
x.id === id ? Object.assign({}, x, { url, uploading: false }) : x,
)
this.setData({ bodyImages: next })
} catch (_) {
this.setData({
bodyImages: this.data.bodyImages.filter((x) => x.id !== id),
})
wx.showToast({ title: `${name} 上传失败`, icon: 'none' })
}
},
removeArticleImage(e) {
if (this.data.auditMode) return
const id = e.currentTarget.dataset.id
if (!id) return
this.setData({
bodyImages: this.data.bodyImages.filter((x) => x.id !== id),
})
},
/** 参考素材:仅从相册/相机选图片并上传AI 侧按图理解) */
chooseReferenceImages() {
this.syncAuditMode()
if (this.data.auditMode || app.globalData.auditMode) {
wx.showToast({ title: '审核模式下不可用', icon: 'none' })
return
}
this._pickReferenceImagesFromAlbum()
},
_pickReferenceImagesFromAlbum() {
const mapFromChooseMedia = (res) => {
const files = res.tempFiles || []
return files.map((f, i) => ({
path: f.tempFilePath,
name: `参考图_${Date.now()}_${i}.jpg`,
size: f.size || 0,
}))
}
const mapFromChooseImage = (res) => {
const paths = res.tempFilePaths || []
const t = Date.now()
return paths.map((path, i) => ({
path,
name: `参考图_${t}_${i}.jpg`,
size: 0,
}))
}
if (typeof wx.chooseMedia === 'function') {
wx.chooseMedia({
count: 9,
mediaType: ['image'],
sourceType: ['album', 'camera'],
sizeType: ['compressed'],
success: (res) => this.handlePickedFiles(mapFromChooseMedia(res)),
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
if (typeof wx.chooseImage === 'function') {
wx.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => this.handlePickedFiles(mapFromChooseImage(res)),
fail: (err) => {
const em = String((err && err.errMsg) || '')
if (/cancel|取消/i.test(em)) return
wx.showToast({ title: '选择图片失败', icon: 'none' })
},
})
return
}
wx.showToast({ title: '当前环境不支持选图片', icon: 'none' })
},
_pickUploadUrl(up) {
if (!up || typeof up !== 'object') return ''
return String(up.url || up.fullUrl || up.data?.url || up.data?.fullUrl || '').trim()
},
_patchAttach(id, patch) {
const next = this.data.attachedFiles.map((x) =>
x.id === id ? Object.assign({}, x, patch) : x,
)
this.setData({ attachedFiles: next })
},
_removeAttachById(id) {
this.setData({
attachedFiles: this.data.attachedFiles.filter((x) => x.id !== id),
})
},
async handlePickedFiles(tempFiles) {
if (!tempFiles.length) return
for (const f of tempFiles) {
const name = String(f.name || '未命名').trim() || '未命名'
const filePath = f.path
const size = f.size || 0
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : ''
const id = newAttachId()
if (size > 29 * 1024 * 1024) {
wx.showToast({ title: `${name} 超过30MB上限`, icon: 'none' })
continue
}
if (IMAGE_EXTS.includes(ext)) {
this.setData({
attachedFiles: this.data.attachedFiles.concat([
{ id, name, kind: 'image', url: '', uploading: true },
]),
})
try {
const up = await uploadByApi({ filePath, folder: 'article-images' })
const url = this._pickUploadUrl(up)
if (!url) throw new Error('no url')
this._patchAttach(id, { url, uploading: false })
} catch (_) {
this._removeAttachById(id)
wx.showToast({ title: `${name} 上传失败`, icon: 'none' })
}
continue
}
if (TEXT_EXTS.includes(ext) && size <= MAX_TEXT_READ_BYTES && size >= 0) {
try {
const fs = wx.getFileSystemManager()
const textContent = fs.readFileSync(filePath, 'utf8')
this.setData({
attachedFiles: this.data.attachedFiles.concat([
{ id, name, kind: 'text', textContent, uploading: false },
]),
})
} catch (_) {
await this._uploadBinaryAttach(id, name, filePath)
}
continue
}
await this._uploadBinaryAttach(id, name, filePath)
}
},
async _uploadBinaryAttach(id, name, filePath) {
this.setData({
attachedFiles: this.data.attachedFiles.concat([
{ id, name, kind: 'binary', url: '', uploading: true },
]),
})
try {
const up = await uploadByApi({ filePath, folder: 'book-attachments' })
const url = this._pickUploadUrl(up)
if (!url) throw new Error('no url')
this._patchAttach(id, { url, uploading: false })
} catch (_) {
this._removeAttachById(id)
wx.showToast({ title: `${name} 上传失败`, icon: 'none' })
}
},
removeAttachedFile(e) {
if (this.data.auditMode) return
const id = e.currentTarget.dataset.id
if (!id) return
this._removeAttachById(id)
},
async generateArticle() {
if (this.data.auditMode || app.globalData.auditMode) return
if (this.data.generating) return
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
if (this.data.attachedFiles.some((x) => x.uploading)) {
wx.showToast({ title: '请等待图片上传完成', icon: 'none' })
return
}
const prompt = String(this.data.aiPrompt || '').trim()
const parts = []
for (const f of this.data.attachedFiles) {
if (f.kind === 'text' && f.textContent) {
parts.push(`--- ${f.name} ---\n${f.textContent}`)
}
}
let referenceText = parts.join('\n\n').trim()
if (referenceText.length > MAX_REFERENCE_SEND) {
referenceText =
referenceText.slice(0, MAX_REFERENCE_SEND) + '\n\n本地参考文末已截断'
}
const materialUrls = this.data.attachedFiles
.filter((x) => x.kind === 'binary' && x.url)
.map((x) => x.url)
const imageUrls = this.data.attachedFiles
.filter((x) => x.kind === 'image' && x.url)
.map((x) => x.url)
if (!prompt && !referenceText && materialUrls.length === 0 && imageUrls.length === 0) {
wx.showToast({ title: '请填写提示词或添加参考图', icon: 'none' })
return
}
this.setData({ generating: true })
try {
const res = await app.request({
url: '/api/miniprogram/super/articles/generate',
method: 'POST',
timeout: 120000,
data: {
userId,
description: prompt,
referenceText,
materialUrls,
imageUrls,
},
})
if (!res?.success) throw new Error(res?.error || '生成失败')
const title = String(res.data?.title || '').trim()
const content = String(res.data?.content || '').trim()
let merged = content
if (title && merged) merged = `${title}\n\n${merged}`
else if (title) merged = title
if (!merged.trim()) throw new Error('生成结果不完整')
this.setData({ title: '', content: merged.trim() })
wx.showToast({ title: '已填入正文', icon: 'success' })
} catch (e) {
wx.showToast({
title: (e && e.message) || '生成失败',
icon: 'none',
duration: 2800,
})
} finally {
this.setData({ generating: false })
}
},
async submitArticle() {
if (this.data.auditMode || app.globalData.auditMode) return
if (this.data.saving) return
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const content = String(this.data.content || '').trim()
if (this.data.bodyImages.some((x) => x.uploading)) {
wx.showToast({ title: '请等待配图上传完成', icon: 'none' })
return
}
const images = this.data.bodyImages
.map((x) => String(x.url || '').trim())
.filter(Boolean)
if (!content && images.length === 0) {
wx.showToast({ title: '请填写文字或添加配图', icon: 'none' })
return
}
const editId = String(this.data.editArticleId || '').trim()
this.setData({ saving: true })
wx.showLoading({ title: editId ? '提交中…' : '发布中…', mask: true })
try {
let res
if (editId) {
res = await app.request({
url: `/api/miniprogram/super/articles/${encodeURIComponent(editId)}`,
method: 'PUT',
data: { userId, title: '', content, images },
})
} else {
res = await app.request({
url: '/api/miniprogram/super/articles',
method: 'POST',
data: { userId, title: '', content, images },
})
}
if (!res?.success) throw new Error(res?.error || '发布失败')
if (editId) {
wx.showToast({ title: '已提交审核', icon: 'success' })
setTimeout(() => wx.navigateBack(), 450)
} else {
this.setData({ title: '', content: '', bodyImages: [] })
wx.showToast({ title: '已提交审核', icon: 'success' })
}
} catch (e) {
wx.showToast({ title: e.message || '发布失败', icon: 'none' })
} finally {
try {
wx.hideLoading()
} catch (_) {}
this.setData({ saving: false })
}
},
goBack() {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/my/my' }) })
},
})

View File

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

View File

@@ -0,0 +1,80 @@
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack">
<icon name="chevron-left" size="44" color="rgba(255,255,255,0.85)"></icon>
</view>
<text class="nav-title">{{editArticleId ? '编辑动态' : '发动态'}}</text>
<view class="nav-placeholder"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- AI参考图片 + 提示词开关showAiWriteSection -->
<view wx:if="{{showAiWriteSection}}" class="card">
<text class="section-title">AI 帮你写</text>
<text class="label">参考图片(可多选)</text>
<view class="file-actions">
<view class="add-file-btn" bindtap="chooseReferenceImages"> 添加图片</view>
</view>
<view wx:for="{{attachedFiles}}" wx:key="id" class="file-row">
<view class="file-main">
<text class="file-name">{{item.name}}</text>
<text wx:if="{{item.uploading}}" class="file-meta">上传中…</text>
<text wx:elif="{{item.kind === 'text'}}" class="file-meta ok">文本已载入</text>
<text wx:elif="{{item.kind === 'image'}}" class="file-meta ok">图片</text>
<text wx:else class="file-meta ok">附件</text>
</view>
<text class="file-remove" data-id="{{item.id}}" bindtap="removeAttachedFile">移除</text>
</view>
<text class="hint">从相册或相机添加参考图,可多选(单次最多 9 张),上传后参与 AI 生成。单张建议不超 30MB。</text>
<text class="label">提示词</text>
<view class="textarea-wrap">
<textarea class="textarea prompt-textarea" maxlength="2000" placeholder="描述主题、语气、要点…(可与参考图组合)" value="{{aiPrompt}}" bindinput="onAiPromptInput" disabled="{{auditMode}}"></textarea>
</view>
<view class="generate-btn {{generating ? 'generate-btn-loading' : ''}}" bindtap="generateArticle">{{generating ? '生成中…' : '生成'}}</view>
</view>
<!-- 朋友圈式:先文字,再九宫格配图(+ 在最后) -->
<view class="card moment-card">
<view class="toolbar">
<view class="tool-btn" bindtap="insertMentionSelf">@自己</view>
<view class="tool-btn" bindtap="insertHashLink">#链接</view>
</view>
<view class="textarea-wrap moment-textarea-wrap">
<textarea
class="textarea moment-textarea"
maxlength="5000"
placeholder="这一刻的想法…"
value="{{content}}"
bindinput="onContentInput"
disabled="{{auditMode}}"
auto-height
show-confirm-bar="{{false}}"
></textarea>
</view>
<view class="moment-photo-grid">
<view wx:for="{{bodyImages}}" wx:key="id" class="moment-photo-cell">
<image wx:if="{{item.url}}" class="moment-photo-img" mode="aspectFill" src="{{item.url}}" />
<view wx:if="{{item.uploading}}" class="moment-photo-mask">上传中…</view>
<text class="moment-photo-remove" data-id="{{item.id}}" catchtap="removeArticleImage">×</text>
</view>
<view wx:if="{{bodyImages.length < 9}}" class="moment-photo-cell moment-photo-add" bindtap="chooseArticleImages">
<text class="moment-photo-add-plus">+</text>
</view>
</view>
<text class="hint moment-hint">配图 {{bodyImages.length}}/9 · 展示在文字下方 · 单张建议不超 30MB · 可直接输入 @昵称 或 #链接(https://xxx)</text>
</view>
<!-- 底部发布(避让微信胶囊) -->
<view wx:if="{{!auditMode}}" class="bottom-publish-bar">
<view class="bottom-publish-btn {{saving ? 'bottom-publish-btn-disabled' : ''}}" bindtap="submitArticle">
<text>{{saving ? (editArticleId ? '提交中…' : '发布中…') : (editArticleId ? '提交审核' : '发布')}}</text>
</view>
</view>
<view wx:if="{{auditMode}}" class="audit-mode-mask" catchtouchmove="preventMove">
<text class="audit-mode-text">开发中...</text>
</view>
</view>

View File

@@ -0,0 +1,248 @@
.page {
min-height: 100vh;
background: #0b1220;
color: #fff;
box-sizing: border-box;
padding-bottom: calc(168rpx + env(safe-area-inset-bottom));
}
.nav-bar {
position: fixed; left: 0; right: 0; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
height: 44px; padding: 0 24rpx; background: rgba(5, 11, 20, 0.9);
}
.nav-back { width: 64rpx; flex-shrink: 0; display: flex; align-items: center; }
.nav-placeholder { width: 64rpx; flex-shrink: 0; }
.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 700; }
.bottom-publish-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: rgba(11, 18, 32, 0.96);
border-top: 1rpx solid rgba(94, 234, 212, 0.12);
}
.bottom-publish-btn {
height: 92rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #22d3ee, #14b8a6);
color: #032b35;
font-size: 32rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.bottom-publish-btn-disabled {
opacity: 0.65;
pointer-events: none;
}
.card {
margin: 24rpx; padding: 28rpx; border-radius: 24rpx;
background: rgba(15, 23, 42, 0.86); border: 1rpx solid rgba(148, 163, 184, 0.2);
}
.section-title {
display: block; font-size: 26rpx; font-weight: 700; color: #e2e8f0;
margin-bottom: 20rpx; letter-spacing: 2rpx;
}
.label { display: block; font-size: 24rpx; color: #cbd5e1; margin-bottom: 12rpx; }
.input-wrap, .textarea-wrap { background: rgba(2, 6, 23, 0.75); border-radius: 16rpx; padding: 18rpx 20rpx; }
.input { width: 100%; color: #fff; font-size: 28rpx; background: transparent; }
.file-actions { margin-bottom: 12rpx; }
.add-file-btn {
display: inline-flex; align-items: center; justify-content: center;
padding: 14rpx 28rpx; border-radius: 16rpx; font-size: 26rpx; font-weight: 600;
color: #22d3ee; border: 1rpx solid rgba(34, 211, 238, 0.45);
background: rgba(8, 47, 73, 0.45);
}
.file-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16rpx; padding: 16rpx 18rpx; margin-bottom: 12rpx;
border-radius: 14rpx; background: rgba(2, 6, 23, 0.55); border: 1rpx solid rgba(148, 163, 184, 0.12);
}
.file-main { flex: 1; min-width: 0; }
.file-name { display: block; font-size: 26rpx; color: #f1f5f9; word-break: break-all; line-height: 1.45; }
.file-meta { display: block; font-size: 22rpx; color: #94a3b8; margin-top: 6rpx; }
.file-meta.ok { color: #5eead4; }
.file-remove {
flex-shrink: 0; font-size: 24rpx; color: #f97316; padding: 8rpx 12rpx;
}
.body-img-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-bottom: 12rpx;
}
.body-img-cell {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 14rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.18);
}
.body-img-thumb {
width: 100%;
height: 100%;
display: block;
}
.body-img-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
}
.body-img-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.prompt-textarea { min-height: 200rpx; }
.generate-btn {
margin-top: 20rpx; height: 88rpx; border-radius: 16rpx;
border: 1rpx solid rgba(34, 211, 238, 0.55); color: #a5f3fc; font-size: 30rpx; font-weight: 700;
display: flex; align-items: center; justify-content: center;
background: rgba(8, 47, 73, 0.45);
}
.generate-btn-loading { opacity: 0.7; pointer-events: none; }
.toolbar { margin-top: 12rpx; margin-bottom: 12rpx; display: flex; gap: 12rpx; }
.tool-btn {
padding: 10rpx 18rpx; border-radius: 999rpx; font-size: 22rpx;
color: #67e8f9; border: 1rpx solid rgba(34, 211, 238, 0.35); background: rgba(8, 47, 73, 0.45);
}
.textarea {
width: 100%; min-height: 380rpx; color: #fff; font-size: 28rpx; line-height: 1.65;
background: transparent;
}
.hint { display: block; margin-top: 10rpx; color: #94a3b8; font-size: 22rpx; }
/* 发动态 · 配图宫格(参考朋友圈) */
.moment-card .toolbar {
margin-top: 0;
}
.moment-textarea-wrap {
margin-top: 8rpx;
padding: 20rpx 22rpx;
}
.moment-textarea {
min-height: 240rpx;
width: 100%;
font-size: 30rpx;
line-height: 1.65;
}
.moment-photo-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
margin-top: 20rpx;
}
.moment-photo-cell {
position: relative;
width: calc((100% - 32rpx) / 3);
padding-bottom: calc((100% - 32rpx) / 3);
height: 0;
border-radius: 12rpx;
overflow: hidden;
background: rgba(2, 6, 23, 0.65);
border: 1rpx solid rgba(148, 163, 184, 0.15);
box-sizing: border-box;
}
.moment-photo-img {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.moment-photo-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
color: #94a3b8;
background: rgba(15, 23, 42, 0.72);
z-index: 1;
}
.moment-photo-remove {
position: absolute;
right: 6rpx;
top: 4rpx;
width: 44rpx;
height: 44rpx;
line-height: 40rpx;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: #fff;
background: rgba(0, 0, 0, 0.45);
border-radius: 999rpx;
z-index: 2;
}
.moment-photo-add {
border: 2rpx dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.35);
display: flex;
align-items: center;
justify-content: center;
}
.moment-photo-add-plus {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -52%);
font-size: 64rpx;
font-weight: 300;
color: rgba(148, 163, 184, 0.85);
line-height: 1;
}
.moment-hint {
margin-top: 16rpx;
}
/* 审核模式:盖住整页(含自定义导航),高于 .nav-bar 的 z-index: 10 */
.audit-mode-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(11, 18, 32, 0.94);
}
.audit-mode-text {
font-size: 36rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.88);
letter-spacing: 4rpx;
}

View File

@@ -0,0 +1,266 @@
/**
* Soul创业派对 - 我的动态(含待审/通过/驳回,驳回可跳转编辑)
*/
const app = getApp()
const PAGE_SIZE = 10
const THUMB_MAX = 3
function relativeTime(iso) {
if (!iso) return ''
const d = new Date(iso)
const t = d.getTime()
if (Number.isNaN(t)) return ''
const sec = Math.max(0, Math.floor((Date.now() - t) / 1000))
if (sec < 60) return '刚刚'
if (sec < 3600) return `${Math.floor(sec / 60)}分钟前`
if (sec < 86400) return `${Math.floor(sec / 3600)}小时前`
if (sec < 86400 * 7) return `${Math.floor(sec / 86400)}天前`
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${m}-${day}`
}
function auditLabel(st) {
const s = String(st || '').toLowerCase()
if (s === 'pending') return '审核中'
if (s === 'rejected') return '已驳回'
return '已发布'
}
function parseImagesField(images) {
if (images == null) return []
if (typeof images === 'string') {
const s = images.trim()
if (!s) return []
try {
const p = JSON.parse(s)
return Array.isArray(p) ? p : []
} catch (_) {
return []
}
}
return Array.isArray(images) ? images : []
}
function normalizeArticleImageSrc(raw) {
const u = String(raw || '').trim()
if (!u || u === 'undefined' || u === 'null') return ''
if (/^https?:\/\//i.test(u)) return u
if (u.startsWith('//')) return `https:${u}`
if (u.startsWith('wxfile://') || u.startsWith('cloud://')) return u
const base = String((app.globalData && app.globalData.baseUrl) || '').replace(/\/$/, '')
if (u.startsWith('/') && base) return `${base}${u}`
return u
}
function buildThumbSlots(imagesField) {
const rawList = parseImagesField(imagesField)
const n = Math.min(rawList.length, THUMB_MAX)
const thumbSlots = []
for (let i = 0; i < n; i++) {
const srcNorm = normalizeArticleImageSrc(String(rawList[i] || '').trim())
const ok =
!!srcNorm &&
(/^https?:\/\//i.test(srcNorm) ||
srcNorm.startsWith('wxfile://') ||
srcNorm.startsWith('cloud://'))
thumbSlots.push({ slotIdx: i, src: ok ? srcNorm : '', loadFailed: false })
}
return {
showThumbRow: rawList.length > 0,
thumbSlots,
thumbMoreCount: rawList.length > THUMB_MAX ? rawList.length - THUMB_MAX : 0,
}
}
function mapRows(rows) {
return (rows || []).map((r) => {
const st = String(r.auditStatus || 'approved').toLowerCase()
const thumb = buildThumbSlots(r.images)
return Object.assign({}, r, {
auditStatus: st === 'pending' || st === 'rejected' ? st : 'approved',
auditLabel: auditLabel(st),
timeText: relativeTime(r.createdAt),
preview: String(r.preview || '').trim(),
rejectReason: String(r.rejectReason || '').trim(),
showThumbRow: thumb.showThumbRow,
thumbSlots: thumb.thumbSlots,
thumbMoreCount: thumb.thumbMoreCount,
})
})
}
Page({
data: {
statusBarHeight: 44,
auditMode: false,
list: [],
page: 1,
total: 0,
hasMore: true,
loading: true,
loadingMore: false,
},
onLoad() {
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44,
auditMode: !!app.globalData.auditMode,
})
try {
app.getAuditMode && app.getAuditMode().catch(() => {})
} catch (_) {}
this.reload()
},
onShow() {
this.setData({ auditMode: !!app.globalData.auditMode })
},
goCompose() {
if (app.globalData.auditMode) {
wx.showToast({ title: '当前为体验版,暂无法访问', icon: 'none' })
return
}
wx.navigateTo({ url: '/pages/super-article-editor/super-article-editor' })
},
onReachBottom() {
this.loadMore()
},
goBack() {
wx.navigateBack({ fail: () => wx.switchTab({ url: '/pages/my/my' }) })
},
noop() {},
openDetail(e) {
const id = e.currentTarget?.dataset?.id
if (!id) return
wx.navigateTo({ url: `/pages/read/read?superArticleId=${encodeURIComponent(String(id))}` })
},
openEdit(e) {
const id = e.currentTarget?.dataset?.id
if (!id) return
wx.navigateTo({ url: `/pages/super-article-editor/super-article-editor?id=${encodeURIComponent(String(id))}` })
},
tapDeleteArticle(e) {
const id = e.currentTarget?.dataset?.id
if (!id || this._articleDeleting) return
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
wx.showModal({
title: '删除动态',
content: '删除后不可恢复,确定删除?',
confirmText: '删除',
confirmColor: '#dc2626',
success: async (r) => {
if (!r.confirm) return
this._articleDeleting = true
wx.showLoading({ title: '删除中…', mask: true })
try {
await app.request({
url: `/api/miniprogram/super/articles/${encodeURIComponent(String(id))}?userId=${encodeURIComponent(String(userId))}`,
method: 'DELETE',
})
wx.hideLoading()
this._articleDeleting = false
const list = this.data.list.filter((x) => String(x.id) !== String(id))
const total = Math.max(0, (Number(this.data.total) || 0) - 1)
this.setData({
list,
total,
hasMore: list.length < total,
})
wx.showToast({ title: '已删除', icon: 'success' })
} catch (_) {
wx.hideLoading()
this._articleDeleting = false
/* app.request 已 toast */
}
},
})
},
onThumbError(e) {
const rowIdx = Number(e.currentTarget.dataset.rowidx)
const slotIdx = Number(e.currentTarget.dataset.slotidx)
if (Number.isNaN(rowIdx) || Number.isNaN(slotIdx)) return
this.setData({ [`list[${rowIdx}].thumbSlots[${slotIdx}].loadFailed`]: true })
},
previewRowImages(e) {
const rowIdx = Number(e.currentTarget.dataset.rowidx)
if (Number.isNaN(rowIdx)) return
const row = this.data.list[rowIdx]
if (!row) return
const urls = parseImagesField(row.images)
.map((u) => normalizeArticleImageSrc(String(u || '').trim()))
.filter((u) => /^https?:\/\//i.test(u))
if (!urls.length) return
const current = String(e.currentTarget.dataset.current || '').trim() || urls[0]
wx.previewImage({ urls, current })
},
async reload() {
const userId = app.globalData.userInfo?.id
if (!userId || !app.globalData.isLoggedIn) {
wx.showToast({ title: '请先登录', icon: 'none' })
setTimeout(() => wx.navigateBack(), 400)
return
}
this.setData({ loading: true, page: 1, list: [], hasMore: true })
try {
const res = await app.request({
url: `/api/miniprogram/super/articles/mine?userId=${encodeURIComponent(String(userId))}&page=1&pageSize=${PAGE_SIZE}`,
silent: true,
})
const raw = Array.isArray(res?.list) ? res.list : []
const list = mapRows(raw)
const total = Number(res?.total) || 0
this.setData({
list,
total,
hasMore: list.length < total,
loading: false,
page: 1,
})
} catch (_) {
this.setData({ loading: false, list: [] })
}
},
async loadMore() {
if (this.data.loading || this.data.loadingMore || !this.data.hasMore) return
const userId = app.globalData.userInfo?.id
if (!userId) return
const nextPage = this.data.page + 1
this.setData({ loadingMore: true })
try {
const res = await app.request({
url: `/api/miniprogram/super/articles/mine?userId=${encodeURIComponent(String(userId))}&page=${nextPage}&pageSize=${PAGE_SIZE}`,
silent: true,
})
const raw = Array.isArray(res?.list) ? res.list : []
const chunk = mapRows(raw)
const merged = this.data.list.concat(chunk)
const total = Number(res?.total) || this.data.total
this.setData({
list: merged,
page: nextPage,
total,
hasMore: merged.length < total,
loadingMore: false,
})
} catch (_) {
this.setData({ loadingMore: false })
}
},
})

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