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:
@@ -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. **直接执行**:用户说「直接做、别讲写了什么」时,**正文极短**;**复盘五块不可省**,可压缩过程为 1~2 条要点。
|
||||
|
||||
@@ -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`)。
|
||||
|
||||
37
.cursor/scripts/clone-soul-yongping-windows.bat
Normal file
37
.cursor/scripts/clone-soul-yongping-windows.bat
Normal 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
|
||||
12
.cursor/scripts/create-offline-bundle.sh
Executable file
12
.cursor/scripts/create-offline-bundle.sh
Executable 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
3
.gitignore
vendored
@@ -49,3 +49,6 @@ soul-api/soul-api-new
|
||||
|
||||
# 本地技能包临时打包目录
|
||||
.tmp_skill_bundle/
|
||||
|
||||
# 从宝塔拉取的线上运行目录镜像(含 .env / 二进制,勿提交)
|
||||
_server_live/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
57
miniprogram/docs/SOP_碎片时间小程序_认证任务众包.md
Normal file
57
miniprogram/docs/SOP_碎片时间小程序_认证任务众包.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 碎片时间小程序 · 认证任务众包 SOP
|
||||
|
||||
> 流程口径:平台只撮合任务与验收;账号来源、合规与平台规则由发起方自负,建议在合同/任务说明里写清。
|
||||
|
||||
---
|
||||
|
||||
## 角色
|
||||
|
||||
| 角色 | 说明 |
|
||||
|:---|:---|
|
||||
| 发起方(你) | 发任务、采购号、审核截图、打款 |
|
||||
| 接单方 | 登录指定账号、完成认证、上传成功截图 |
|
||||
|
||||
---
|
||||
|
||||
## 步骤
|
||||
|
||||
**Step 0|产品**
|
||||
碎片时间小程序具备:发布任务、任务详情、接单/认领(或群内确认后你在后台绑定接单方)、上传凭证、发起方审核、标记完成/打款(或线下打款后在小程序确认)。
|
||||
|
||||
**Step 1|生成任务**
|
||||
在小程序里创建「认证类」任务:写清平台名称、认证类型、截止时间、赏金、**需上传的验收材料**(如:认证成功页截图)。
|
||||
|
||||
**Step 2|丢到群里**
|
||||
把任务链接或任务卡片发到目标群,说明谁接谁在小程序里认领(或私信你登记,由你在后台绑定该用户为执行人)。
|
||||
**门禁:必须等到明确有人接单/认领后,再进入 Step 3。**
|
||||
|
||||
**Step 3|采购认证号**
|
||||
接单已锁定后,再到淘宝等平台购买符合任务要求的认证号(店铺、类目、是否已部分认证等按任务说明买)。
|
||||
|
||||
**Step 4|交付登录信息**
|
||||
将 **账号、密码、绑定手机号**(及验证码接收方式,若需要)通过约定渠道发给接单方;附 **极简认证流程**(要点的什么、上传什么、注意哪几条),避免长篇说明书。
|
||||
|
||||
**Step 5|跟进认证**
|
||||
接单方登录并按流程完成认证;页面显示 **已成功** 后,截取 **带成功状态** 的截图。
|
||||
|
||||
**Step 6|上传与验收**
|
||||
接单方在小程序任务里 **上传成功截图**;发起方核对截图与任务要求一致后 **审核通过并打款**,任务关闭。
|
||||
|
||||
---
|
||||
|
||||
## 验收清单(发起方勾选)
|
||||
|
||||
- [ ] 群内/小程序已锁定唯一接单方,未锁人不买号
|
||||
- [ ] 账号信息与绑定手机已完整交给接单方
|
||||
- [ ] 截图含明确「认证成功」或平台等价状态,且在任务约定平台内
|
||||
- [ ] 赏金金额与打款方式与任务页一致
|
||||
|
||||
---
|
||||
|
||||
## 异常处理(简)
|
||||
|
||||
| 情况 | 处理 |
|
||||
|:---|:---|
|
||||
| 买号后接单方失联 | 任务重新挂群;原接单方在小程序取消认领规则需产品支持 |
|
||||
| 认证失败 | 先区分是号问题还是操作问题;换号或换人按任务规则是否补赏金,事先写进任务说明 |
|
||||
| 截图不清晰 | 退回补传,限次数写在任务里 |
|
||||
@@ -348,7 +348,7 @@ Page({
|
||||
.replace(/\{\{prefix\}\}/g, prefix)
|
||||
.trim() || baseTitle
|
||||
} else if (prefix) {
|
||||
mainTitle = `${prefix} · ${nm}`
|
||||
mainTitle = baseTitle
|
||||
} else {
|
||||
mainTitle = `@${nm}`
|
||||
}
|
||||
|
||||
@@ -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.pagePopupItems:unlockIntroTitle / 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
|
||||
}
|
||||
|
||||
@@ -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 (_) {}
|
||||
},
|
||||
|
||||
// 点击菜单
|
||||
|
||||
@@ -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 + 分销(pagePopupItems,key 沿用 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-height(previewMaxHeightPx=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(/ /g, ' ')
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/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 }
|
||||
},
|
||||
|
||||
// 显示登录弹窗(每次打开协议未勾选,符合审核要求)
|
||||
|
||||
@@ -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="分享到朋友圈">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
153
scripts/pull_from_baota.py
Normal 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)
|
||||
1
soul-admin/dist/assets/index-BHhAT-JW.css
vendored
1
soul-admin/dist/assets/index-BHhAT-JW.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-BfljfNs2.css
vendored
Normal file
1
soul-admin/dist/assets/index-BfljfNs2.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1010
soul-admin/dist/assets/index-CXKq85jN.js
vendored
Normal file
1010
soul-admin/dist/assets/index-CXKq85jN.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24
soul-admin/dist/index.html
vendored
24
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">小程序界面文案 mpUi(JSON)</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 按页面路径 + 英文键维护(pagePopupItems);目录、Tab、首页板块等仍由其它配置决定。
|
||||
</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">
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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"}
|
||||
@@ -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 将章节详情序列化为 map;data.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.go(HTML 剥标签后与 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 {
|
||||
|
||||
121
soul-api/internal/handler/chapter_preview.go
Normal file
121
soul-api/internal/handler/chapter_preview.go
Normal 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_ui(JSON 对象)与默认文案;占位符 {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
|
||||
}
|
||||
@@ -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": "当前为朋友圈单页预览,无法在此登录或付款。请点击底部「前往小程序」进入完整版后再解锁本章。"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)})
|
||||
|
||||
12
soul-api/scripts/backfill-vip-orders-for-revenue.sql
Normal file
12
soul-api/scripts/backfill-vip-orders-for-revenue.sql
Normal 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_VIP,payment_method=manual,便于与真实微信单区分。
|
||||
-- =============================================================================
|
||||
|
||||
SELECT '请运行 backfill-vip-orders-preview.sql 与 backfill-vip-orders-insert.sql' AS hint;
|
||||
44
soul-api/scripts/backfill-vip-orders-insert.sql
Normal file
44
soul-api/scripts/backfill-vip-orders-insert.sql
Normal 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')
|
||||
);
|
||||
33
soul-api/scripts/backfill-vip-orders-preview.sql
Normal file
33
soul-api/scripts/backfill-vip-orders-preview.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- 预览:将补多少超级个体用户、合计增加多少营收(每人一条 ¥1980 vip 已付订单)
|
||||
-- 条件:当前仍为 VIP(is_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;
|
||||
Reference in New Issue
Block a user