feat: 阅读页与章节预览 API;管理端内容页;book/h5_read;脚本与文档
- miniprogram: read 页与 member-detail/my;SOP 文档 - soul-api: chapter_preview、book/h5_read 调整;VIP 订单回填 SQL - soul-admin: ContentPage、dist - scripts: pull_from_baota;content_upload、gitignore、对话规则 Made-with: Cursor
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 条要点。
|
||||
|
||||
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:
|
||||
|
||||
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|上传与验收**
|
||||
接单方在小程序任务里 **上传成功截图**;发起方核对截图与任务要求一致后 **审核通过并打款**,任务关闭。
|
||||
|
||||
---
|
||||
|
||||
## 验收清单(发起方勾选)
|
||||
|
||||
- [ ] 群内/小程序已锁定唯一接单方,未锁人不买号
|
||||
- [ ] 账号信息与绑定手机已完整交给接单方
|
||||
- [ ] 截图含明确「认证成功」或平台等价状态,且在任务约定平台内
|
||||
- [ ] 赏金金额与打款方式与任务页一致
|
||||
|
||||
---
|
||||
|
||||
## 异常处理(简)
|
||||
|
||||
| 情况 | 处理 |
|
||||
|:---|:---|
|
||||
| 买号后接单方失联 | 任务重新挂群;原接单方在小程序取消认领规则需产品支持 |
|
||||
| 认证失败 | 先区分是号问题还是操作问题;换号或换人按任务规则是否补赏金,事先写进任务说明 |
|
||||
| 截图不清晰 | 退回补传,限次数写在任务里 |
|
||||
@@ -14,6 +14,9 @@ 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 },
|
||||
|
||||
@@ -22,10 +25,48 @@ 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' })
|
||||
@@ -92,6 +133,7 @@ Page({
|
||||
}),
|
||||
loading: false
|
||||
})
|
||||
this._scheduleResumeLinkFlowIfNeeded()
|
||||
return
|
||||
}
|
||||
} catch (e) {}
|
||||
@@ -101,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) {}
|
||||
|
||||
@@ -125,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
|
||||
},
|
||||
|
||||
// 将空值、「未填写」、纯空格均视为未填写(用于隐藏对应项)
|
||||
@@ -315,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 || {}
|
||||
@@ -186,6 +208,11 @@ Page({
|
||||
readSinglePageMode: false,
|
||||
// 朋友圈单页付费墙:说明默认收起,点「购买本章」后展开极简文案 + 底栏箭头(无长 Modal)
|
||||
momentsPaywallExpanded: false,
|
||||
|
||||
// 未付费预览比例(与后端 unpaid_preview_percent / 章节 preview_percent 一致)
|
||||
effectivePreviewPercent: 20,
|
||||
// 付费墙 / 朋友圈文案(后端 read_preview_ui + 占位符已替换)
|
||||
readUi: {},
|
||||
},
|
||||
|
||||
_isLockedState(state) {
|
||||
@@ -268,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() {
|
||||
@@ -529,6 +602,7 @@ Page({
|
||||
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),
|
||||
@@ -680,6 +754,7 @@ Page({
|
||||
|
||||
// 设置章节内容(兼容纯文本/Markdown 与 TipTap HTML)
|
||||
async setChapterContent(res) {
|
||||
this._applyChapterMetaFromResponse(res)
|
||||
await app.getReadExtras()
|
||||
const { lines, segments } = contentParser.parseContent(res.content, getContentParseConfig())
|
||||
// 预览内容由后端统一截取比例,这里展示全部预览内容
|
||||
@@ -1068,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: () => {
|
||||
@@ -1084,8 +1157,10 @@ Page({
|
||||
setTimeout(() => {
|
||||
wx.hideToast()
|
||||
wx.showModal({
|
||||
title: '分享到朋友圈',
|
||||
content: '已复制发圈文案(非分享给好友)。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
|
||||
title: ui.momentsModalTitle || '分享到朋友圈',
|
||||
content:
|
||||
ui.momentsModalContent ||
|
||||
'已复制发圈文案。\n\n请点击右上角「···」→「分享到朋友圈」粘贴发布。',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
@@ -1099,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>
|
||||
|
||||
@@ -132,27 +132,30 @@
|
||||
<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-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</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" bindtap="onUnlockTapInSinglePage">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥1</text>
|
||||
<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-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
|
||||
<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-title">{{readUi.fullUnlockTitle || '解锁完整内容'}}</text>
|
||||
<text class="paywall-desc paywall-desc--pre" wx:if="{{readBeforeLoginHint}}">{{readBeforeLoginHint}}</text>
|
||||
<text class="paywall-desc">已阅读{{previewPercent}}%,登录并支付 ¥{{section && section.price != null ? section.price : sectionPrice}} 后阅读全文</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>
|
||||
@@ -162,7 +165,7 @@
|
||||
<view class="login-btn" bindtap="showLoginModal" style="margin-top:12px">
|
||||
<text class="login-btn-text">手机号登录后购买</text>
|
||||
</view>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">{{readUi.notLoginPaywallTip || '分享给好友一起学习,还能赚取佣金'}}</text>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
@@ -216,26 +219,30 @@
|
||||
<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-desc" wx:if="{{!auditMode}}">试读 {{previewPercent}}%(与后台试读比例一致)</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" bindtap="onUnlockTapInSinglePage">
|
||||
<text class="btn-label">购买本章</text>
|
||||
<text class="btn-price brand-color">¥1</text>
|
||||
<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-desc paywall-desc--moments-expanded" wx:if="{{momentsPaywallExpanded}}">{{readSinglePageHint || '预览不可付款,请点底部「前往小程序」。'}}</text>
|
||||
<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">已阅读{{previewPercent}}%,购买后继续阅读</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>
|
||||
@@ -253,7 +260,7 @@
|
||||
</view>
|
||||
</view>
|
||||
<view class="paywall-audit-tip" wx:if="{{auditMode}}">审核中,暂不支持购买</view>
|
||||
<text class="paywall-tip" wx:if="{{!auditMode}}">分享给好友一起学习,还能赚取佣金</text>
|
||||
<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>
|
||||
|
||||
@@ -327,6 +327,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;
|
||||
|
||||
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)
|
||||
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
6
soul-admin/dist/index.html
vendored
6
soul-admin/dist/index.html
vendored
@@ -4,10 +4,10 @@
|
||||
<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-BRyXRtx1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BfljfNs2.css">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"soul-api/internal/cache"
|
||||
"soul-api/internal/database"
|
||||
@@ -643,44 +642,6 @@ 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)
|
||||
}
|
||||
|
||||
// previewContent 取内容的前 percent%(不少于 100 字,上限 500 字),并追加省略提示
|
||||
func previewContent(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
|
||||
}
|
||||
const maxPreview = 500
|
||||
if limit > maxPreview {
|
||||
limit = maxPreview
|
||||
}
|
||||
if limit > total {
|
||||
limit = total
|
||||
}
|
||||
runes := []rune(content)
|
||||
return string(runes[:limit]) + "\n\n……"
|
||||
}
|
||||
|
||||
// findChapterAndRespond 按条件查章节并返回统一格式
|
||||
// 免费判断优先级:system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
|
||||
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content,否则返回 previewContent
|
||||
@@ -723,13 +684,12 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
isPremium := ch.EditionPremium != nil && *ch.EditionPremium
|
||||
hasFullAccess := isFree || checkUserChapterAccess(db, userID, ch.ID, isPremium)
|
||||
var returnContent string
|
||||
// 未解锁:正文截取用「章节覆盖 ∪ 全局」;响应里顶层 previewPercent 仅表示全局默认,data.previewPercent 表示章节私有(model omitempty)
|
||||
var effectiveUnpaidPreviewPercent int
|
||||
// 未解锁:正文截取见 chapter_preview.go(HTML 剥标签后与 H5 一致)
|
||||
if hasFullAccess {
|
||||
returnContent = ch.Content
|
||||
} else {
|
||||
effectiveUnpaidPreviewPercent = effectivePreviewPercent(db, &ch)
|
||||
returnContent = previewContent(ch.Content, effectiveUnpaidPreviewPercent)
|
||||
percent := chapterPreviewPercent(db, &ch)
|
||||
returnContent = previewContent(ch.Content, percent)
|
||||
}
|
||||
|
||||
// data 中的 content 必须与外层 content 一致,避免泄露完整内容给未授权用户
|
||||
@@ -739,17 +699,20 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
|
||||
chForResponse.ChapterTitle = sanitizeChapterTitleField(ch.ChapterTitle)
|
||||
chForResponse.SectionTitle = sanitizeChapterTitleField(ch.SectionTitle)
|
||||
|
||||
effectivePct := chapterPreviewPercent(db, &ch)
|
||||
out := gin.H{
|
||||
"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,
|
||||
"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)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
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