Merge branch 'devlop' into yongxu-dev

# Conflicts:
#	miniprogram/pages/read/read.js   resolved by devlop version
#	miniprogram/pages/read/read.wxml   resolved by devlop version
#	miniprogram/project.private.config.json   resolved by devlop version
#	miniprogram/utils/mpPagePopups.js   resolved by devlop version
#	soul-admin/dist/assets/index-BRyXRtx1.js   resolved by devlop version
#	soul-admin/dist/assets/index-CZFUM7K_.js   resolved by devlop version
#	soul-admin/dist/assets/index-i0PBc3Gp.js   resolved by devlop version
#	soul-admin/dist/index.html   resolved by devlop version
#	soul-admin/src/pages/settings/MpUiPopupTableSection.tsx   resolved by devlop version
#	soul-admin/src/pages/settings/mpUiCopyConfig.ts   resolved by devlop version
#	soul-api/internal/handler/book.go   resolved by devlop version
#	soul-api/internal/handler/db.go   resolved by devlop version
This commit is contained in:
Alex-larget
2026-03-27 20:28:44 +08:00
35 changed files with 2756 additions and 437 deletions

View File

@@ -12,7 +12,7 @@ alwaysApply: true
0. **默认零提问**:派对/Soul 相关开发、运维、脚本、填表链路,**禁止**反问用户「是否执行」「要不要」。缺信息则读本仓库代码与配置、用合理默认推进;**仅**验证码/密钥缺失/不可逆删除等无法代劳时,用**一句**说明缺什么。
1. **语言**:面向用户的说明、结论、按钮文案解释等,默认 **简体中文**。
2. **收尾**:每一轮对用户可见的助手回复,**最后一段**必须是完整 **[卡若复盘]YYYY-MM-DD HH:mm** 块,含五段:**🎯 目标·结果·达成率**单行30 字且含达成率 %)·**📌 过程** · **💡 反思** · **📝 总结** · **▶ 下一步执行**。复盘块内**禁止表格**。
2. **收尾**:每一轮对用户可见的助手回复,**最后一段**必须是完整 **[卡若复盘]YYYY-MM-DD HH:mm** 块,含五段:**🎯 目标·结果·达成率****单行一句50 字**,句内含达成率 **%**、**可负**;分发任务按视频号等**实际成功÷计划**计;**禁止** ➡️/📊 复述行与标准 ☯)·**📌 过程** · **💡 反思** · **📝 总结** · **▶ 下一步执行**。复盘块内**禁止表格**。细则以卡若 `运营中枢/参考资料/卡若复盘格式_固定规则.md` **v5.0** 为准。
3. **格式源**:与卡若 AI 仓库内 `运营中枢/参考资料/卡若复盘格式_固定规则.md` 保持同一种写法若当前工作区已挂载「卡若AI」目录修改复盘规则时以该文件为唯一真源。
4. **需求节奏**:仍服从「需求即执行」——先「好」再改代码再报结果;复盘块放在**全条回复最末**。
5. **直接执行**:用户说「直接做、别讲写了什么」时,**正文极短****复盘五块不可省**,可压缩过程为 12 条要点。

View File

@@ -53,3 +53,107 @@ git config --global credential.helper osxkeychain
- 脚本内部:`.cursor/scripts/gitea-sync.log`
- launchd 标准输出:`.cursor/scripts/gitea-sync-launchd.log`
- launchd 错误:`.cursor/scripts/gitea-sync-launchd.err.log`
## 跨网克隆失败502 / `RPC failed` / `curl 18` / `early EOF`
现象:`git clone http://open.quwanzhi.com:3000/...` 枚举对象到 100% 后断线,或一开始就 **502 Bad Gateway**。仓库约 2.5 万对象时较常见,**根因多在服务端反代或链路**,客户端可做降级与重试。
### 1. 优先:浅克隆 + 按需补历史Windows `cmd` 示例)
先少传数据,成功率最高:
```bat
git config --global http.postBuffer 524288000
git clone --depth 1 --single-branch --branch devlop http://open.quwanzhi.com:3000/fnvtk/soul-yongping.git
cd soul-yongping
git fetch --unshallow
```
仍断线可再试 **partial clone**Git 2.22+
```bat
git clone --filter=blob:none --depth 1 --single-branch --branch devlop http://open.quwanzhi.com:3000/fnvtk/soul-yongping.git
```
失败目录可删后重试;或进入半成品目录执行 `git fetch` 多次Git 会续传。
### 2. 客户端其它设置(可选)
```bat
git config --global http.version HTTP/1.1
git config --global core.compression 0
```
压缩关掉会多占带宽,有时能避开中间设备对「大块压缩流」的处理问题。
### 3. 能走内网时改用局域网 Gitea
文档顶部 **gitea-local**`192.168.1.201:3000`)通常比公网 `:3000` 稳定;在公司/VPN 内优先用内网地址克隆。
### 3.1 内网 `192.168.1.201:3000` 仍 502 / early EOF
说明:**内网和公网同一症状 = 问题在 Gitea 本机或前面的反代**,不是「你电脑网络」 alone。
**先确认服务是否活着(在能访问内网的机器上):**
```bat
curl -sI http://192.168.1.201:3000/
```
-**立刻 502**:多为 **Nginx或宝塔反代连不上 Gitea 进程**Gitea 挂了、监听端口不对、防火墙)。
-**Web 能开、只有 git clone 断**:多为 **反代超时 / 缓冲** 不适合大块 `git-upload-pack` 流。
**有服务器权限时(宝塔 / Nginx**:在 **指向 Gitea 的那一段** 加大超时、关掉对 Git 流的缓冲(上游端口以你机器为准,常见 Gitea 在 `127.0.0.1:3000` 或其它端口,勿照抄错):
```nginx
client_max_body_size 512M;
proxy_connect_timeout 300s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
proxy_buffering off;
proxy_request_buffering off;
```
改完后 **`nginx -t && nginx -s reload`**,并 **重启 Gitea**。仍 502 时看 **`error.log`** 里 `upstream timed out` / `connection refused` 对应哪一层。
**若短期无法改服务器**:用下面 **离线 bundle**,从已有完整仓库的机器拷一份到 Windows不经过 HTTP 大包传输)。
### 4. 离线绕过git bundle推荐不依赖 Gitea HTTP 稳定)
**Mac / 已能拉全仓库的机器**(本仓库根目录)执行:
```bash
chmod +x .cursor/scripts/create-offline-bundle.sh
.cursor/scripts/create-offline-bundle.sh devlop
```
会在仓库根生成 `soul-yongping-devlop.bundle`,拷到 Windows 后:
```bat
git clone E:\路径\soul-yongping-devlop.bundle soul-yongping
cd soul-yongping
git remote add origin http://192.168.1.201:3000/fnvtk/soul-yongping.git
git fetch origin
```
日常 `pull`/`push` 仍走 Gitea若 push 仍断,再与运维修反代。
### 5. Windows 一键重试克隆(仍依赖服务端正常)
将本仓库拷到 Windows 或只拷脚本后,在 `cmd` 中进入脚本目录:
```bat
set REPO=http://192.168.1.201:3000/fnvtk/soul-yongping.git
set BRANCH=devlop
clone-soul-yongping-windows.bat
```
(需登录时第一次会提示账号密码,或事先 `git config --global credential.helper manager` 并手动 `git ls-remote` 存凭证。)
### 6. 服务端(有权限时):反代与 Gitea摘要
502 / 传一半断线,除第 3.1 节参数外,还需保证 **Nginx `proxy_pass` 指向的 Gitea 进程在线**。官方反代说明:<https://docs.gitea.com/installation/reverse-proxies>
### 7. 安全提醒
勿在截图/聊天记录里长期暴露「用户名:密码@」完整 URL改用 **访问令牌** + 凭证管理器Windows`git config --global credential.helper manager`)。

View File

@@ -0,0 +1,37 @@
@echo off
chcp 65001 >nul
setlocal EnableDelayedExpansion
REM 按需修改:内网或公网;账号密码勿长期写死,建议用访问令牌 + credential manager
if not defined REPO set "REPO=http://192.168.1.201:3000/fnvtk/soul-yongping.git"
if not defined BRANCH set "BRANCH=devlop"
git config --global http.postBuffer 524288000
git config --global http.version HTTP/1.1
if exist soul-yongping (
echo 目录 soul-yongping 已存在,请先改名或删除后再运行。
exit /b 1
)
echo [1/3] 浅克隆 --depth 1 ...
git clone --depth 1 --single-branch --branch "%BRANCH%" "%REPO%" soul-yongping
if !errorlevel! equ 0 goto unshallow
echo [2/3] 失败,尝试 partial clone ...
git clone --filter=blob:none --depth 1 --single-branch --branch "%BRANCH%" "%REPO%" soul-yongping
if !errorlevel! equ 0 goto unshallow
echo [3/3] 仍失败。502/early EOF 多为服务器 Nginx 超时或 Gitea 上游异常,见 README-gitea-sync.md「内网仍 502」与离线 bundle。
exit /b 1
:unshallow
cd soul-yongping
echo 拉全历史 git fetch --unshallow ...
git fetch --unshallow
if !errorlevel! neq 0 (
echo unshallow 未完成可多执行几次: git fetch --unshallow
)
cd ..
echo 完成。
exit /b 0

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# 在「已有完整仓库」的机器上生成 bundle拷到 U 盘/共享盘后 Windows: git clone xxx.bundle soul-yongping
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
BRANCH="${1:-devlop}"
OUT="${2:-$ROOT/soul-yongping-${BRANCH}.bundle}"
git rev-parse --verify "$BRANCH" >/dev/null
git bundle create "$OUT" "$BRANCH"
echo "已生成: $OUT"
echo "Windows 示例: git clone %CD%\\soul-yongping-${BRANCH}.bundle soul-yongping"
echo "后续补远程: cd soul-yongping && git remote add origin http://192.168.1.201:3000/fnvtk/soul-yongping.git"

3
.gitignore vendored
View File

@@ -49,3 +49,6 @@ soul-api/soul-api-new
# 本地技能包临时打包目录
.tmp_skill_bundle/
# 从宝塔拉取的线上运行目录镜像(含 .env / 二进制,勿提交)
_server_live/

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
import argparse
import importlib.util
import re
import sys
from pathlib import Path
@@ -44,15 +45,17 @@ def strip_md_title_line(text: str) -> str:
def for_miniprogram_body(text: str) -> str:
"""上传 README少用 --- 分割线;正文内独立一行的 --- 改为空行分段"""
"""上传 README少用 --- 与 **独立一行的 --- 改为空行;去掉 **;压缩多余空行"""
out_lines: list[str] = []
for line in text.splitlines():
if line.strip() == "---":
out_lines.append("")
out_lines.append("")
else:
out_lines.append(line)
return "\n".join(out_lines).strip() + "\n"
body = "\n".join(out_lines)
body = body.replace("**", "")
body = re.sub(r"\n{3,}", "\n\n", body)
return body.strip() + "\n"
def next_10_id(cur) -> str:

View File

@@ -11,8 +11,8 @@ const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
// baseUrl 手动切换(注释方式):
// const API_BASE_URL = 'http://localhost:8080'
const API_BASE_URL = 'https://soulapi.quwanzhi.com'
const API_BASE_URL = 'http://localhost:8080'
// const API_BASE_URL = 'https://soulapi.quwanzhi.com'
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.2'

View File

@@ -0,0 +1,57 @@
# 碎片时间小程序 · 认证任务众包 SOP
> 流程口径:平台只撮合任务与验收;账号来源、合规与平台规则由发起方自负,建议在合同/任务说明里写清。
---
## 角色
| 角色 | 说明 |
|:---|:---|
| 发起方(你) | 发任务、采购号、审核截图、打款 |
| 接单方 | 登录指定账号、完成认证、上传成功截图 |
---
## 步骤
**Step 0产品**
碎片时间小程序具备:发布任务、任务详情、接单/认领(或群内确认后你在后台绑定接单方)、上传凭证、发起方审核、标记完成/打款(或线下打款后在小程序确认)。
**Step 1生成任务**
在小程序里创建「认证类」任务:写清平台名称、认证类型、截止时间、赏金、**需上传的验收材料**(如:认证成功页截图)。
**Step 2丢到群里**
把任务链接或任务卡片发到目标群,说明谁接谁在小程序里认领(或私信你登记,由你在后台绑定该用户为执行人)。
**门禁:必须等到明确有人接单/认领后,再进入 Step 3。**
**Step 3采购认证号**
接单已锁定后,再到淘宝等平台购买符合任务要求的认证号(店铺、类目、是否已部分认证等按任务说明买)。
**Step 4交付登录信息**
**账号、密码、绑定手机号**(及验证码接收方式,若需要)通过约定渠道发给接单方;附 **极简认证流程**(要点的什么、上传什么、注意哪几条),避免长篇说明书。
**Step 5跟进认证**
接单方登录并按流程完成认证;页面显示 **已成功** 后,截取 **带成功状态** 的截图。
**Step 6上传与验收**
接单方在小程序任务里 **上传成功截图**;发起方核对截图与任务要求一致后 **审核通过并打款**,任务关闭。
---
## 验收清单(发起方勾选)
- [ ] 群内/小程序已锁定唯一接单方,未锁人不买号
- [ ] 账号信息与绑定手机已完整交给接单方
- [ ] 截图含明确「认证成功」或平台等价状态,且在任务约定平台内
- [ ] 赏金金额与打款方式与任务页一致
---
## 异常处理(简)
| 情况 | 处理 |
|:---|:---|
| 买号后接单方失联 | 任务重新挂群;原接单方在小程序取消认领规则需产品支持 |
| 认证失败 | 先区分是号问题还是操作问题;换号或换人按任务规则是否补赏金,事先写进任务说明 |
| 截图不清晰 | 退回补传,限次数写在任务里 |

View File

@@ -348,7 +348,7 @@ Page({
.replace(/\{\{prefix\}\}/g, prefix)
.trim() || baseTitle
} else if (prefix) {
mainTitle = `${prefix} · ${nm}`
mainTitle = baseTitle
} else {
mainTitle = `@${nm}`
}

View File

@@ -12,6 +12,10 @@ const soulBridge = require('../../utils/soulBridge.js')
const { trackClick } = require('../../utils/trackClick')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 从「我的」登录成功后 reLaunch 回本页并自动继续链接流程 */
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
Page({
data: { statusBarHeight: 44, navBarTotalPx: 88, member: null, loading: true, isOwnProfile: false },
@@ -21,25 +25,63 @@ Page({
const sb = app.globalData.statusBarHeight || 44
const myId = app.globalData.userInfo?.id
const isOwnProfile = !!(options.id && myId && String(options.id) === String(myId))
if (options.resumeLink === '1') this._resumeLinkAfterLoad = true
this.setData({ statusBarHeight: sb, navBarTotalPx: sb + 44, isOwnProfile })
if (options.id) this.loadMember(options.id)
},
/** 朋友圈单页等受限环境 */
_isSinglePageMode() {
try {
if (app.globalData.isSinglePageMode) return true
const sys = wx.getSystemInfoSync()
if (sys && sys.mode === 'singlePage') return true
const launch = typeof wx.getLaunchOptionsSync === 'function' ? wx.getLaunchOptionsSync() : null
if (launch && Number(launch.scene) === 1154) return true
} catch (e) {}
return false
},
/** 资料加载完成后:① resumeLink=1我的页登录回流② 本地存了待链接 memberId单页→完整版且已登录 */
_scheduleResumeLinkFlowIfNeeded() {
if (!this.data.member || !app.globalData.isLoggedIn) return
let trigger = false
if (this._resumeLinkAfterLoad) {
trigger = true
this._resumeLinkAfterLoad = false
} else {
try {
const rid = wx.getStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
if (rid != null && String(rid).trim() !== '' && String(rid) === String(this.data.member.id)) {
wx.removeStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
trigger = true
}
} catch (_) {}
}
if (!trigger) return
if (this._resumeLinkScheduled) return
this._resumeLinkScheduled = true
setTimeout(() => {
this._resumeLinkScheduled = false
this.startLinkFlow()
}, 400)
},
/** 本人名片:去完整编辑资料(单页) */
goMyProfileEdit() {
wx.navigateTo({ url: '/pages/profile-edit/profile-edit?full=1' })
},
/**
* 未登录解锁前:先展示后台可配的「链接 vs 解锁」说明用户确认后再弹出登录引导mpUi.memberDetailPage
* 未登录解锁前:先展示后台可配的「链接 vs 解锁」说明mpUi.pagePopupItemsunlockIntroTitle / unlockIntroBody兼容旧 memberDetailPage
*/
_showUnlockIntroThenLogin(afterConfirm) {
const mp = app.globalData.configCache?.mpConfig?.mpUi?.memberDetailPage || {}
const title = String(mp.unlockIntroTitle || '解锁与链接说明').trim() || '解锁与链接说明'
const body = String(
mp.unlockIntroBody ||
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。\n\n请确认已了解后再登录。',
).trim()
const title =
mpPagePopups.getMemberDetailContent(app, 'unlockIntroTitle') ||
'解锁与链接说明'
const body =
mpPagePopups.getMemberDetailContent(app, 'unlockIntroBody') ||
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。\n\n请确认已了解后再登录。'
wx.showModal({
title,
content: body,
@@ -91,6 +133,7 @@ Page({
}),
loading: false
})
this._scheduleResumeLinkFlowIfNeeded()
return
}
} catch (e) {}
@@ -100,7 +143,11 @@ Page({
const res = await app.request({ url: `/api/miniprogram/vip/members?id=${id}`, silent: true })
if (res?.success && res.data) {
const d = Array.isArray(res.data) ? res.data[0] : res.data
if (d) { this.setData({ member: this.enrichAndFormat(d), loading: false }); return }
if (d) {
this.setData({ member: this.enrichAndFormat(d), loading: false })
this._scheduleResumeLinkFlowIfNeeded()
return
}
}
} catch (e) {}
@@ -124,11 +171,13 @@ Page({
helpNeed: u.helpNeed || u.help_need,
ckbLeadToken: u.ckbLeadToken || u.ckb_lead_token,
}), loading: false })
this._scheduleResumeLinkFlowIfNeeded()
return
}
}
} catch (e) {}
this.setData({ loading: false })
this._resumeLinkAfterLoad = false
},
// 将空值、「未填写」、纯空格均视为未填写(用于隐藏对应项)
@@ -314,12 +363,41 @@ Page({
trackClick('member_detail', 'avatar_click', '链接头像_' + (member.id || ''))
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
// 第一步:仅说明原因;第二步:引导登录(完整版去「我的」;单页引导底部「前往小程序」)
wx.showModal({
title: `链接「${nickname}`,
content: '请先登录后再发起链接。',
confirmText: '去登录',
title: '提示',
content: `发起与「${nickname}」的链接前,需要先登录小程序账号。登录后智能助手与人工可协同协助您对接。`,
confirmText: '下一步',
cancelText: '取消',
success: (r) => { if (r.confirm) wx.switchTab({ url: '/pages/my/my' }) }
success: (r1) => {
if (!r1.confirm) return
const isSp = this._isSinglePageMode()
if (isSp) {
try {
wx.setStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY, String(member.id))
} catch (_) {}
wx.showModal({
title: '前往完整小程序',
content: '当前为预览模式。请轻触屏幕底部「前往小程序」进入完整版,在「我的」中登录;登录成功后将自动回到本页并继续链接流程。',
showCancel: false,
confirmText: '我知道了'
})
return
}
wx.showModal({
title: `链接「${nickname}`,
content: '是否前往「我的」页登录?',
confirmText: '去登录',
cancelText: '取消',
success: (r2) => {
if (!r2.confirm) return
try {
wx.setStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY, String(member.id))
} catch (_) {}
wx.switchTab({ url: '/pages/my/my' })
}
})
}
})
return
}

View File

@@ -871,6 +871,17 @@ Page({
this.initUserStatus()
this.setData({ showLoginModal: false })
wx.showToast({ title: '登录成功', icon: 'success' })
// 超级个体详情:未登录点链接 → 去登录 → 回详情页自动继续链接流程(与 member-detail LOGIN_RESUME_MEMBER_DETAIL_KEY 一致)
try {
const LOGIN_RESUME_MEMBER_DETAIL_KEY = 'login_resume_member_detail_id'
const rid = wx.getStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
if (rid != null && String(rid).trim() !== '') {
wx.removeStorageSync(LOGIN_RESUME_MEMBER_DETAIL_KEY)
wx.reLaunch({
url: '/pages/member-detail/member-detail?id=' + encodeURIComponent(String(rid).trim()) + '&resumeLink=1'
})
}
} catch (_) {}
},
// 点击菜单

View File

@@ -24,6 +24,28 @@ const soulBridge = require('../../utils/soulBridge.js')
const app = getApp()
const mpPagePopups = require('../../utils/mpPagePopups.js')
/** 与后端 defaultReadPreviewUI / read_preview_ui 配置键一致;占位符 {percent} {price} 在拉章节后替换 */
const READ_UI_DEFAULTS = {
singlePageUnlockTitle: '解锁完整内容',
singlePagePayButtonText: '支付 ¥{price} 解锁全文',
singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。',
payTapModalTitle: '解锁说明',
payTapModalContent:
'全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
fullUnlockTitle: '解锁完整内容',
fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明',
fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读',
fullPaywallTip: '转发给需要的人,一起学习还能赚佣金',
notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文',
notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金',
shareTipLine: '好友经你分享购买,你可获得约 90% 收益',
momentsModalTitle: '分享到朋友圈',
momentsModalContent:
'已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
momentsClipboardFooter: '\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——',
timelineTitleSuffix: '(预览{percent}%',
}
/** 阅读页解析正文用:人物字典 + #标签(与 /config/read-extras 一致) */
function getContentParseConfig() {
const g = getApp().globalData || {}
@@ -39,16 +61,8 @@ function getContentParseConfig() {
return { persons, linkTags }
}
/** 章节详情接口正文:以 data.content 为准(兼容旧缓存顶层 content */
function chapterApiContent(res) {
if (!res) return ''
const fromData = res.data && res.data.content
if (fromData != null && String(fromData).length) return fromData
return res.content || ''
}
/** 补全 mentionDisplay避免旧数据无字段昵称去空白防「@ 名」 */
/** 试读比例data.previewPercent有效试读%);兼容旧响应顶层 previewPercent缺省 20 */
/** 试读比例:优先 data.previewPercent章节私有),否则顶层 previewPercent(全局 unpaid_preview_percent缺省 20 */
function normalizePreviewPercent(res) {
if (!res) return 20
const tryNum = (v) => {
@@ -56,8 +70,9 @@ function normalizePreviewPercent(res) {
if (!isNaN(n) && n >= 1 && n <= 100) return n
return undefined
}
const fromData = res.data && res.data.previewPercent
return tryNum(fromData) ?? tryNum(res.previewPercent) ?? 20
const inner = res.data && res.data.previewPercent
const outer = res.previewPercent
return tryNum(inner) ?? tryNum(outer) ?? 20
}
function normalizeMentionSegments(segments) {
@@ -142,17 +157,15 @@ Page({
// 阅读进度
readingProgress: 0,
/** 未解锁付费墙:试读比例来自接口 data.previewPercent有效试读% */
/** 未解锁付费墙:合并 data.previewPercent章节)与顶层 previewPercent全局 */
previewPercent: 20,
/** 未解锁时:按 previewPercent 裁切可见高度px。0 表示未启用/待测量 */
previewMaxHeightPx: 0,
/** mpUi.readPage.beforeLoginHint未登录付费墙上方说明文案 */
readBeforeLoginHint: '',
/** 单页长文案title + sub + 分销pagePopupItemskey 沿用 singlePage* */
readSinglePageTitle: '解锁完整内容',
readSinglePagePaywallSub: '预览页不能直接付款,务必先点底栏「前往小程序」。',
readSinglePageShareLine1: '好友经你分享购买,你可获得约 90% 收益',
readSinglePageShareLine2: '转发给需要的人,一起学习还能赚佣金',
/** 朋友圈单页标题与说明mpUi.readPage.singlePageTitle / singlePagePaywallHint */
readSinglePageTitle: '解锁全文',
readSinglePageHint: '',
showPaywall: false,
// 上一篇/下一篇
@@ -195,6 +208,11 @@ Page({
readSinglePageMode: false,
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal
momentsPaywallExpanded: false,
// 未付费预览比例(与后端 unpaid_preview_percent / 章节 preview_percent 一致)
effectivePreviewPercent: 20,
// 付费墙 / 朋友圈文案(后端 read_preview_ui + 占位符已替换)
readUi: {},
},
_isLockedState(state) {
@@ -202,9 +220,8 @@ Page({
},
/**
* 未解锁试读:先不设 max-heightpreviewMaxHeightPx=0让正文与图片在 DOM 里完整排版;
* 再量 #previewContentMeasure 总高度,按 previewPercent 换算 max-height + overflow:hidden
* 图片 widthFix 异步增高后由 onPreviewContentImageLoad 防抖再次测量。
* 未解锁时:先渲染完整正文,再测量其高度,按 previewPercent 计算可见高度并裁切。
* 这样后端无需截断,避免 HTML/段落被截断导致的渲染失败
*/
_updatePreviewClipHeight() {
const state = this.data.accessState
@@ -245,16 +262,6 @@ Page({
apply().catch(() => {})
},
/** 预览区内图片加载完成:布局高度变化,防抖后重新按完整高度裁切 */
onPreviewContentImageLoad() {
if (!this._isLockedState(this.data.accessState)) return
if (this._previewClipImgTimer) clearTimeout(this._previewClipImgTimer)
this._previewClipImgTimer = setTimeout(() => {
this._previewClipImgTimer = null
this._updatePreviewClipHeight()
}, 200)
},
/**
* 是否处于朋友圈等「单页预览」环境。
* 兼容:部分机型/基础库首帧 getSystemInfoSync().mode 未就绪,需结合 launch/enter scene 1154、getWindowInfo。
@@ -288,15 +295,61 @@ Page({
return !!app.globalData.isSinglePageMode
},
/** 单页模式下点「支付解锁」行:触觉反馈;文案与底栏箭头默认已展开,不再弹长 Modal */
_formatPriceToken(n) {
const x = Number(n)
if (!isFinite(x)) return '1'
if (Math.abs(x - Math.round(x)) < 1e-9) return String(Math.round(x))
return String(Number(x.toFixed(2)))
},
/** 章节接口 meta预览比例 + 付费墙/朋友圈文案模板 */
_applyChapterMetaFromResponse(res) {
if (!res || typeof res !== 'object') return
const pct =
typeof res.unpaidPreviewPercent === 'number' ? res.unpaidPreviewPercent : 20
const priceNum =
res.price != null
? Number(res.price)
: this.data.section?.price != null
? Number(this.data.section.price)
: Number(this.data.sectionPrice || 1)
const priceTok = this._formatPriceToken(priceNum)
const base = { ...READ_UI_DEFAULTS }
if (res.readPreviewUi && typeof res.readPreviewUi === 'object') {
Object.keys(res.readPreviewUi).forEach((k) => {
const v = res.readPreviewUi[k]
if (typeof v === 'string' && v.trim()) base[k] = v.trim()
})
}
const filled = {}
Object.keys(base).forEach((k) => {
filled[k] = String(base[k] || '')
.replace(/\{percent\}/g, String(pct))
.replace(/\{price\}/g, priceTok)
})
this.setData({ effectivePreviewPercent: pct, readUi: filled })
},
/** 单页模式:点支付解锁 → 弹窗说明 → 展开底栏引导与箭头 */
onUnlockTapInSinglePage() {
trackClick('read', 'btn_click', '单页_解锁引导')
try {
wx.vibrateShort({ type: 'light' })
} catch (e) {}
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
const ui = this.data.readUi || {}
wx.showModal({
title: ui.payTapModalTitle || '解锁说明',
content:
ui.payTapModalContent ||
'请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
showCancel: false,
confirmText: '知道了',
success: () => {
if (this._detectReadSinglePage() && typeof this.setData === 'function') {
this.setData({ readSinglePageMode: true, momentsPaywallExpanded: true })
}
},
})
},
onShow() {
@@ -304,7 +357,7 @@ Page({
this.setData({
auditMode: app.globalData.auditMode || false,
readSinglePageMode: sp,
...(sp ? { momentsPaywallExpanded: true } : { momentsPaywallExpanded: false }),
...(sp ? {} : { momentsPaywallExpanded: false }),
})
},
@@ -326,25 +379,17 @@ Page({
let readSinglePageTitle =
mpPagePopups.getReadPageContent(app, 'singlePageTitle') ||
String((mp.mpUi && mp.mpUi.readPage && mp.mpUi.readPage.singlePageTitle) || '').trim() ||
'解锁完整内容'
readSinglePageTitle = readSinglePageTitle || '解锁完整内容'
const readSinglePagePaywallSub =
mpPagePopups.getReadPageContent(app, 'singlePagePaywallSub') ||
'预览页不能直接付款,务必先点底栏「前往小程序」。'
const readSinglePageShareLine1 =
mpPagePopups.getReadPageContent(app, 'singlePageShareEarn1') ||
'好友经你分享购买,你可获得约 90% 收益'
const readSinglePageShareLine2 =
mpPagePopups.getReadPageContent(app, 'singlePageShareEarn2') ||
'转发给需要的人,一起学习还能赚佣金'
'解锁全文'
readSinglePageTitle = readSinglePageTitle || '解锁全文'
const readSinglePageHint =
mpPagePopups.getReadPageContent(app, 'singlePagePaywallHint') ||
String((mp.mpUi && mp.mpUi.readPage && mp.mpUi.readPage.singlePagePaywallHint) || '').trim()
if (typeof this.setData === 'function') {
this.setData({
auditMode,
readBeforeLoginHint,
readSinglePageTitle,
readSinglePagePaywallSub,
readSinglePageShareLine1,
readSinglePageShareLine2,
readSinglePageHint,
})
}
}
@@ -387,7 +432,6 @@ Page({
return
}
const spMode = this._detectReadSinglePage()
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight,
@@ -396,8 +440,8 @@ Page({
loading: true,
accessState: 'unknown',
pendingGiftRequestSn: giftRequestSn || '',
readSinglePageMode: spMode,
momentsPaywallExpanded: spMode,
readSinglePageMode: this._detectReadSinglePage(),
momentsPaywallExpanded: false,
})
if (ref) {
@@ -555,9 +599,10 @@ Page({
const parseCfg = getContentParseConfig()
const sectionPrice = this.data.sectionPrice ?? 1
let res = prefetchedChapter
if (!res || !chapterApiContent(res)) {
if (!res || !res.content) {
res = await app.request({ url: this._getChapterUrl({ id }), silent: true })
}
this._applyChapterMetaFromResponse(res)
const section = {
id: res.id || id,
title: res.sectionTitle || res.title || this.getSectionTitle(id),
@@ -567,7 +612,8 @@ Page({
this.setData({ section })
// 规则更新:小程序端始终使用“完整正文”渲染;未解锁时通过前端裁切可见比例实现预览
const displayContent = chapterApiContent(res)
// 兼容老返回:完整正文可能在 res.data.content老链路只有 res.content
const displayContent = (res.data?.content ?? res.content)
if (res && displayContent) {
const { lines, segments } = contentParser.parseContent(displayContent, parseCfg)
// 预览比例由前端按高度裁切,这里渲染完整内容(避免后端截断导致渲染失败)
@@ -585,7 +631,8 @@ Page({
// 先关闭裁切,等 DOM 更新后再测量并按比例裁切
this.setData({ ...updates, previewMaxHeightPx: 0 })
this._updatePreviewClipHeight()
try { wx.setStorageSync(cacheKey, { ...res }) } catch (_) {}
// 写入本地缓存(存 displayContent供离线/重试降级使用)
try { wx.setStorageSync(cacheKey, { ...res, content: displayContent }) } catch (_) {}
if (accessManager.canAccessFullContent(accessState)) {
app.markSectionAsRead(id)
}
@@ -595,14 +642,13 @@ Page({
console.error('[Read] 加载内容失败,尝试本地缓存:', e)
try {
const cached = wx.getStorageSync(cacheKey)
const cachedBody = cached && chapterApiContent(cached)
if (cached && cachedBody) {
if (cached && cached.content) {
await app.getReadExtras()
const { lines, segments } = contentParser.parseContent(cachedBody, getContentParseConfig())
const { lines, segments } = contentParser.parseContent(cached.content, getContentParseConfig())
// 预览比例由前端按高度裁切,这里渲染完整内容
const previewCount = lines.length
this.setData({
content: cachedBody,
content: cached.content,
contentParagraphs: lines,
contentSegments: normalizeMentionSegments(segments),
previewParagraphs: lines.slice(0, previewCount),
@@ -708,9 +754,9 @@ Page({
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML
async setChapterContent(res) {
this._applyChapterMetaFromResponse(res)
await app.getReadExtras()
const body = chapterApiContent(res)
const { lines, segments } = contentParser.parseContent(body, getContentParseConfig())
const { lines, segments } = contentParser.parseContent(res.content, getContentParseConfig())
// 预览内容由后端统一截取比例,这里展示全部预览内容
const previewCount = lines.length
const sectionPrice = this.data.sectionPrice ?? 1
@@ -724,7 +770,7 @@ Page({
isFree: res.isFree === true || (res.price !== undefined && res.price === 0),
price: res.price ?? sectionPrice
},
content: body,
content: res.content,
previewContent: lines.slice(0, previewCount).join('\n'),
contentParagraphs: lines,
contentSegments: normalizeMentionSegments(segments),
@@ -740,7 +786,7 @@ Page({
async silentRefresh(id) {
try {
const res = await this.fetchChapterWithTimeout(id, 10000)
if (res && chapterApiContent(res)) {
if (res && res.content) {
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 后台缓存更新成功:', id)
}
@@ -763,7 +809,7 @@ Page({
setTimeout(async () => {
try {
const res = await this.fetchChapterWithTimeout(id, 8000)
if (res && chapterApiContent(res)) {
if (res && res.content) {
await this.setChapterContent(res)
wx.setStorageSync(`chapter_${id}`, res)
console.log('[Read] 重试成功:', id, '第', currentRetry + 1, '次')
@@ -867,7 +913,7 @@ Page({
wx.showToast({ title: '暂无跳转地址', icon: 'none' })
},
// 点击正文图片 → 全屏预览(预览区图片同时 bindload 触发 onPreviewContentImageLoad
// 点击正文图片 → 全屏预览
onImageTap(e) {
const src = e.currentTarget.dataset.src
if (!src) return
@@ -1097,14 +1143,12 @@ Page({
shareToMoments() {
trackClick('read', 'btn_click', '分享到朋友圈_' + (this.data.sectionId || ''))
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
const raw = (this.data.content || '')
.replace(/<[^>]+>/g, '\n')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"')
.replace(/[#@]\S+/g, '')
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
const picked = sentences.slice(0, 5)
const copyText = picked.length > 0 ? title + '\n\n' + picked.join('\n\n') : `🔥 刚看完这篇《${title}》,推荐给你!\n\n#卡若创业派对 #真实商业故事`
const ui = this.data.readUi || {}
const paras = (this.data.previewParagraphs || []).filter(Boolean).join('\n\n')
const footer = ui.momentsClipboardFooter || ''
const copyText = paras
? `${title}\n\n${paras}${footer}`
: `${title}${footer || `\n\n—— 预览约 ${this.data.effectivePreviewPercent ?? 20}% ,搜「卡若创业派对」小程序阅读全文 ——`}`
wx.setClipboardData({
data: copyText,
success: () => {
@@ -1113,8 +1157,10 @@ Page({
setTimeout(() => {
wx.hideToast()
wx.showModal({
title: '分享到朋友圈',
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
title: ui.momentsModalTitle || '分享到朋友圈',
content:
ui.momentsModalContent ||
'已复制发圈文案。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
showCancel: false,
confirmText: '知道了'
})
@@ -1128,15 +1174,18 @@ Page({
// 分享到朋友圈:带文章标题,过长时截断
onShareTimeline() {
const { section, sectionId, sectionMid, chapterTitle } = this.data
const { section, sectionId, sectionMid, chapterTitle, readUi } = this.data
const ref = app.getMyReferralCode()
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
const query = ref ? `${q}&ref=${ref}` : q
const articleTitle = (section?.title || chapterTitle || '').trim()
const title = articleTitle
? (articleTitle.length > 28 ? articleTitle.slice(0, 28) + '...' : articleTitle)
: '卡若创业派对 - 真实商业故事'
return { title, query }
const base = articleTitle || '卡若创业派对 - 真实商业故事'
const suffix = (readUi && readUi.timelineTitleSuffix) || ''
let full = base + suffix
if (full.length > 30) {
full = full.slice(0, 30) + '…'
}
return { title: full, query }
},
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)

View File

@@ -106,7 +106,7 @@
</button>
</view>
<view class="share-tip-inline" wx:if="{{!auditMode}}">
<text class="share-tip-text">分享后好友购买,你可获得 {{shareRate || 90}}% 收益</text>
<text class="share-tip-text">{{readUi.shareTipLine || '好友经你分享购买,你可获得约 90% 收益'}}</text>
</view>
</view>
@@ -124,7 +124,7 @@
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
<text user-select wx:if="{{!(item.length === 1 && item[0].type === 'image')}}"><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text>
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindload="onPreviewContentImageLoad" bindtap="onImageTap" data-src="{{seg.src}}"></image>
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>
</view>
@@ -132,44 +132,40 @@
<view class="fade-mask"></view>
</view>
<!-- 付费墙 - 未登录:单页=短引导;完整端=长文案+登录 -->
<view class="preview-percent-row" wx:if="{{effectivePreviewPercent > 0}}">
<text class="preview-percent-badge">预览 {{effectivePreviewPercent}}%</text>
</view>
<!-- 付费墙 - 未登录:完整小程序登录+价;朋友圈单页与正文同款「购买本章 ¥1」点后再展开极简说明 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<!-- 单页预览:长文案(预览无法支付+分销);完整小程序:图二短文案 -->
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">{{readSinglePageTitle}}</text>
<text class="paywall-sp-lead" wx:if="{{!auditMode}}">全文 ¥{{section && section.price != null ? section.price : sectionPrice}}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。</text>
<view class="purchase-options paywall-sp-purchase-wrap" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 解锁全文</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
<text class="paywall-title">{{readUi.singlePageUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc" wx:if="{{readUi.fullUnlockDesc}}">{{readUi.fullUnlockDesc}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section purchase-btn--fulltext" bindtap="onUnlockTapInSinglePage">
<text class="btn-label btn-label--block">{{readUi.singlePagePayButtonText || '支付解锁'}}</text>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-sp-sub" wx:if="{{!auditMode}}">{{readSinglePagePaywallSub}}</text>
<view class="paywall-share-earn-wrap" wx:if="{{!auditMode}}">
<text class="share-tip-text">{{readSinglePageShareLine1}}</text>
<text class="share-tip-text paywall-share-earn-sub">{{readSinglePageShareLine2}}</text>
</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readUi.singlePageExpandedHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
</block>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint && !auditMode}}">{{readBeforeLoginHint}}</text>
<block wx:if="{{!auditMode}}">
<text class="paywall-sp-lead paywall-sp-lead--short">可先上滑阅读预览;需要全文时,点下方「支付 ¥{{section && section.price != null ? section.price : sectionPrice}}」查看说明</text>
<view class="purchase-options paywall-sp-purchase-wrap">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 解锁全文</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint}}">{{readBeforeLoginHint}}</text>
<text class="paywall-desc">{{readUi.notLoginUnlockDesc || '已预览部分内容,登录并支付后阅读全文'}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
</block>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
</view>
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
<text class="login-btn-text">手机号登录后购买</text>
</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
</block>
</view>
@@ -215,7 +211,7 @@
<view class="paragraph" wx:for="{{contentSegments}}" wx:key="index">
<text user-select wx:if="{{!(item.length === 1 && item[0].type === 'image')}}"><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text>
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindload="onPreviewContentImageLoad" bindtap="onImageTap" data-src="{{seg.src}}"></image>
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
</block>
</view>
</view>
@@ -223,53 +219,52 @@
<view class="fade-mask"></view>
</view>
<view class="preview-percent-row" wx:if="{{effectivePreviewPercent > 0}}">
<text class="preview-percent-badge">预览 {{effectivePreviewPercent}}%</text>
</view>
<!-- 付费墙 - 已登录未购买 -->
<view class="paywall {{readSinglePageMode ? 'paywall--single-preview' : ''}}">
<view class="paywall-icon"><icon name="lock" size="80" color="#00CED1"></icon></view>
<block wx:if="{{readSinglePageMode}}">
<text class="paywall-title">{{readSinglePageTitle}}</text>
<text class="paywall-sp-lead" wx:if="{{!auditMode}}">全文 ¥{{section && section.price != null ? section.price : sectionPrice}}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。</text>
<view class="purchase-options paywall-sp-purchase-wrap" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="onUnlockTapInSinglePage">
<text class="btn-label">支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 解锁全文</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
<text class="paywall-title">{{readUi.singlePageUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc" wx:if="{{readUi.fullUnlockDesc}}">{{readUi.fullUnlockDesc}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section purchase-btn--fulltext" bindtap="onUnlockTapInSinglePage">
<text class="btn-label btn-label--block">{{readUi.singlePagePayButtonText || '支付解锁'}}</text>
</view>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-sp-sub" wx:if="{{!auditMode}}">{{readSinglePagePaywallSub}}</text>
<view class="paywall-share-earn-wrap" wx:if="{{!auditMode}}">
<text class="share-tip-text">{{readSinglePageShareLine1}}</text>
<text class="share-tip-text paywall-share-earn-sub">{{readSinglePageShareLine2}}</text>
</view>
<text class="paywall-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readUi.singlePageExpandedHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
</block>
<block wx:else>
<text class="paywall-title">解锁完整内容</text>
<block wx:if="{{!auditMode}}">
<text class="paywall-sp-lead paywall-sp-lead--short">可先上滑阅读预览;需要全文时,点下方「支付 ¥{{section && section.price != null ? section.price : sectionPrice}}」查看说明</text>
<view class="purchase-options paywall-sp-purchase-wrap">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 解锁全文</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
<text class="paywall-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
<text class="paywall-desc">{{readUi.fullUnlockDesc || '可先上滑阅读预览'}}</text>
<text class="paywall-subdesc" wx:if="{{readUi.fullLockedProgressText}}">{{readUi.fullLockedProgressText}}</text>
<view class="purchase-options" wx:if="{{!auditMode}}">
<view class="purchase-btn purchase-section" bindtap="handlePurchaseSection">
<text class="btn-label">购买本章</text>
<text class="btn-price brand-color">¥{{section && section.price != null ? section.price : sectionPrice}}</text>
</view>
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="purchase-btn purchase-fullbook" bindtap="handlePurchaseFullBook" wx:if="{{purchasedCount >= 3}}">
<view class="btn-left">
<icon name="sparkles" size="32" color="#FFD700" customClass="btn-sparkle"></icon>
<text class="btn-label">解锁全部 {{totalSections}} 章</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice}}</text>
<text class="btn-discount">省82%</text>
</view>
<view class="btn-right">
<text class="btn-price">¥{{fullBookPrice}}</text>
<text class="btn-discount">省82%</text>
</view>
</view>
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
</block>
</view>
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.fullPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
<view class="gift-share-row" bindtap="showGiftShareModal" wx:if="{{isLoggedIn && !auditMode}}">
<icon name="gift" size="40" color="#00CED1" customClass="gift-share-icon"></icon>
<text class="gift-share-text">代付分享</text>
</view>
</block>
</view>
@@ -415,7 +410,7 @@
<!-- 单页预览(朋友圈):指向底栏「前往小程序」,字少;完整小程序仍保留发圈悬浮钮 -->
<view class="singlepage-launch-pointer" wx:if="{{readSinglePageMode && momentsPaywallExpanded}}" aria-hidden="true">
<view class="singlepage-launch-pointer__arrow"></view>
<view class="singlepage-launch-pointer__arrow"></view>
</view>
<view class="fab-share-moments" wx:if="{{!readSinglePageMode}}" bindtap="shareToMoments" hover-class="fab-share-moments-hover" aria-label="分享到朋友圈">

View File

@@ -355,6 +355,41 @@
margin-bottom: 48rpx;
}
.paywall-subdesc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.52);
text-align: center;
display: block;
margin-bottom: 36rpx;
line-height: 1.5;
padding: 0 16rpx;
}
.preview-percent-row {
margin: 16rpx 0 8rpx;
display: flex;
justify-content: center;
}
.preview-percent-badge {
font-size: 24rpx;
color: #00CED1;
border: 2rpx solid rgba(0, 206, 209, 0.35);
border-radius: 999rpx;
padding: 8rpx 24rpx;
background: rgba(0, 206, 209, 0.08);
}
.purchase-btn--fulltext {
justify-content: center;
}
.purchase-btn--fulltext .btn-label--block {
flex: 1;
text-align: center;
width: 100%;
}
/* ===== 购买选项 ===== */
.purchase-options {
display: flex;

View File

@@ -23,13 +23,6 @@
"condition": {
"miniprogram": {
"list": [
{
"name": "pages/read/read",
"pathName": "pages/read/read",
"query": "mid=233",
"scene": null,
"launchMode": "singlePage"
},
{
"name": "个人资料",
"pathName": "pages/avatar-nickname/avatar-nickname",

View File

@@ -4,7 +4,7 @@
*
* 当前代码显式引用的键(与 soul-admin 默认种子 / db.defaultMpUi 一致,勿改 key 除非双端同步):
* - MEMBER_PATH unlockIntroTitle, unlockIntroBody → member-detail.js _showUnlockIntroThenLogin
* - READ_PATH beforeLoginHint, singlePageTitle, singlePagePaywallHint(旧), singlePagePaywallSub, singlePageShareEarn1/2单页长文案区 → read.js onLoad
* - READ_PATH beforeLoginHint, singlePageTitle, singlePagePaywallHint → read.js onLoad
*/
const MEMBER_PATH = '/pages/member-detail/member-detail'
@@ -45,9 +45,6 @@ function getReadPageContent(app, key) {
if (key === 'beforeLoginHint') return String(rp.beforeLoginHint || '').trim()
if (key === 'singlePageTitle') return String(rp.singlePageTitle || '').trim()
if (key === 'singlePagePaywallHint') return String(rp.singlePagePaywallHint || '').trim()
if (key === 'singlePagePaywallSub') return String(rp.singlePagePaywallSub || '').trim()
if (key === 'singlePageShareEarn1') return String(rp.singlePageShareEarn1 || '').trim()
if (key === 'singlePageShareEarn2') return String(rp.singlePageShareEarn2 || '').trim()
return ''
}

153
scripts/pull_from_baota.py Normal file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从宝塔正式机拉取线上运行目录到本地镜像(与 soul-api/master.py、soul-admin/master.py 同源 SSH 配置)。
说明:
- 服务器上一般是「二进制 + .env + 日志」与「静态 dist」不包含完整 Go/React 源码。
- 默认解压到仓库根目录 _server_live/soul-api、_server_live/soul-admin不覆盖本地工程源码。
环境变量与 master.py 一致DEPLOY_HOST、DEPLOY_USER、DEPLOY_PASSWORD、DEPLOY_SSH_KEY、
DEPLOY_PROJECT_PATH、DEPLOY_BASE_PATH。
"""
from __future__ import print_function
import argparse
import importlib.util
import os
import shutil
import sys
import tarfile
import tempfile
import threading
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
SOUL_API_DIR = os.path.join(ROOT, "soul-api")
def _load_api_master():
path = os.path.join(SOUL_API_DIR, "master.py")
spec = importlib.util.spec_from_file_location("soul_api_deploy_master", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _pull_dir_tar(client, remote_dir, local_dir, mod, timeout=600):
"""远端 tar czf 流式下载并解压到 local_dir。"""
import shlex
remote_q = shlex.quote(remote_dir)
cmd = "tar czf - -C %s . 2>/dev/null" % remote_q
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
err_holder = []
def _drain():
try:
err_holder.append(stderr.read())
except Exception:
pass
t = threading.Thread(target=_drain)
t.daemon = True
t.start()
fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz")
os.close(fd)
try:
with open(tmp_path, "wb") as out:
while True:
chunk = stdout.read(256 * 1024)
if not chunk:
break
out.write(chunk)
t.join(timeout=5)
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0:
print(" [警告] 远端 tar 退出码: %s" % exit_status)
if os.path.isdir(local_dir):
shutil.rmtree(local_dir)
os.makedirs(local_dir, exist_ok=True)
with tarfile.open(tmp_path, "r:gz") as tf:
tf.extractall(local_dir)
print(" [成功] 已同步到: %s" % local_dir)
return True
finally:
try:
os.remove(tmp_path)
except OSError:
pass
def main():
parser = argparse.ArgumentParser(description="从宝塔拉取 soul-api / soul-admin 线上目录")
parser.add_argument("--api-only", action="store_true", help="仅拉 soul-api")
parser.add_argument("--admin-only", action="store_true", help="仅拉 soul-admin")
args = parser.parse_args()
mod = _load_api_master()
cfg = mod.get_cfg()
if not cfg.get("password") and not cfg.get("ssh_key"):
print("[失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return 1
pull_api = not args.admin_only
pull_admin = not args.api_only
if args.api_only and args.admin_only:
print("[失败] 不能同时指定 --api-only 与 --admin-only")
return 1
client = None
try:
client = mod._connect_ssh(cfg)
live_root = os.path.join(ROOT, "_server_live")
os.makedirs(live_root, exist_ok=True)
print("=" * 60)
print(" 从宝塔拉取线上目录 → %s" % live_root)
print(" 主机: %s@%s:%s" % (cfg["user"], cfg["host"], mod.DEFAULT_SSH_PORT))
print("=" * 60)
if pull_api:
print("[1] soul-api: %s" % cfg["project_path"])
_pull_dir_tar(
client,
cfg["project_path"],
os.path.join(live_root, "soul-api"),
mod,
)
if pull_admin:
admin_base = os.environ.get("DEPLOY_BASE_PATH", "/www/wwwroot/self/soul-admin").rstrip("/")
print("[2] soul-admin: %s" % admin_base)
# 复用同一连接;若仅拉 admin上面未开新连接也行
if not pull_api:
pass
_pull_dir_tar(
client,
admin_base,
os.path.join(live_root, "soul-admin"),
mod,
)
print("")
print(" 完成。镜像根目录: %s" % live_root)
return 0
except Exception as e:
print("[失败] %s" % e)
import traceback
traceback.print_exc()
return 1
finally:
if client:
try:
client.close()
except Exception:
pass
if __name__ == "__main__":
sys.exit(main() or 0)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1010
soul-admin/dist/assets/index-CXKq85jN.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-CZFUM7K_.js"></script>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-CXKq85jN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BfljfNs2.css">
</head>
<body>
<div id="root"></div>
</body>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -9,6 +9,7 @@ import {
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import RichEditor, { type PersonItem, type LinkTagItem, type RichEditorRef } from '@/components/RichEditor'
@@ -57,6 +58,33 @@ import { PersonAddEditModal, type PersonFormData } from './PersonAddEditModal'
import { getPersonDetail } from '@/api/ckb'
import { apiUrl } from '@/api/client'
/** 与 soul-api mergeReadPreviewUI 默认键一致;小程序用 {percent} {price} 占位符替换 */
const READ_PREVIEW_UI_TEMPLATE = JSON.stringify(
{
singlePageUnlockTitle: '解锁完整内容',
singlePagePayButtonText: '支付 ¥{price} 解锁全文',
singlePageExpandedHint: '预览页不能直接付款,务必先点底栏「前往小程序」。',
payTapModalTitle: '解锁说明',
payTapModalContent:
'全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。',
fullUnlockTitle: '解锁完整内容',
fullUnlockDesc: '可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明',
fullLockedProgressText: '已阅读约 {percent}% ,购买后继续阅读',
fullPaywallTip: '转发给需要的人,一起学习还能赚佣金',
notLoginUnlockDesc: '已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文',
notLoginPaywallTip: '分享给好友一起学习,还能赚取佣金',
shareTipLine: '好友经你分享购买,你可获得约 90% 收益',
momentsModalTitle: '分享到朋友圈',
momentsModalContent:
'已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
momentsClipboardFooter:
'\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——',
timelineTitleSuffix: '(预览{percent}%',
},
null,
2,
)
interface SectionListItem {
id: string
mid?: number
@@ -261,6 +289,9 @@ export function ContentPage() {
const [previewPercent, setPreviewPercent] = useState(20)
const [previewPercentLoading, setPreviewPercentLoading] = useState(false)
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
const [readPreviewUiJson, setReadPreviewUiJson] = useState(READ_PREVIEW_UI_TEMPLATE)
const [readPreviewUiLoading, setReadPreviewUiLoading] = useState(false)
const [readPreviewUiSaving, setReadPreviewUiSaving] = useState(false)
const [persons, setPersons] = useState<PersonItem[]>([])
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
const [linkTagList, setLinkTagList] = useState<LinkTagItem[]>([])
@@ -801,15 +832,60 @@ export function ContentPage() {
} catch { toast.error('保存失败') } finally { setPreviewPercentSaving(false) }
}
const loadReadPreviewUi = useCallback(async () => {
setReadPreviewUiLoading(true)
try {
const data = await get<{ success?: boolean; data?: unknown }>(
'/api/db/config/full?key=read_preview_ui',
{ cache: 'no-store' as RequestCache },
)
const d = data && (data as { data?: unknown }).data
if (d != null && typeof d === 'object' && !Array.isArray(d) && Object.keys(d as object).length > 0) {
setReadPreviewUiJson(JSON.stringify(d, null, 2))
} else {
setReadPreviewUiJson(READ_PREVIEW_UI_TEMPLATE)
}
} catch {
setReadPreviewUiJson(READ_PREVIEW_UI_TEMPLATE)
} finally {
setReadPreviewUiLoading(false)
}
}, [])
const handleSaveReadPreviewUi = async () => {
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(readPreviewUiJson) as Record<string, unknown>
} catch {
toast.error('JSON 格式错误,请检查括号与引号')
return
}
setReadPreviewUiSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'read_preview_ui',
value: parsed,
description: '阅读页/朋友圈付费墙与分享文案(占位符 {percent} {price}',
})
if (res && (res as { success?: boolean }).success !== false) toast.success('阅读页文案已保存')
else toast.error('保存失败: ' + ((res as { error?: string }).error || ''))
} catch {
toast.error('保存失败')
} finally {
setReadPreviewUiSaving(false)
}
}
useEffect(() => {
loadPinnedSections()
loadPreviewPercent()
loadReadPreviewUi()
loadPersons()
loadLinkTags()
loadCkbLeadCounts()
loadLinkedMps()
loadCkbWebhookUrl()
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps, loadCkbWebhookUrl])
}, [loadPinnedSections, loadPreviewPercent, loadReadPreviewUi, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps, loadCkbWebhookUrl])
useEffect(() => {
loadLinkTagList()
@@ -2519,7 +2595,42 @@ export function ContentPage() {
>
{previewPercentSaving ? '保存中...' : '保存'}
</Button>
<span className="text-xs text-gray-500"> {previewPercent}% </span>
<span className="text-xs text-gray-500"> {previewPercent}% %</span>
</div>
<div className="mt-6 space-y-2">
<Label className="text-gray-400 text-sm"> / JSON</Label>
<p className="text-xs text-gray-500">
<code className="text-gray-400">{'{percent}'}</code>
<code className="text-gray-400">{'{price}'}</code>
</p>
<Textarea
className="bg-[#0a1628] border-gray-700 text-gray-200 font-mono text-xs min-h-[280px]"
value={readPreviewUiJson}
onChange={(e) => setReadPreviewUiJson(e.target.value)}
disabled={readPreviewUiLoading}
spellCheck={false}
/>
<div className="flex items-center gap-2 flex-wrap">
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-300"
onClick={() => loadReadPreviewUi()}
disabled={readPreviewUiLoading}
>
</Button>
<Button
type="button"
size="sm"
onClick={handleSaveReadPreviewUi}
disabled={readPreviewUiSaving || readPreviewUiLoading}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{readPreviewUiSaving ? '保存中...' : '保存文案配置'}
</Button>
</div>
</div>
</CardContent>
</Card>

View File

@@ -41,7 +41,6 @@ import type { PagePopupItem } from './mpUiCopyConfig'
import {
DEFAULT_POPUP_PAGE_PATH,
POPUP_PAGE_PATH_OPTIONS,
SEED_PAGE_POPUP_ITEMS,
displayPageName,
isValidPopupKey,
newPagePopupId,
@@ -194,10 +193,10 @@ export function MpUiPopupTableSection({
variant="outline"
size="sm"
className="border-gray-600 text-gray-200"
title={`用与小程序对齐的 ${SEED_PAGE_POPUP_ITEMS.length} 条标准文案替换当前列表,再点「保存设置」写入数据库。迁移完成后可移除此按钮。`}
title="用与小程序对齐的 5 条标准文案替换当前列表,再点「保存设置」写入数据库。迁移完成后可移除此按钮。"
onClick={() => {
setPagePopupItems(seedPagePopupItemsWithIds())
toast.info(`已填入标准 ${SEED_PAGE_POPUP_ITEMS.length} 条,请点击「保存设置」写入数据库`)
toast.info('已填入标准 5 条,请点击「保存设置」写入数据库')
}}
>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />

View File

@@ -40,11 +40,15 @@ import {
EyeOff,
LayoutGrid,
Sparkles,
MessageSquare,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
import { ApiDocsPage } from '@/pages/api-docs/ApiDocsPage'
import { MpUiPopupTableSection } from '@/pages/settings/MpUiPopupTableSection'
import type { PagePopupItem } from '@/pages/settings/mpUiCopyConfig'
import { buildMpUiPayload, splitMpUiForPopupEditor } from '@/pages/settings/mpUiCopyConfig'
interface AuthorInfo {
name?: string
@@ -76,7 +80,7 @@ interface MpConfig {
mchId?: string
minWithdraw?: number
auditMode?: boolean
/** 小程序界面文案与跳转,与 soul-api defaultMpUi 结构一致,服务端会与默认值深合并 */
/** mpUi含 pagePopupItems 等;服务端与 defaultMpUi 深合并 */
mpUi?: Record<string, unknown>
}
@@ -119,59 +123,10 @@ const defaultFeatures: FeatureConfig = {
aboutEnabled: true,
}
/** 与管理端保存后、后端 deepMergeMpUi 的默认结构对齐,供「填入模板」与文档说明 */
const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
tabBar: { home: '首页', chapters: '目录', match: '找伙伴', my: '我的' },
chaptersPage: {
bookTitle: '一场SOUL的创业实验场',
bookSubtitle: '来自Soul派对房的真实商业故事',
newBadgeText: 'NEW',
},
// homePage.linkKaruoAvatar首页「链接卡若」头像 HTTPS空则小程序用「卡」字占位
homePage: {
logoTitle: '卡若创业派对',
logoSubtitle: '来自派对房的真实故事',
linkKaruoText: '点击链接卡若',
linkKaruoAvatar: '',
pinnedTitlePrefix: '派对会员',
pinnedMainTitleTemplate: '',
searchPlaceholder: '搜索章节标题或内容...',
bannerTag: '推荐',
bannerReadMoreText: '点击阅读',
superSectionTitle: '超级个体',
superSectionLinkText: '获客入口',
superSectionLinkPath: '/pages/match/match',
pickSectionTitle: '精选推荐',
latestSectionTitle: '最新新增',
},
myPage: {
cardLabel: '名片',
vipLabelVip: '会员中心',
vipLabelGuest: '成为会员',
cardPath: '',
vipPath: '/pages/vip/vip',
readStatLabel: '已读章节',
recentReadTitle: '最近阅读',
readStatPath: '/pages/reading-records/reading-records?focus=all',
recentReadPath: '/pages/reading-records/reading-records?focus=recent',
},
memberDetailPage: {
unlockIntroTitle: '解锁与链接说明',
unlockIntroBody:
'「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。请先阅读说明,确认后再登录。',
},
readPage: {
beforeLoginHint: '试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。',
singlePageTitle: '解锁全文',
singlePagePaywallHint:
'当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。',
},
}
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
const SYSTEM_SECTION_KEYS = ['basic', 'mp', 'oss', 'features'] as const
const SYSTEM_SECTION_KEYS = ['basic', 'mp', 'mp-copy', 'oss', 'features'] as const
type SystemSectionKey = (typeof SYSTEM_SECTION_KEYS)[number]
export function SettingsPage() {
@@ -186,8 +141,10 @@ export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [mpUiJson, setMpUiJson] = useState('{}')
const [chaptersNewBadgeText, setChaptersNewBadgeText] = useState('NEW')
/** 不在 pagePopupItems 内的 mpUi 顶层键tabBar、homePage 等),保存时写回 */
const [mpUiExtra, setMpUiExtra] = useState<Record<string, unknown>>({})
/** 弹窗文案列表mpUi.pagePopupItems */
const [pagePopupItems, setPagePopupItems] = useState<PagePopupItem[]>([])
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
@@ -220,22 +177,9 @@ export function SettingsPage() {
if (res.mpConfig && typeof res.mpConfig === 'object') {
const merged = { ...res.mpConfig } as MpConfig
setMpConfig((prev) => ({ ...prev, ...merged }))
const raw = merged.mpUi
const rawObj =
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
const chaptersPage =
rawObj.chaptersPage && typeof rawObj.chaptersPage === 'object' && !Array.isArray(rawObj.chaptersPage)
? (rawObj.chaptersPage as Record<string, unknown>)
: {}
const badgeRaw = chaptersPage.newBadgeText ?? chaptersPage.sectionNewBadgeText
setChaptersNewBadgeText(typeof badgeRaw === 'string' && badgeRaw.trim() ? badgeRaw.trim() : 'NEW')
setMpUiJson(
JSON.stringify(
rawObj,
null,
2,
),
)
const { extra, pagePopupItems: rows } = splitMpUiForPopupEditor(merged.mpUi)
setMpUiExtra(extra)
setPagePopupItems(rows)
}
if (res.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
@@ -317,34 +261,7 @@ export function SettingsPage() {
const handleSave = async () => {
setIsSaving(true)
try {
let mpUi: Record<string, unknown> = {}
try {
const t = mpUiJson.trim()
if (t) {
const parsed: unknown = JSON.parse(t)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
mpUi = parsed as Record<string, unknown>
} else {
showResult('保存失败', '小程序文案 mpUi 须为 JSON 对象(非数组)', true)
setIsSaving(false)
return
}
}
} catch {
showResult('保存失败', '小程序文案 mpUi 不是合法 JSON', true)
setIsSaving(false)
return
}
const chaptersPageRaw = mpUi.chaptersPage
const chaptersPage =
chaptersPageRaw && typeof chaptersPageRaw === 'object' && !Array.isArray(chaptersPageRaw)
? { ...(chaptersPageRaw as Record<string, unknown>) }
: {}
const badgeText = chaptersNewBadgeText.trim()
if (badgeText) chaptersPage.newBadgeText = badgeText
else delete chaptersPage.newBadgeText
delete chaptersPage.sectionNewBadgeText
mpUi.chaptersPage = chaptersPage
const mpUi = buildMpUiPayload(pagePopupItems, mpUiExtra)
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
featureConfig,
@@ -487,6 +404,13 @@ export function SettingsPage() {
<Smartphone className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="mp-copy"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
>
<MessageSquare className="w-3.5 h-3.5 mr-1" />
</TabsTrigger>
<TabsTrigger
value="oss"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 text-xs"
@@ -767,46 +691,9 @@ export function SettingsPage() {
/>
</div>
</div>
<div className="space-y-2 pt-2 border-t border-gray-700/50">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"> NEW </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="例如NEW / 最新 / 刚更新"
value={chaptersNewBadgeText}
maxLength={8}
onChange={(e) => setChaptersNewBadgeText(e.target.value)}
/>
<p className="text-xs text-gray-500">
5 mpUi JSON
</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-gray-300"> mpUiJSON</Label>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-200"
onClick={() => setMpUiJson(JSON.stringify(MP_UI_TEMPLATE_OBJECT, null, 2))}
>
</Button>
</div>
<p className="text-xs text-gray-500">
Tab / 5
config
</p>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm min-h-[280px]"
spellCheck={false}
value={mpUiJson}
onChange={(e) => setMpUiJson(e.target.value)}
/>
</div>
<p className="text-xs text-gray-500 pt-2 border-t border-gray-700/50">
Tab + pagePopupItemsTab
</p>
</CardContent>
</Card>
@@ -843,7 +730,15 @@ export function SettingsPage() {
/>
</div>
</CardContent>
</Card>
</Card>
</TabsContent>
<TabsContent value="mp-copy" className="space-y-6 mt-0">
<MpUiPopupTableSection
pagePopupItems={pagePopupItems}
setPagePopupItems={setPagePopupItems}
extraKeysCount={Object.keys(mpUiExtra).length}
/>
</TabsContent>
<TabsContent value="oss" className="space-y-6 mt-0">

View File

@@ -162,10 +162,7 @@ export function migrateLegacyPagePopups(raw: unknown): PagePopupItem[] {
const pairs: [string, string, PagePopupScope][] = [
['beforeLoginHint', '未登录时付费墙上方说明', 'fullApp'],
['singlePageTitle', '朋友圈单页 · 付费区标题', 'singlePage'],
['singlePagePaywallHint', '朋友圈单页 · 付费墙说明(旧)', 'singlePage'],
['singlePagePaywallSub', '朋友圈单页 · 付费墙第二段小字', 'singlePage'],
['singlePageShareEarn1', '朋友圈单页 · 分销说明第一行', 'singlePage'],
['singlePageShareEarn2', '朋友圈单页 · 分销说明第二行', 'singlePage'],
['singlePagePaywallHint', '朋友圈单页 · 付费墙说明', 'singlePage'],
]
for (const [k, behavior, scope] of pairs) {
if (typeof r[k] === 'string' && String(r[k]).trim()) {
@@ -291,9 +288,9 @@ export function summarizePopupRow(p: PagePopupItem): string {
* 对照(唯一来源清单):
* - `miniprogram/utils/mpPagePopups.js``getMemberDetailContent` / `getReadPageContent` 使用的 pagePath + key
* - `/pages/member-detail/member-detail.js` → `_showUnlockIntroThenLogin`unlockIntro*;正文兜底与下述 content 一致)
* - `/pages/read/read.js` → `onLoad` 内 beforeLoginHint / singlePage*(标题兜底「解锁完整内容」)
* - `/pages/read/read.js` → `onLoad` 内 beforeLoginHint / singlePage*(标题兜底「解锁全文」)
*
* 当前共 8 条,勿随意改 key改文案请在后台或改此处种子后重新保存。
* 当前共 5 条,勿随意改 key改文案请在后台或改此处种子后重新保存。
*/
export const SEED_PAGE_POPUP_ITEMS: Omit<PagePopupItem, 'id'>[] = [
{
@@ -327,41 +324,17 @@ export const SEED_PAGE_POPUP_ITEMS: Omit<PagePopupItem, 'id'>[] = [
scope: 'singlePage',
key: 'singlePageTitle',
behavior: '朋友圈单页 · 付费区标题',
content: '解锁完整内容',
content: '解锁全文',
},
{
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope: 'singlePage',
key: 'singlePagePaywallHint',
behavior: '朋友圈单页 · 付费墙说明(旧版折叠区,当前页已不用)',
behavior: '朋友圈单页 · 付费墙说明',
content:
'当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。',
},
{
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope: 'singlePage',
key: 'singlePagePaywallSub',
behavior: '朋友圈单页 · 付费墙第二段小字(按钮下方)',
content: '预览页不能直接付款,务必先点底栏「前往小程序」。',
},
{
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope: 'singlePage',
key: 'singlePageShareEarn1',
behavior: '朋友圈单页 · 分销说明第一行',
content: '好友经你分享购买,你可获得约 90% 收益',
},
{
pageName: KNOWN_PAGE_NAMES[READ_PATH],
pagePath: READ_PATH,
scope: 'singlePage',
key: 'singlePageShareEarn2',
behavior: '朋友圈单页 · 分销说明第二行',
content: '转发给需要的人,一起学习还能赚佣金',
},
]
export function seedPagePopupItemsWithIds(): PagePopupItem[] {

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/rechargealert.tsx","./src/components/richeditor.tsx","./src/components/modules/mbti/mbtiavatarsmanager.tsx","./src/components/modules/user/memberuserselect.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/mbtiavatarprompts.ts","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/mpuipopuptablesection.tsx","./src/pages/settings/settingspage.tsx","./src/pages/settings/mpuicopyconfig.ts","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}

View File

@@ -642,34 +642,9 @@ func getUnpaidPreviewPercent(db *gorm.DB) int {
return 20
}
// effectivePreviewPercent 章节 preview_percent 优先1~100否则用全局 unpaid_preview_percent
func effectivePreviewPercent(db *gorm.DB, ch *model.Chapter) int {
if ch != nil && ch.PreviewPercent != nil {
p := *ch.PreviewPercent
if p >= 1 && p <= 100 {
return p
}
}
return getUnpaidPreviewPercent(db)
}
// chapterDetailDataMap 将章节详情序列化为 mapdata.previewPercent 为有效试读%(章节覆盖优先,否则全局)。
func chapterDetailDataMap(ch model.Chapter, effectivePreview int) (map[string]interface{}, error) {
b, err := json.Marshal(ch)
if err != nil {
return nil, err
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return nil, err
}
m["previewPercent"] = effectivePreview
return m, nil
}
// findChapterAndRespond 按条件查章节并返回统一格式
// 免费判断优先级system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
// data.content 始终返回完整正文;试读比例在 data.previewPercent由小程序 overflow:hidden 裁切;不返回顶层 content/previewPercent
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content否则返回 previewContent
// content 缓存:优先 Redis命中时仅查元数据不含 LONGTEXT未命中时查全量并回填缓存
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
var ch model.Chapter
@@ -704,35 +679,43 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
isFree = true
}
// 权限与试读比例:未解锁仍返回全文,试读由端上裁切
// 确定返回的 content免费直接返回付费须校验购买权限
userID := c.Query("userId")
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
hasFullAccess := isFree || checkUserChapterAccess(db, userID, ch.ID, isPremium)
effectivePreview := effectivePreviewPercent(db, &ch)
returnContent := ch.Content
var returnContent string
// 未解锁:正文截取见 chapter_preview.goHTML 剥标签后与 H5 一致)
if hasFullAccess {
returnContent = ch.Content
} else {
percent := chapterPreviewPercent(db, &ch)
returnContent = previewContent(ch.Content, percent)
}
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
chForResponse := ch
chForResponse.Content = returnContent
chForResponse.PartTitle = sanitizeChapterTitleField(ch.PartTitle)
chForResponse.ChapterTitle = sanitizeChapterTitleField(ch.ChapterTitle)
chForResponse.SectionTitle = sanitizeChapterTitleField(ch.SectionTitle)
dataPayload, err := chapterDetailDataMap(chForResponse, effectivePreview)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
effectivePct := chapterPreviewPercent(db, &ch)
out := gin.H{
"success": true,
"data": dataPayload,
"chapterTitle": chForResponse.ChapterTitle,
"partTitle": chForResponse.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": chForResponse.SectionTitle,
"isFree": isFree,
"hasFullAccess": hasFullAccess,
"success": true,
"data": chForResponse,
"content": returnContent,
"chapterTitle": chForResponse.ChapterTitle,
"partTitle": chForResponse.PartTitle,
"id": ch.ID,
"mid": ch.MID,
"sectionTitle": chForResponse.SectionTitle,
"isFree": isFree,
"hasFullAccess": hasFullAccess,
"unpaidPreviewPercent": effectivePct,
"readPreviewUi": mergeReadPreviewUI(db),
}
if !hasFullAccess {
out["previewPercent"] = getUnpaidPreviewPercent(db)
}
// 文章详情内直接输出上一篇/下一篇,省去单独请求
if list := getOrderedChapterList(); len(list) > 0 {

View File

@@ -0,0 +1,121 @@
package handler
import (
"encoding/json"
"html"
"regexp"
"strings"
"unicode/utf8"
"soul-api/internal/model"
"gorm.io/gorm"
)
var (
reHTMLScript = regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`)
reHTMLStyle = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`)
reHTMLBr = regexp.MustCompile(`(?i)<\s*br\s*/?>`)
reHTMLPClose = regexp.MustCompile(`(?i)</\s*p\s*>`)
reHTMLTags = regexp.MustCompile(`<[^>]+>`)
)
// stripHTMLToPlainPreview 将正文 HTML 转为纯文本再截取,避免预览截在 <img 中间导致用户看到半截标签
func stripHTMLToPlainPreview(s string) string {
s = reHTMLScript.ReplaceAllString(s, " ")
s = reHTMLStyle.ReplaceAllString(s, " ")
s = reHTMLBr.ReplaceAllString(s, "\n")
s = reHTMLPClose.ReplaceAllString(s, "\n")
s = reHTMLTags.ReplaceAllString(s, "")
s = html.UnescapeString(s)
s = strings.NewReplacer("\r\n", "\n", "\r", "\n").Replace(s)
return strings.TrimSpace(s)
}
// chapterPreviewPercent 章节单独 preview_percent 覆盖全局 unpaid_preview_percent
func chapterPreviewPercent(db *gorm.DB, ch *model.Chapter) int {
g := getUnpaidPreviewPercent(db)
if ch != nil && ch.PreviewPercent != nil {
p := *ch.PreviewPercent
if p >= 1 && p <= 100 {
return p
}
}
return g
}
// previewContent 取内容的前 percent%(按纯文本 rune 计;原 HTML 先剥标签),与 h5 落地页一致
func previewContent(content string, percent int) string {
work := content
if strings.Contains(content, "<") && strings.Contains(content, ">") {
work = stripHTMLToPlainPreview(content)
}
total := utf8.RuneCountInString(work)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
const maxPreview = 500
if limit > maxPreview {
limit = maxPreview
}
if limit > total {
limit = total
}
runes := []rune(work)
return string(runes[:limit]) + "\n\n……"
}
func defaultReadPreviewUI() map[string]string {
return map[string]string{
"singlePageUnlockTitle": "解锁完整内容",
"singlePagePayButtonText": "支付 ¥{price} 解锁全文",
"singlePageExpandedHint": "预览页不能直接付款,务必先点底栏「前往小程序」。",
"payTapModalTitle": "解锁说明",
"payTapModalContent": "全文 ¥{price}。预览里无法完成支付:请先点屏幕底部「前往小程序」进入完整版,登录后再付款解锁。",
"fullUnlockTitle": "解锁完整内容",
"fullUnlockDesc": "可先上滑阅读预览;需要全文时,点下方「支付¥{price}」查看说明",
"fullLockedProgressText": "已阅读约 {percent}% ,购买后继续阅读",
"fullPaywallTip": "转发给需要的人,一起学习还能赚佣金",
"notLoginUnlockDesc": "已预览约 {percent}% 内容,登录并支付 ¥{price} 后阅读全文",
"notLoginPaywallTip": "分享给好友一起学习,还能赚取佣金",
"shareTipLine": "好友经你分享购买,你可获得约 90% 收益",
"momentsModalTitle": "分享到朋友圈",
"momentsModalContent": "已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。",
"momentsClipboardFooter": "\n\n—— 以上为正文预览约 {percent}% ,搜「卡若创业派对」小程序阅读全文 ——",
"timelineTitleSuffix": "(预览{percent}%",
}
}
// mergeReadPreviewUI 合并 system_config.read_preview_uiJSON 对象)与默认文案;占位符 {percent}/{price} 由小程序替换
func mergeReadPreviewUI(db *gorm.DB) map[string]string {
out := defaultReadPreviewUI()
var row model.SystemConfig
if err := db.Where("config_key = ?", "read_preview_ui").First(&row).Error; err != nil || len(row.ConfigValue) == 0 {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
out[k] = strings.TrimSpace(t)
}
default:
// 兼容数字等被误存:忽略非字符串
}
}
return out
}

View File

@@ -168,11 +168,8 @@ func defaultMpUi() gin.H {
gin.H{"id": "seed-unlock-title", "pageName": "成员详情", "pagePath": "/pages/member-detail/member-detail", "scope": "fullApp", "key": "unlockIntroTitle", "behavior": "解锁前说明弹窗 · 标题wx.showModal title", "content": "解锁与链接说明"},
gin.H{"id": "seed-unlock-body", "pageName": "成员详情", "pagePath": "/pages/member-detail/member-detail", "scope": "fullApp", "key": "unlockIntroBody", "behavior": "解锁前说明弹窗 · 正文wx.showModal content", "content": "「链接」用于提交留资,由对方通过获客计划跟进;「解锁」用于复制手机/微信号后自行添加好友。\n\n请确认已了解后再登录。"},
gin.H{"id": "seed-read-hint", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "fullApp", "key": "beforeLoginHint", "behavior": "未登录时付费墙上方说明", "content": "试读进度与下方百分比以后台配置为准;登录后可购买解锁全文。"},
gin.H{"id": "seed-read-sp-title", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "singlePage", "key": "singlePageTitle", "behavior": "朋友圈单页 · 付费区标题", "content": "解锁完整内容"},
gin.H{"id": "seed-read-sp-hint", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "singlePage", "key": "singlePagePaywallHint", "behavior": "朋友圈单页 · 付费墙说明(旧)", "content": "当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。"},
gin.H{"id": "seed-read-sp-sub", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "singlePage", "key": "singlePagePaywallSub", "behavior": "朋友圈单页 · 付费墙第二段小字", "content": "预览页不能直接付款,务必先点底栏「前往小程序」。"},
gin.H{"id": "seed-read-sp-se1", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "singlePage", "key": "singlePageShareEarn1", "behavior": "朋友圈单页 · 分销说明第一行", "content": "好友经你分享购买,你可获得约 90% 收益"},
gin.H{"id": "seed-read-sp-se2", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "singlePage", "key": "singlePageShareEarn2", "behavior": "朋友圈单页 · 分销说明第二行", "content": "转发给需要的人,一起学习还能赚佣金"},
gin.H{"id": "seed-read-sp-title", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "singlePage", "key": "singlePageTitle", "behavior": "朋友圈单页 · 付费区标题", "content": "解锁全文"},
gin.H{"id": "seed-read-sp-hint", "pageName": "文章详情 / 阅读", "pagePath": "/pages/read/read", "scope": "singlePage", "key": "singlePagePaywallHint", "behavior": "朋友圈单页 · 付费墙说明", "content": "当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。"},
},
}
}

View File

@@ -5,7 +5,6 @@ import (
"html"
"net/http"
"strings"
"unicode/utf8"
"soul-api/internal/config"
"soul-api/internal/database"
@@ -37,8 +36,8 @@ func H5ReadPage(c *gin.Context) {
return
}
percent := effectivePreviewPercent(db, &ch)
preview := h5PreviewContent(ch.Content, percent)
percent := chapterPreviewPercent(db, &ch)
preview := previewContent(ch.Content, percent)
title := ch.SectionTitle
if title == "" {
@@ -74,28 +73,6 @@ func H5ReadPage(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(pageHTML))
}
func h5PreviewContent(content string, percent int) string {
total := utf8.RuneCountInString(content)
if total == 0 {
return ""
}
if percent < 1 {
percent = 1
}
if percent > 100 {
percent = 100
}
limit := total * percent / 100
if limit < 100 {
limit = 100
}
if limit > total {
limit = total
}
runes := []rune(content)
return string(runes[:limit])
}
func h5Error(msg string) string {
return fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>提示</title><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0f1923;color:#ccc;}</style>

View File

@@ -943,7 +943,7 @@ func DBUserTracksList(c *gin.Context) {
"id": t.ID, "action": t.Action, "actionLabel": userTrackActionLabelCN(t.Action),
"target": target, "chapterTitle": chTitle, "module": module,
"moduleLabel": userTrackModuleLabelCN(module),
"createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt),
"createdAt": t.CreatedAt, "timeAgo": humanTimeAgoCN(createdAt),
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "tracks": out, "total": len(out)})

View File

@@ -0,0 +1,12 @@
-- =============================================================================
-- 说明:本目录拆成两个脚本,请勿直接执行本文件全文。
--
-- 1) 先执行预览backfill-vip-orders-preview.sql
-- 2) 确认人数与金额后再执行backfill-vip-orders-insert.sql
--
-- 背景:数据概览的总收入/订单数来自 orders 表 status IN (paid,completed,success)。
-- 管理端开通的超级个体users.is_vip=1若没有 product_type=vip 的已付订单,则不会计入。
-- 插入订单后transaction_id=MANUAL_BACKFILL_VIPpayment_method=manual便于与真实微信单区分。
-- =============================================================================
SELECT '请运行 backfill-vip-orders-preview.sql 与 backfill-vip-orders-insert.sql' AS hint;

View File

@@ -0,0 +1,44 @@
-- 插入:为符合条件的超级个体用户各补一条 vip ¥1980 已付订单(仅无 vip 已付记录者)
-- 执行前请先跑 backfill-vip-orders-preview.sql并备份 orders 表
INSERT INTO orders (
id,
order_sn,
user_id,
open_id,
product_type,
product_id,
amount,
description,
status,
payment_method,
transaction_id,
pay_time,
created_at,
updated_at
)
SELECT
CONCAT('BFV', MD5(CONCAT(u.id, ':vip1980:backfill'))),
CONCAT('BFV', MD5(CONCAT(u.id, ':vip1980:backfill'))),
u.id,
COALESCE(u.open_id, ''),
'vip',
'vip_annual',
1980.00,
'卡若创业派对VIP年度会员365天【数据补录与超级个体权益对齐】',
'paid',
'manual',
'MANUAL_BACKFILL_VIP',
COALESCE(u.vip_activated_at, u.updated_at, u.created_at, NOW()),
NOW(),
NOW()
FROM users u
WHERE u.deleted_at IS NULL
AND COALESCE(u.is_vip, 0) = 1
AND (u.vip_expire_date IS NULL OR u.vip_expire_date > NOW())
AND NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.product_type = 'vip'
AND o.status IN ('paid', 'completed', 'success')
);

View File

@@ -0,0 +1,33 @@
-- 预览:将补多少超级个体用户、合计增加多少营收(每人一条 ¥1980 vip 已付订单)
-- 条件:当前仍为 VIPis_vip=1 且未过期或未填过期视为仍有效)且尚无 vip 已付订单
SELECT
COUNT(*) AS users_to_backfill,
COUNT(*) * 1980.00 AS revenue_to_add_yuan
FROM users u
WHERE u.deleted_at IS NULL
AND COALESCE(u.is_vip, 0) = 1
AND (u.vip_expire_date IS NULL OR u.vip_expire_date > NOW())
AND NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.product_type = 'vip'
AND o.status IN ('paid', 'completed', 'success')
);
SELECT
u.id,
u.nickname,
u.vip_activated_at,
u.vip_expire_date
FROM users u
WHERE u.deleted_at IS NULL
AND COALESCE(u.is_vip, 0) = 1
AND (u.vip_expire_date IS NULL OR u.vip_expire_date > NOW())
AND NOT EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id
AND o.product_type = 'vip'
AND o.status IN ('paid', 'completed', 'success')
)
ORDER BY u.vip_activated_at DESC, u.id;