19 Commits

Author SHA1 Message Date
卡若
b038a042c2 v1.20 首页版块命名:创业老板排行 → 超级个体
- 界面标题改为「超级个体」(VIP会员展示)
- 与小程序上传 v1.20 保持一致

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-24 14:25:52 +08:00
卡若
afc2376e96 v1.19 全面改版:VIP会员系统、我的收益、创业老板排行、阅读量排序
- 后端: users表新增VIP字段, 4个VIP API (purchase/status/profile/members)
- 后端: hot接口改按user_tracks阅读量排序
- 后端: orders表支持vip产品类型, migrate新增vip_fields迁移
- 小程序「我的」: 推广中心改为我的收益, 头像VIP标识, VIP入口卡片
- 小程序「我的」: 最近阅读显示真实章节名称
- 小程序首页: 去掉内容概览, 新增创业老板排行(4列网格)
- 小程序首页: 精选推荐从hot接口获取, goToRead增加track记录
- 新增页面: VIP详情页, 会员详情页
- 开发文档精简为10个标准目录, 创建SKILL.md, 需求日志规范化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 14:07:41 +08:00
卡若
e91a5d9f7a feat: 首页推荐逻辑-排除序言尾声,精选按点击量,小程序接入featuredSections
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 20:59:22 +08:00
卡若
7551840c86 feat: 管理后台改造 + 小程序最新章节逻辑 + 变更文档
【soul-admin 管理后台】
- 交易中心 → 推广中心(侧边栏与页面标题)
- 移除 5 个冗余按钮,仅保留「API 接口」
- 删除按钮改为悬停显示
- 免费/付费可点击切换(单击切换,双击付费可设金额)
- 加号移至章节右侧(序言、附录等),小节内移除加号
- 章节与小节支持拖拽排序
- 持续隐藏「上传内容」等按钮,解决双页面问题

【小程序首页 - 最新章节】
- latest-chapters API: 2 日内有新章取最新 3 章,否则随机免费章
- 首页 Banner 调用 /api/book/latest-chapters
- 标签动态显示「最新更新」或「为你推荐」

【开发文档】
- 新增 soul-admin变更记录_v2026-02.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 20:44:38 +08:00
卡若
f6846b5941 fix(souladmin): 添加 ?v=2 强制刷新 JS 缓存,修复 Failed to fetch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 19:34:55 +08:00
卡若
685b476721 feat(admin): API 文档区添加生成 TOKEN 功能
- 在 API 接口面板顶部增加「生成 TOKEN」按钮
- 一键获取 Token,可复制用于 curl/Skill 上传新章节
- souladmin 域名下自动使用同源 /api 代理

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 19:32:26 +08:00
卡若
74b1c3396d fix(souladmin): 修复 souladmin.quwanzhi.com 登录 Failed to fetch
- Vue 管理后台 API 改为同源 (O1=""),请求 /api
- souladmin Nginx 扩展配置代理 /api 到 souldev.quwanzhi.com
- 新增 scripts/fix_souladmin_login.sh 部署脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 19:30:25 +08:00
卡若
6e276fca61 内容管理页面增强:上传/删除/API文档集成到原有admin页面
- 将"初始化数据库"和"同步到数据库"按钮替换为"上传内容"和"API接口"
- 隐藏"导入"、"导出"、"同步飞书"按钮
- 每个章节条目增加"删除"按钮
- 添加上传面板和API接口文档面板(可展开/收起)
- 保持原有侧边栏和页面风格不变

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 15:30:20 +08:00
卡若
76d90a0397 新增内容管理页面:上传/删除/API文档一体化
- content-manager.html: 替代原有Vue内容管理页
- 支持章节列表、搜索、编辑、删除功能
- 集成上传表单和API接口文档
- 解决原页面"加载中..."问题(CORS已修复)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 15:11:29 +08:00
卡若
934f7c7988 精简「我的」页面菜单 + 数据库去重 + 内容上传接口
- 移除扫一扫/提现记录/设置独立菜单项,设置功能整合到页面内
- 新增绑定微信号、清缓存、退出登录的内联设置区
- 添加 content_upload.py:Skill 可直接调用上传内容到数据库
- 数据库已去重(196→69条)并添加唯一索引防止再次重复

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 14:04:55 +08:00
卡若
0e4baa4b7f feat: 我的页整合扫一扫/设置与提现、all-chapters去重、内容上传API、文档与后台登录
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-20 18:50:16 +08:00
卡若
09fb67d2af 更新:next-env/package 配置、端口配置表、新增小程序接口申请文案
- next-env.d.ts / package.json 配置调整
- 开发文档/服务器管理 端口配置表更新
- 新增 开发文档/10、项目管理/小程序接口申请文案.md(wx.chooseAddress、wx.getPhoneNumber 等申请理由)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 06:32:27 +08:00
乘风
70497d3047 更新.gitignore以排除部署配置文件,删除不再使用的一键部署脚本,优化小程序部署流程,增强文档说明。 2026-01-31 17:39:21 +08:00
乘风
ceac5b73ff 删除不再使用的文件,包括.gitignore、多个启动和配置指南文档,以及小程序相关的脚本和文档,简化项目结构以提高可维护性。 2026-01-31 15:36:52 +08:00
乘风
77a1c87678 优化章节读取逻辑,优先从数据库获取章节数据并处理最新的 isFree 状态;改进用户行为轨迹和绑定关系加载,增加错误处理和用户提示。 2026-01-31 11:42:49 +08:00
乘风
e21837bf47 更新小程序启动指南,添加本机安装路径说明及一键打开功能说明;更新编译脚本,优先使用D盘的微信开发者工具路径。 2026-01-30 15:45:01 +08:00
乘风
41bf3de992 1 2026-01-30 14:12:10 +08:00
乘风
ff1e4824f7 Merge branch 'soul-content' into yongpxu-soul 2026-01-30 14:12:03 +08:00
乘风
c08ca72992 2026-01-30 14:10:47 +08:00
167 changed files with 19969 additions and 11728 deletions

View File

@@ -89,6 +89,12 @@
```
在「版本管理」设为体验版测试
### Soul 第9章文章上传写好即传
- 文章路径: `个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例/`
- 写好文章后执行: `./scripts/upload_soul_article.sh "<文章完整路径>"`
- 接口: content_upload.py 直连数据库id 已存在则**更新**,否则**创建**,保持不重复
- 第9章固定: part-4, chapter-9
### 注意事项
- 小程序版本号:未发布前保持 1.14,正式发布后递增
- 后台部署后需等待约30秒生效

21
.dockerignore Normal file
View File

@@ -0,0 +1,21 @@
# 构建/运行不需要的目录,不打包进镜像
node_modules
.next
.git
.gitignore
*.md
.env*
.DS_Store
# 部署与开发脚本不打包
scripts
*.sh
deploy_config.json
deploy_config.example.json
requirements-deploy.txt
# 小程序、文档、附加模块
miniprogram
开发文档
addons
book

4
.gitignore vendored
View File

@@ -5,3 +5,7 @@ node_modules/
.trae/
*.log
node_modules
# 部署配置(含服务器信息,勿提交)
deploy_config.json
scripts/deploy_config.json

View File

@@ -1 +0,0 @@
node_modules/

View File

@@ -1,5 +1,93 @@
# 部署指南
## 整站与后台管理端
本项目是**一个 Next.js 应用**,前台 H5、后台管理、API 都在同一套代码里:
- **前台**`/``/chapters``/read/*``/my``/match`
- **后台管理端**`/admin``/admin/login``/admin/settings``/admin/users`
- **API**`/api/*`(含 `/api/admin/*`
**部署一次 = 前台 + 后台 + API 一起上线。** 后台无需单独部署,上线后访问:
- 后台首页:`https://你的域名/admin`
- 后台登录:`https://你的域名/admin/login`(账号见项目文档,如 `admin` / `key123456`
---
## 项目内已有的部署配置
| 类型 | 文件/目录 | 说明 |
|------|------------|------|
| 总览文档 | `DEPLOYMENT.md`(本文件) | 部署步骤、环境变量、支付回调 |
| Docker | `Dockerfile` | Next.js 独立构建(`output: 'standalone'` |
| Docker 编排 | `docker-compose.yml` | 整站容器、端口 3000、支付/基础环境变量 |
| Next 配置 | `next.config.mjs` | `output: 'standalone'` 供 Docker 使用 |
| 宝塔一键部署 | `scripts/deploy-to-server.sh` | SSH 到宝塔服务器拉代码、安装依赖、构建、PM2 重启 |
| 宝塔自动化 | `开发文档/8、部署/Next.js自动化部署流程.md` | GitHub Webhook + 宝塔,推送即自动部署 |
| NAS 部署 | `deploy_to_nas.sh``redeploy.sh``quick_deploy.sh` | 部署到 NAS / 内网环境 |
| **宝塔部署(跨平台)** | **`scripts/deploy_baota.py`** | **Python 脚本Windows/Mac/Linux 通用,不依赖 .sh 或 sshpass** |
`vercel.json`Vercel 会按默认规则部署本仓库;若需自定义路由或头信息,可再加 `vercel.json`
---
## 宝塔部署Python 跨平台)
本项目在 Mac 上开发,原有一键部署脚本为 `scripts/deploy-to-server.sh`(依赖 sshpass仅 Linux/Mac。为在 **Windows / Mac / Linux** 上都能部署到宝塔,提供了 **Python 脚本**,不依赖 shell 或 sshpass。
### 1. 安装依赖
\`\`\`bash
pip install paramiko
\`\`\`
### 2. 配置服务器信息
复制示例配置并填写真实信息(**不要提交到 Git**
\`\`\`bash
cp scripts/deploy_config.example.json deploy_config.json
# 编辑 deploy_config.json填写 server_host、server_user、project_path、branch、pm2_app_name 等
\`\`\`
或使用环境变量(不写配置文件时,脚本会提示输入密码):
- `DEPLOY_HOST`:服务器 IP
- `DEPLOY_USER`SSH 用户名(如 root
- `DEPLOY_PROJECT_PATH`:服务器上项目路径(如 /www/wwwroot/soul
- `DEPLOY_BRANCH`:要部署的分支(如 soul-content
- `DEPLOY_PM2_APP`PM2 应用名(如 soul
- `DEPLOY_SSH_KEY`SSH 私钥路径(可选,不填则用密码)
### 3. 执行部署
在**项目根目录**执行:
\`\`\`bash
python scripts/deploy_baota.py
# 或指定配置
python scripts/deploy_baota.py --config scripts/deploy_config.json
# 仅查看将要执行的步骤(不连接)
python scripts/deploy_baota.py --dry-run
\`\`\`
脚本会依次执行SSH 连接 → 拉取代码 → 安装依赖 → 构建 → PM2 重启。部署完成后访问:
- 前台:`https://soul.quwanzhi.com`
- 后台:`https://soul.quwanzhi.com/admin`
### 4. 首次在宝塔上准备
若服务器上尚未有代码,需先在宝塔上:
1. 在网站目录(如 `/www/wwwroot/soul`)执行 `git clone <你的仓库> .`,或从本地上传代码。
2. 在宝塔「PM2 管理器」中新增项目:项目目录选该路径,启动文件为 `node_modules/next/dist/bin/next``node server.js`(若使用 standalone 输出),启动参数为 `start -p 3006`(与 `package.json``start` 端口一致)。
3. 配置 Nginx 反向代理到该端口,并绑定域名。
4. 之后即可用 `python scripts/deploy_baota.py` 做日常拉代码、构建、重启。
---
## 生产环境部署步骤
### 1. Vercel部署
@@ -54,6 +142,14 @@ vercel --prod
2. 在产品中心配置支付回调URL`https://your-domain.com/api/payment/wechat/notify`
3. 添加支付授权域名:`your-domain.com`
**提现(商家转账到零钱):** 详见 `开发文档/提现功能完整技术文档.md`。需配置:
- `WECHAT_MCH_ID`:商户号
- `WECHAT_APP_ID`:小程序/公众号 AppID`wxb8bbb2b10dec74aa`
- `WECHAT_API_V3_KEY``WECHAT_MCH_KEY`APIv3 密钥32 字节,用于回调解密)
- `WECHAT_KEY_PATH``WECHAT_MCH_PRIVATE_KEY_PATH`商户私钥文件路径apiclient_key.pem
- `WECHAT_MCH_CERT_SERIAL_NO`商户证书序列号OpenSSL 从 apiclient_cert.pem 提取)
- 商户平台需配置:商家转账到零钱、转账结果通知 URL`https://你的域名/api/payment/wechat/transfer/notify`
### 5. 测试流程
1. 创建测试订单
@@ -81,6 +177,23 @@ npm run dev
# 访问 http://localhost:3000
\`\`\`
### Windows 本地执行 `pnpm build` 报 EPERM symlink
本项目使用 `output: 'standalone'`,构建时 Next.js 会创建符号链接。**Windows 默认不允许普通用户创建符号链接**,会报错:
- `EPERM: operation not permitted, symlink ... -> .next\standalone\node_modules\...`
**可选做法(任选其一):**
1. **开启 Windows 开发者模式(推荐,一劳永逸)**
- 设置 → 隐私和安全性 → 针对开发人员 → **开发人员模式** 打开
- 开启后无需管理员即可创建符号链接,本地 `pnpm build` 可正常完成。
2. **以管理员身份运行终端再执行构建**
- 右键 Cursor/终端 → “以管理员身份运行”,在项目根目录执行 `pnpm build`
若只做部署、不在本机打 standalone 包,可直接用 `python scripts/deploy_baota.py`,构建会在**服务器Linux**上执行,不会遇到该问题。
## 注意事项
1. 生产环境必须使用HTTPS

41
app/admin/error.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client'
import { useEffect } from 'react'
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('[Admin] 页面错误:', error)
}, [error])
return (
<div className="min-h-screen flex items-center justify-center bg-[#0a1628]">
<div className="bg-[#0f2137] border border-gray-700/50 rounded-2xl p-8 max-w-md w-full mx-4 shadow-xl">
<div className="text-center">
<div className="text-5xl mb-4">😞</div>
<h2 className="text-xl font-bold text-white mb-2"></h2>
<p className="text-gray-400 text-sm mb-6"></p>
<div className="flex gap-3">
<button
onClick={reset}
className="flex-1 py-2.5 rounded-lg bg-[#38bdac] hover:bg-[#2da396] text-white text-sm font-medium"
>
</button>
<a
href="/admin"
className="flex-1 py-2.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-gray-200 text-sm font-medium text-center"
>
</a>
</div>
</div>
</div>
</div>
)
}

View File

@@ -11,24 +11,25 @@ export default function AdminDashboard() {
const [users, setUsers] = useState<any[]>([])
const [purchases, setPurchases] = useState<any[]>([])
// 从API获取数据
// 从API获取数据(任意接口失败时仍保持页面可展示,不抛错)
async function loadData() {
try {
// 获取用户数据
const usersRes = await fetch('/api/db/users')
const usersData = await usersRes.json()
if (usersData.success && usersData.users) {
const usersData = await usersRes.ok ? usersRes.json().catch(() => ({})) : { success: false }
if (usersData.success && Array.isArray(usersData.users)) {
setUsers(usersData.users)
}
// 获取订单数据
} catch (e) {
console.warn('加载用户数据失败', e)
}
try {
const ordersRes = await fetch('/api/orders')
const ordersData = await ordersRes.json()
if (ordersData.success && ordersData.orders) {
const ordersData = await ordersRes.ok ? ordersRes.json().catch(() => ({})) : { success: false }
if (ordersData.success && Array.isArray(ordersData.orders)) {
setPurchases(ordersData.orders)
}
} catch (e) {
console.log('加载数据失败', e)
console.warn('加载订单数据失败', e)
}
}
@@ -63,7 +64,7 @@ export default function AdminDashboard() {
)
}
const totalRevenue = purchases.reduce((sum, p) => sum + (p.amount || 0), 0)
const totalRevenue = purchases.reduce((sum, p) => sum + (Number(p?.amount) || 0), 0)
const totalUsers = users.length
const totalPurchases = purchases.length
@@ -71,7 +72,7 @@ export default function AdminDashboard() {
{ title: "总用户数", value: totalUsers, icon: Users, color: "text-blue-400", bg: "bg-blue-500/20", link: "/admin/users" },
{
title: "总收入",
value: `¥${totalRevenue.toFixed(2)}`,
value: `¥${Number(totalRevenue).toFixed(2)}`,
icon: TrendingUp,
color: "text-[#38bdac]",
bg: "bg-[#38bdac]/20",
@@ -80,7 +81,7 @@ export default function AdminDashboard() {
{ title: "订单数", value: totalPurchases, icon: ShoppingBag, color: "text-purple-400", bg: "bg-purple-500/20", link: "/admin/orders" },
{
title: "转化率",
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
value: `${totalUsers > 0 ? (Number(totalPurchases) / Number(totalUsers) * 100).toFixed(1) : 0}%`,
icon: BookOpen,
color: "text-orange-400",
bg: "bg-orange-500/20",
@@ -132,11 +133,11 @@ export default function AdminDashboard() {
>
<div>
<p className="text-sm font-medium text-white">{p.sectionTitle || "整本购买"}</p>
<p className="text-xs text-gray-500">{new Date(p.createdAt).toLocaleString()}</p>
<p className="text-xs text-gray-500">{p?.createdAt ? new Date(p.createdAt).toLocaleString() : "-"}</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-[#38bdac]">+¥{p.amount}</p>
<p className="text-xs text-gray-400">{p.paymentMethod || "微信支付"}</p>
<p className="text-sm font-bold text-[#38bdac]">+¥{Number(p?.amount) || 0}</p>
<p className="text-xs text-gray-400">{p?.paymentMethod || "微信支付"}</p>
</div>
</div>
))}
@@ -169,7 +170,7 @@ export default function AdminDashboard() {
</div>
</div>
<p className="text-xs text-gray-400">
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
{u?.createdAt ? new Date(u.createdAt).toLocaleDateString() : "-"}
</p>
</div>
))}

View File

@@ -39,6 +39,14 @@ export default function SettingsPage() {
minWithdraw: 10, // 最低提现金额
})
// 功能开关配置
const [featureConfig, setFeatureConfig] = useState({
matchEnabled: true, // 找伙伴功能开关(默认开启)
referralEnabled: true, // 推广功能开关
searchEnabled: true, // 搜索功能开关
aboutEnabled: true // 关于页面开关
})
// 加载配置
useEffect(() => {
const loadConfig = async () => {
@@ -48,6 +56,7 @@ export default function SettingsPage() {
const data = await res.json()
if (data.freeChapters) setFreeChapters(data.freeChapters)
if (data.mpConfig) setMpConfig(prev => ({ ...prev, ...data.mpConfig }))
if (data.features) setFeatureConfig(prev => ({ ...prev, ...data.features }))
}
} catch (e) {
console.log('Load config error:', e)
@@ -82,16 +91,41 @@ export default function SettingsPage() {
})
// 保存免费章节和小程序配置
await fetch('/api/db/config', {
const res1 = await fetch('/api/db/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ freeChapters, mpConfig })
})
const result1 = await res1.json()
console.log('保存免费章节和小程序配置:', result1)
alert("设置已保存!")
// 保存功能开关配置
const res2 = await fetch('/api/db/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: 'feature_config',
config: featureConfig,
description: '功能开关配置'
})
})
const result2 = await res2.json()
console.log('保存功能开关配置:', result2)
// 验证保存结果
const verifyRes = await fetch('/api/db/config')
const verifyData = await verifyRes.json()
console.log('验证保存结果:', verifyData.features)
// 立即更新本地状态
if (verifyData.features) {
setFeatureConfig(prev => ({ ...prev, ...verifyData.features }))
}
alert("设置已保存!\n\n找伙伴功能" + (verifyData.features?.matchEnabled ? "✅ 开启" : "❌ 关闭"))
} catch (error) {
console.error('Save settings error:', error)
alert("保存失败")
alert("保存失败: " + (error as Error).message)
} finally {
setIsSaving(false)
}
@@ -357,6 +391,114 @@ export default function SettingsPage() {
</CardContent>
</Card>
{/* 功能开关设置 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Settings className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">/</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
{/* 找伙伴功能开关 */}
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="match-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
Web端的找伙伴功能显示
</p>
</div>
<Switch
id="match-enabled"
checked={featureConfig.matchEnabled}
onCheckedChange={(checked) =>
setFeatureConfig(prev => ({ ...prev, matchEnabled: checked }))
}
/>
</div>
{/* 推广功能开关 */}
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Gift className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="referral-enabled" className="text-white font-medium cursor-pointer">
广
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
广
</p>
</div>
<Switch
id="referral-enabled"
checked={featureConfig.referralEnabled}
onCheckedChange={(checked) =>
setFeatureConfig(prev => ({ ...prev, referralEnabled: checked }))
}
/>
</div>
{/* 搜索功能开关 */}
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="search-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
</p>
</div>
<Switch
id="search-enabled"
checked={featureConfig.searchEnabled}
onCheckedChange={(checked) =>
setFeatureConfig(prev => ({ ...prev, searchEnabled: checked }))
}
/>
</div>
{/* 关于页面开关 */}
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">
访
</p>
</div>
<Switch
id="about-enabled"
checked={featureConfig.aboutEnabled}
onCheckedChange={(checked) =>
setFeatureConfig(prev => ({ ...prev, aboutEnabled: checked }))
}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-xs text-blue-300">
💡
</p>
</div>
</CardContent>
</Card>
{/* 小程序配置 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
@@ -478,14 +620,22 @@ export default function SettingsPage() {
<span className="text-white"></span>
<span className="font-normal text-xs text-gray-500"></span>
</Label>
<Switch id="referral-enabled" defaultChecked />
<Switch
id="referral-enabled"
checked={featureConfig.referralEnabled}
onCheckedChange={(checked) => setFeatureConfig(prev => ({ ...prev, referralEnabled: checked }))}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="match-enabled" className="flex flex-col space-y-1">
<span className="text-white"></span>
<span className="font-normal text-xs text-gray-500"></span>
</Label>
<Switch id="match-enabled" defaultChecked />
<Switch
id="match-enabled"
checked={featureConfig.matchEnabled}
onCheckedChange={(checked) => setFeatureConfig(prev => ({ ...prev, matchEnabled: checked }))}
/>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { getAdminCookieName, getAdminCookieOptions } from '@/lib/admin-auth'
export async function POST(_req: NextRequest) {
const res = NextResponse.json({ success: true })
const opts = getAdminCookieOptions()
res.cookies.set(getAdminCookieName(), '', { ...opts, maxAge: 0 })
return res
}

View File

@@ -1,9 +1,11 @@
/**
* 后台提现管理API
* 获取所有提现记录,处理提现审批
* 批准时如已配置微信转账则调用「商家转账到零钱」,否则仅更新为成功(需线下打款)
*/
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { createTransfer } from '@/lib/wechat-transfer'
// 获取所有提现记录
export async function GET(request: Request) {
@@ -112,24 +114,47 @@ export async function PUT(request: Request) {
}
if (action === 'approve') {
// 批准提现 - 更新状态为成功
const openid = withdrawal.wechat_openid || ''
const amountFen = Math.round(parseFloat(withdrawal.amount) * 100)
if (openid && amountFen > 0) {
const result = await createTransfer({
openid,
amountFen,
outDetailNo: id,
transferRemark: 'Soul创业派对-提现',
})
if (result.success) {
await query(`
UPDATE withdrawals
SET status = 'processing', transaction_id = ?
WHERE id = ?
`, [result.batchId || result.outBatchNo || '', id])
return NextResponse.json({
success: true,
message: '已发起微信转账,等待到账后自动更新状态',
batchId: result.batchId,
})
}
return NextResponse.json({
success: false,
error: result.errorMessage || '微信转账发起失败',
}, { status: 400 })
}
// 无 openid 或金额为 0仅标记为成功线下打款
await query(`
UPDATE withdrawals
SET status = 'success', processed_at = NOW(), transaction_id = ?
WHERE id = ?
`, [`manual_${Date.now()}`, id])
// 更新用户已提现金额
await query(`
UPDATE users
SET withdrawn_earnings = withdrawn_earnings + ?,
pending_earnings = pending_earnings - ?
WHERE id = ?
`, [withdrawal.amount, withdrawal.amount, withdrawal.user_id])
return NextResponse.json({
success: true,
message: '提现已批准'
message: '提现已批准(线下打款)',
})
} else if (action === 'reject') {

View File

@@ -0,0 +1,72 @@
/**
* Web 端登录:手机号 + 密码
* POST { phone, password } -> 校验后返回用户信息(不含密码)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { verifyPassword } from '@/lib/password'
function mapRowToUser(r: any) {
return {
id: r.id,
phone: r.phone || '',
nickname: r.nickname || '',
isAdmin: !!r.is_admin,
purchasedSections: Array.isArray(r.purchased_sections)
? r.purchased_sections
: (r.purchased_sections ? JSON.parse(String(r.purchased_sections)) : []) || [],
hasFullBook: !!r.has_full_book,
referralCode: r.referral_code || '',
earnings: parseFloat(String(r.earnings || 0)),
pendingEarnings: parseFloat(String(r.pending_earnings || 0)),
withdrawnEarnings: parseFloat(String(r.withdrawn_earnings || 0)),
referralCount: Number(r.referral_count) || 0,
createdAt: r.created_at || '',
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { phone, password } = body
if (!phone || !password) {
return NextResponse.json(
{ success: false, error: '请输入手机号和密码' },
{ status: 400 }
)
}
const rows = await query(
'SELECT id, phone, nickname, password, is_admin, has_full_book, referral_code, earnings, pending_earnings, withdrawn_earnings, referral_count, purchased_sections, created_at FROM users WHERE phone = ?',
[String(phone).trim()]
) as any[]
if (!rows || rows.length === 0) {
return NextResponse.json(
{ success: false, error: '用户不存在或密码错误' },
{ status: 401 }
)
}
const row = rows[0]
const storedPassword = row.password == null ? '' : String(row.password)
if (!verifyPassword(String(password), storedPassword)) {
return NextResponse.json(
{ success: false, error: '密码错误' },
{ status: 401 }
)
}
const user = mapRowToUser(row)
return NextResponse.json({ success: true, user })
} catch (e) {
console.error('[Auth Login] error:', e)
return NextResponse.json(
{ success: false, error: '登录失败' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,54 @@
/**
* 忘记密码 / 重置密码Web 端)
* POST { phone, newPassword } -> 按手机号更新密码(无验证码版本,适合内测/内部使用)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { hashPassword } from '@/lib/password'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { phone, newPassword } = body
if (!phone || !newPassword) {
return NextResponse.json(
{ success: false, error: '请输入手机号和新密码' },
{ status: 400 }
)
}
const trimmedPhone = String(phone).trim()
const trimmedPassword = String(newPassword).trim()
if (trimmedPassword.length < 6) {
return NextResponse.json(
{ success: false, error: '密码至少 6 位' },
{ status: 400 }
)
}
const rows = await query('SELECT id FROM users WHERE phone = ?', [trimmedPhone]) as any[]
if (!rows || rows.length === 0) {
return NextResponse.json(
{ success: false, error: '该手机号未注册' },
{ status: 404 }
)
}
const hashed = hashPassword(trimmedPassword)
await query('UPDATE users SET password = ?, updated_at = NOW() WHERE phone = ?', [
hashed,
trimmedPhone,
])
return NextResponse.json({ success: true, message: '密码已重置,请使用新密码登录' })
} catch (e) {
console.error('[Auth ResetPassword] error:', e)
return NextResponse.json(
{ success: false, error: '重置失败' },
{ status: 500 }
)
}
}

View File

@@ -3,7 +3,72 @@ import fs from 'fs'
import path from 'path'
import { query } from '@/lib/db'
/** 精选推荐:按 user_tracks 的 view_chapter 点击量排序,排除序言/尾声/附录 */
async function getFeaturedSections(): Promise<Array<{ id: string; title: string; tag: string; tagClass: string; part: string }>> {
const tags = [
{ tag: '热门', tagClass: 'tag-pink' },
{ tag: '推荐', tagClass: 'tag-purple' },
{ tag: '精选', tagClass: 'tag-free' }
]
try {
// 优先按 view_chapter 点击量排序
const rows = (await query(`
SELECT c.id, c.section_title, c.part_title, c.is_free,
COALESCE(t.cnt, 0) as view_count
FROM chapters c
LEFT JOIN (
SELECT chapter_id, COUNT(*) as cnt
FROM user_tracks
WHERE action = 'view_chapter' AND chapter_id IS NOT NULL
GROUP BY chapter_id
) t ON c.id = t.chapter_id
WHERE c.id NOT IN ('preface','epilogue')
AND c.id NOT LIKE 'appendix-%' AND c.id NOT LIKE 'appendix_%'
AND (c.part_title NOT LIKE '%序言%' AND c.part_title NOT LIKE '%尾声%')
ORDER BY view_count DESC, c.updated_at DESC
LIMIT 3
`)) as any[]
if (rows && rows.length > 0) {
return rows.map((r, i) => ({
id: r.id,
title: r.section_title || r.title || '',
part: (r.part_title || '真实的行业').replace(/^第[一二三四五六七八九十]+篇|?/, '').trim() || '真实的行业',
tag: tags[i]?.tag || '推荐',
tagClass: tags[i]?.tagClass || 'tag-purple'
}))
}
} catch (e) {
console.log('[All Chapters API] 精选推荐查询失败:', (e as Error).message)
}
try {
const fallback = (await query(`
SELECT id, section_title, part_title, is_free
FROM chapters
WHERE id NOT IN ('preface','epilogue')
AND id NOT LIKE 'appendix-%' AND id NOT LIKE 'appendix_%'
AND (part_title NOT LIKE '%序言%' AND part_title NOT LIKE '%尾声%')
ORDER BY updated_at DESC
LIMIT 3
`)) as any[]
if (fallback?.length > 0) {
return fallback.map((r, i) => ({
id: r.id,
title: r.section_title || r.title || '',
part: (r.part_title || '真实的行业').replace(/^第[一二三四五六七八九十]+篇|?/, '').trim() || '真实的行业',
tag: tags[i]?.tag || '推荐',
tagClass: tags[i]?.tagClass || 'tag-purple'
}))
}
} catch (_) {}
return [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
]
}
export async function GET() {
const featuredSections = await getFeaturedSections()
try {
// 方案1: 优先从数据库读取章节数据
try {
@@ -17,34 +82,87 @@ export async function GET() {
`) as any[]
if (dbChapters && dbChapters.length > 0) {
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '')
console.log('[All Chapters API] 从数据库读取成功,共', dbChapters.length, '')
// 格式化数据
const allChapters = dbChapters.map((chapter: any) => ({
id: chapter.id,
sectionId: chapter.section_id,
title: chapter.title,
sectionTitle: chapter.section_title,
content: chapter.content,
isFree: !!chapter.is_free,
price: chapter.price || 0,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
sectionOrder: chapter.section_order,
chapterOrder: chapter.chapter_order,
createdAt: chapter.created_at,
updatedAt: chapter.updated_at
}))
// 格式化并按 id 去重(保留首次出现)
const seen = new Set<string>()
const allChapters = dbChapters
.map((chapter: any) => ({
id: chapter.id,
sectionId: chapter.section_id ?? chapter.id,
title: chapter.title ?? chapter.section_title,
sectionTitle: chapter.section_title ?? chapter.title,
content: chapter.content,
isFree: !!chapter.is_free,
price: chapter.price || 0,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000,
sectionOrder: chapter.section_order,
chapterOrder: chapter.chapter_order,
createdAt: chapter.created_at,
updatedAt: chapter.updated_at
}))
.filter((row: { id: string }) => {
if (seen.has(row.id)) return false
seen.add(row.id)
return true
})
return NextResponse.json({
success: true,
data: allChapters,
chapters: allChapters,
total: allChapters.length,
source: 'database'
source: 'database',
featuredSections
})
}
} catch (dbError) {
console.log('[All Chapters API] 数据库读取失败,尝试文件读取:', (dbError as Error).message)
console.log('[All Chapters API] sections 表读取失败,尝试 chapters 表:', (dbError as Error).message)
}
// 方案1b: 从 chapters 表读取(与 lib/db 表结构一致)
try {
const dbChapters = await query(`
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title, content,
is_free, price, word_count, sort_order, created_at, updated_at
FROM chapters
ORDER BY sort_order ASC, id ASC
`) as any[]
if (dbChapters && dbChapters.length > 0) {
console.log('[All Chapters API] 从 chapters 表读取成功,共', dbChapters.length, '条')
const seen = new Set<string>()
const allChapters = dbChapters
.map((row: any) => ({
id: row.id,
sectionId: row.id,
title: row.section_title,
sectionTitle: row.section_title,
content: row.content,
isFree: !!row.is_free,
price: row.price || 0,
words: row.word_count || 0,
sectionOrder: row.sort_order ?? 0,
chapterOrder: 0,
createdAt: row.created_at,
updatedAt: row.updated_at
}))
.filter((row: { id: string }) => {
if (seen.has(row.id)) return false
seen.add(row.id)
return true
})
return NextResponse.json({
success: true,
data: allChapters,
chapters: allChapters,
total: allChapters.length,
source: 'database',
featuredSections
})
}
} catch (e2) {
console.log('[All Chapters API] chapters 表读取失败,尝试文件:', (e2 as Error).message)
}
// 方案2: 从JSON文件读取
@@ -72,11 +190,20 @@ export async function GET() {
}
if (chaptersData.length > 0) {
// 添加字数估算
const allChapters = chaptersData.map((chapter: any) => ({
...chapter,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
}))
// 添加字数估算并按 id 去重
const seen = new Set<string>()
const allChapters = chaptersData
.map((chapter: any) => ({
...chapter,
id: chapter.id ?? chapter.sectionId,
words: chapter.words || Math.floor(Math.random() * 3000) + 2000
}))
.filter((row: any) => {
const id = row.id || row.sectionId
if (!id || seen.has(String(id))) return false
seen.add(String(id))
return true
})
return NextResponse.json({
success: true,
@@ -84,7 +211,8 @@ export async function GET() {
chapters: allChapters,
total: allChapters.length,
source: 'file',
path: usedPath
path: usedPath,
featuredSections
})
}
@@ -97,7 +225,8 @@ export async function GET() {
data: defaultChapters,
chapters: defaultChapters,
total: defaultChapters.length,
source: 'default'
source: 'default',
featuredSections
})
} catch (error) {
@@ -111,7 +240,8 @@ export async function GET() {
chapters: defaultChapters,
total: defaultChapters.length,
source: 'fallback',
warning: '使用默认数据'
warning: '使用默认数据',
featuredSections
})
}
}

View File

@@ -1,74 +1,86 @@
/**
* 热门章节API
* 返回点击量最高的章节
* 按阅读量user_tracks view_chapter排序
*/
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
const DEFAULT_CHAPTERS = [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人', views: 0 },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', tag: '热门', tagClass: 'tag-pink', part: '真实的赚钱', views: 0 },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业', views: 0 },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱', views: 0 },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', tag: '最新', tagClass: 'tag-green', part: '真实的赚钱', views: 0 },
]
const SECTION_INFO: Record<string, any> = {
'1.1': { title: '荷包:电动车出租的被动收入模式', part: '真实的人', tag: '免费', tagClass: 'tag-free' },
'1.2': { title: '老墨:资源整合高手的社交方法', part: '真实的人', tag: '推荐', tagClass: 'tag-purple' },
'2.1': { title: '电商的底层逻辑', part: '真实的行业', tag: '推荐', tagClass: 'tag-purple' },
'3.1': { title: '3000万流水如何跑出来', part: '真实的行业', tag: '热门', tagClass: 'tag-pink' },
'4.1': { title: '我的第一次创业失败', part: '真实的错误', tag: '热门', tagClass: 'tag-pink' },
'5.1': { title: '未来职业的三个方向', part: '真实的社会', tag: '推荐', tagClass: 'tag-purple' },
'8.1': { title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱', tag: '推荐', tagClass: 'tag-purple' },
'9.12': { title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱', tag: '热门', tagClass: 'tag-pink' },
'9.13': { title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱', tag: '最新', tagClass: 'tag-green' },
'9.14': { title: '大健康私域一个月150万的70后', part: '真实的赚钱', tag: '热门', tagClass: 'tag-pink' },
'9.15': { title: '本地同城运营拿150万投资', part: '真实的赚钱', tag: '热门', tagClass: 'tag-pink' },
}
export async function GET() {
try {
// 从数据库查询点击量高的章节(如果有统计表)
let hotChapters = []
let hotChapters: any[] = []
try {
// 尝试从订单表统计购买量高的章节
// 按 user_tracks 的 view_chapter 阅读量排序
const rows = await query(`
SELECT
section_id as id,
COUNT(*) as purchase_count
FROM orders
WHERE status = 'completed' AND section_id IS NOT NULL
GROUP BY section_id
ORDER BY purchase_count DESC
SELECT chapter_id as id, COUNT(*) as view_count
FROM user_tracks
WHERE action = 'view_chapter' AND chapter_id IS NOT NULL AND chapter_id != ''
GROUP BY chapter_id
ORDER BY view_count DESC
LIMIT 10
`) as any[]
if (rows && rows.length > 0) {
// 补充章节信息
const sectionInfo: Record<string, any> = {
'1.1': { title: '荷包:电动车出租的被动收入模式', part: '真实的人', tag: '免费' },
'9.12': { title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱', tag: '热门' },
'3.1': { title: '3000万流水如何跑出来', part: '真实的行业', tag: '热门' },
'8.1': { title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱', tag: '推荐' },
'9.13': { title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱', tag: '最新' },
'9.14': { title: '大健康私域一个月150万的70后', part: '真实的赚钱', tag: '热门' },
'1.2': { title: '老墨:资源整合高手的社交方法', part: '真实的人', tag: '推荐' },
'2.1': { title: '电商的底层逻辑', part: '真实的行业', tag: '推荐' },
'4.1': { title: '我的第一次创业失败', part: '真实的错误', tag: '热门' },
'5.1': { title: '未来职业的三个方向', part: '真实的社会', tag: '推荐' }
}
hotChapters = rows.map((row: any) => ({
id: row.id,
...(sectionInfo[row.id] || { title: `章节${row.id}`, part: '', tag: '热门' }),
purchaseCount: row.purchase_count
}))
if (rows?.length) {
hotChapters = rows.map((row: any) => {
const info = SECTION_INFO[row.id] || {}
return {
id: row.id,
title: info.title || `章节 ${row.id}`,
part: info.part || '',
tag: info.tag || '热门',
tagClass: info.tagClass || 'tag-pink',
views: row.view_count
}
})
}
} catch (e) {
console.log('[Hot] 数据库查询失败,使用默认数据')
console.log('[Hot] user_tracks查询失败尝试订单统计')
// 降级:从订单表统计
try {
const rows = await query(`
SELECT product_id as id, COUNT(*) as purchase_count
FROM orders WHERE status = 'paid' AND product_id IS NOT NULL
GROUP BY product_id ORDER BY purchase_count DESC LIMIT 10
`) as any[]
if (rows?.length) {
hotChapters = rows.map((row: any) => ({
id: row.id,
...(SECTION_INFO[row.id] || { title: `章节 ${row.id}`, part: '', tag: '热门', tagClass: 'tag-pink' }),
views: row.purchase_count
}))
}
} catch { /* 使用默认 */ }
}
// 如果没有数据,返回默认热门章节
if (hotChapters.length === 0) {
hotChapters = [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', part: '真实的人' },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', tag: '热门', part: '真实的赚钱' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', part: '真实的赚钱' },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', tag: '最新', part: '真实的赚钱' }
]
if (!hotChapters.length) {
hotChapters = DEFAULT_CHAPTERS
}
return NextResponse.json({
success: true,
chapters: hotChapters
})
return NextResponse.json({ success: true, chapters: hotChapters })
} catch (error) {
console.error('[Hot] Error:', error)
return NextResponse.json({
success: false,
chapters: []
})
return NextResponse.json({ success: true, chapters: DEFAULT_CHAPTERS })
}
}

View File

@@ -1,55 +1,109 @@
// app/api/book/latest-chapters/route.ts
// 获取最新章节列表
// 获取最新章节有2日内更新则取最新3章否则随机取免费章节
// 排除序言、尾声、附录,只推荐正文章节
import { NextRequest, NextResponse } from 'next/server'
import { getBookStructure } from '@/lib/book-file-system'
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
export async function GET(req: NextRequest) {
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000
/** 是否应排除(序言、尾声、附录等特殊章节) */
function isExcludedChapter(id: string, partTitle: string): boolean {
const lowerId = String(id || '').toLowerCase()
if (lowerId === 'preface' || lowerId === 'epilogue') return true
if (lowerId.startsWith('appendix-') || lowerId.startsWith('appendix_')) return true
const pt = String(partTitle || '')
if (/序言|尾声/.test(pt)) return true
return false
}
export async function GET() {
try {
const bookStructure = getBookStructure()
// 获取所有章节并按时间排序
const allChapters: any[] = []
bookStructure.forEach((part: any) => {
part.chapters.forEach((chapter: any) => {
allChapters.push({
id: chapter.slug,
title: chapter.title,
part: part.title,
words: Math.floor(Math.random() * 3000) + 1500, // 模拟字数
updateTime: getRelativeTime(new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000)),
readTime: Math.ceil((Math.random() * 3000 + 1500) / 300)
})
let allChapters: Array<{
id: string
title: string
part: string
isFree: boolean
price: number
updatedAt: Date | string | null
createdAt: Date | string | null
}> = []
try {
const dbRows = (await query(`
SELECT id, part_title, section_title, is_free, price, created_at, updated_at
FROM chapters
ORDER BY sort_order ASC, id ASC
`)) as any[]
if (dbRows?.length > 0) {
allChapters = dbRows
.map((row: any) => ({
id: row.id,
title: row.section_title || row.title || '',
part: row.part_title || '真实的行业',
isFree: !!row.is_free,
price: row.price || 0,
updatedAt: row.updated_at || row.created_at,
createdAt: row.created_at
}))
.filter((c) => !isExcludedChapter(c.id, c.part))
}
} catch (e) {
console.log('[latest-chapters] 数据库读取失败:', (e as Error).message)
}
if (allChapters.length === 0) {
return NextResponse.json({
success: true,
banner: { id: '1.1', title: '荷包:电动车出租的被动收入模式', part: '真实的人' },
label: '为你推荐',
chapters: [],
hasNewUpdates: false
})
}
const now = Date.now()
const sorted = [...allChapters].sort((a, b) => {
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
return tb - ta
})
// 取最新的3章
const latestChapters = allChapters.slice(0, 3)
const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0
const hasNewUpdates = now - mostRecentTime < TWO_DAYS_MS
let banner: { id: string; title: string; part: string }
let label: string
let chapters: typeof allChapters
if (hasNewUpdates && sorted.length > 0) {
chapters = sorted.slice(0, 3)
banner = { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
label = '最新更新'
} else {
const freeChapters = allChapters.filter((c) => c.isFree || c.price === 0)
const candidates = freeChapters.length > 0 ? freeChapters : allChapters
const shuffled = [...candidates].sort(() => Math.random() - 0.5)
chapters = shuffled.slice(0, 3)
banner = chapters[0]
? { id: chapters[0].id, title: chapters[0].title, part: chapters[0].part }
: { id: allChapters[0].id, title: allChapters[0].title, part: allChapters[0].part }
label = '为你推荐'
}
return NextResponse.json({
success: true,
chapters: latestChapters,
total: allChapters.length
banner,
label,
chapters: chapters.map((c) => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })),
hasNewUpdates
})
} catch (error) {
console.error('获取章节失败:', error)
console.error('[latest-chapters] Error:', error)
return NextResponse.json(
{ error: '获取章节失败' },
{ success: false, error: '获取失败' },
{ status: 500 }
)
}
}
// 获取相对时间
function getRelativeTime(date: Date): string {
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
if (days < 30) return `${Math.floor(days / 7)}周前`
return `${Math.floor(days / 30)}个月前`
}

View File

@@ -0,0 +1,97 @@
/**
* 内容上传 API
* 供科室/Skill 直接上传单篇文章到书籍内容,写入 chapters 表
* 字段标题、定价、内容、格式、插入内容中的图片URL 列表)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
function slug(id: string): string {
return id.replace(/\s+/g, '-').replace(/[^\w\u4e00-\u9fa5-]/g, '').slice(0, 30) || 'section'
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
title,
price = 1,
content = '',
format = 'markdown',
images = [],
partId = 'part-1',
partTitle = '真实的人',
chapterId = 'chapter-1',
chapterTitle = '未分类',
isFree = false,
sectionId
} = body
if (!title || typeof title !== 'string') {
return NextResponse.json(
{ success: false, error: '标题 title 不能为空' },
{ status: 400 }
)
}
// 若内容中含占位符 {{image_0}} {{image_1}},用 images 数组替换
let finalContent = typeof content === 'string' ? content : ''
if (Array.isArray(images) && images.length > 0) {
images.forEach((url: string, i: number) => {
finalContent = finalContent.replace(
new RegExp(`\\{\\{image_${i}\\}\\}`, 'g'),
url.startsWith('http') ? `![图${i + 1}](${url})` : url
)
})
}
// 未替换的占位符去掉
finalContent = finalContent.replace(/\{\{image_\d+\}\}/g, '')
const wordCount = (finalContent || '').length
const id = sectionId || `upload.${slug(title)}.${Date.now()}`
await query(
`INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 9999, 'published')
ON DUPLICATE KEY UPDATE
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
is_free = VALUES(is_free),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP`,
[
id,
partId,
partTitle,
chapterId,
chapterTitle,
title,
finalContent,
wordCount,
!!isFree,
Number(price) || 1
]
)
return NextResponse.json({
success: true,
id,
message: '内容已上传并写入 chapters 表',
title,
price: Number(price) || 1,
isFree: !!isFree,
wordCount
})
} catch (error) {
console.error('[Content Upload]', error)
return NextResponse.json(
{
success: false,
error: '上传失败: ' + (error as Error).message
},
{ status: 500 }
)
}
}

View File

@@ -146,13 +146,40 @@ export async function GET(request: NextRequest) {
}
// 列出所有章节(不含内容)
// 优先从数据库读取,确保新建章节能立即显示
if (action === 'list') {
const sectionsFromDb = new Map<string, any>()
try {
const rows = await query(`
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
price, is_free, content
FROM chapters ORDER BY part_id, chapter_id, id
`) as any[]
if (rows && rows.length > 0) {
for (const r of rows) {
sectionsFromDb.set(r.id, {
id: r.id,
title: r.section_title || '',
price: r.price ?? 1,
isFree: !!r.is_free,
partId: r.part_id || 'part-1',
partTitle: r.part_title || '',
chapterId: r.chapter_id || 'chapter-1',
chapterTitle: r.chapter_title || '',
filePath: ''
})
}
}
} catch (e) {
console.log('[Book API] list 从数据库读取失败,回退到 bookData:', (e as Error).message)
}
// 合并:以数据库为准,数据库没有的用 bookData 补
const sections: any[] = []
for (const part of bookData) {
for (const chapter of part.chapters) {
for (const section of chapter.sections) {
sections.push({
const dbRow = sectionsFromDb.get(section.id)
sections.push(dbRow || {
id: section.id,
title: section.title,
price: section.price,
@@ -163,14 +190,25 @@ export async function GET(request: NextRequest) {
chapterTitle: chapter.title,
filePath: section.filePath
})
sectionsFromDb.delete(section.id)
}
}
}
// 数据库有但 bookData 没有的(新建章节)
for (const [, v] of sectionsFromDb) {
sections.push(v)
}
// 按 id 去重,避免数据库重复或合并逻辑导致同一文章出现多次
const seen = new Set<string>()
const deduped = sections.filter((s) => {
if (seen.has(s.id)) return false
seen.add(s.id)
return true
})
return NextResponse.json({
success: true,
sections,
total: sections.length
sections: deduped,
total: deduped.length
})
}
@@ -324,7 +362,7 @@ export async function POST(request: NextRequest) {
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, price, saveToFile = true } = body
const { id, title, content, price, saveToFile = true, partId, chapterId, partTitle, chapterTitle, isFree } = body
if (!id) {
return NextResponse.json({
@@ -334,28 +372,40 @@ export async function PUT(request: NextRequest) {
}
const sectionInfo = getSectionInfo(id)
const finalPartId = partId || sectionInfo?.partId || 'part-1'
const finalPartTitle = partTitle || sectionInfo?.partTitle || '未分类'
const finalChapterId = chapterId || sectionInfo?.chapterId || 'chapter-1'
const finalChapterTitle = chapterTitle || sectionInfo?.chapterTitle || '未分类'
const finalPrice = price ?? sectionInfo?.section?.price ?? 1
const finalIsFree = isFree ?? sectionInfo?.section?.isFree ?? false
// 更新数据库
// 更新数据库(含新建章节)
try {
await query(`
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
ON DUPLICATE KEY UPDATE
part_id = VALUES(part_id),
part_title = VALUES(part_title),
chapter_id = VALUES(chapter_id),
chapter_title = VALUES(chapter_title),
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
is_free = VALUES(is_free),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP
`, [
id,
sectionInfo?.partId || 'part-1',
sectionInfo?.partTitle || '未分类',
sectionInfo?.chapterId || 'chapter-1',
sectionInfo?.chapterTitle || '未分类',
title || sectionInfo?.section.title || '',
finalPartId,
finalPartTitle,
finalChapterId,
finalChapterTitle,
title || sectionInfo?.section?.title || '',
content || '',
(content || '').length,
price ?? sectionInfo?.section.price ?? 1
finalIsFree,
finalPrice
])
} catch (e) {
console.error('[Book API] 更新数据库失败:', e)

View File

@@ -72,6 +72,14 @@ const DEFAULT_CONFIGS: Record<string, any> = {
totalSections: 62,
freeSections: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
latestSectionId: '9.14'
},
// 功能开关配置
feature_config: {
matchEnabled: true, // 找伙伴功能开关(默认开启)
referralEnabled: true, // 推广功能开关
searchEnabled: true, // 搜索功能开关
aboutEnabled: true // 关于页面开关
}
}
@@ -150,6 +158,7 @@ export async function GET(request: NextRequest) {
// 提取前端需要的格式
const bookConfig = allConfigs.book_config || DEFAULT_CONFIGS.book_config
const featureConfig = allConfigs.feature_config || DEFAULT_CONFIGS.feature_config
return NextResponse.json({
success: true,
@@ -157,6 +166,7 @@ export async function GET(request: NextRequest) {
sources,
// 前端直接使用的格式
freeChapters: bookConfig.freeSections || DEFAULT_CONFIGS.book_config.freeSections,
features: featureConfig, // 功能开关
mpConfig: mpConfig || {
appId: 'wxb8bbb2b10dec74aa',
apiDomain: 'https://soul.quwanzhi.com',
@@ -222,14 +232,21 @@ export async function POST(request: NextRequest) {
}, { status: 400 })
}
console.log(`[Config API] 保存配置 ${key}:`, config)
// 保存到数据库
const success = await setConfig(key, config, description)
if (success) {
// 验证保存结果
const saved = await getConfig(key)
console.log(`[Config API] 验证保存结果 ${key}:`, saved)
return NextResponse.json({
success: true,
message: '配置保存成功',
key
key,
savedConfig: saved // 返回实际保存的配置
})
} else {
return NextResponse.json({

View File

@@ -115,6 +115,47 @@ export async function POST(request: NextRequest) {
}
}
// VIP会员字段
if (!migration || migration === 'vip_fields') {
const vipFields = [
{ name: 'is_vip', def: "BOOLEAN DEFAULT FALSE COMMENT 'VIP会员'" },
{ name: 'vip_expire_date', def: "TIMESTAMP NULL COMMENT 'VIP到期时间'" },
{ name: 'vip_name', def: "VARCHAR(100) COMMENT '会员真实姓名'" },
{ name: 'vip_project', def: "VARCHAR(200) COMMENT '会员项目名称'" },
{ name: 'vip_contact', def: "VARCHAR(100) COMMENT '会员联系方式'" },
{ name: 'vip_avatar', def: "VARCHAR(500) COMMENT '会员展示头像'" },
{ name: 'vip_bio', def: "VARCHAR(500) COMMENT '会员简介'" },
]
let addedCount = 0
let existCount = 0
for (const field of vipFields) {
try {
await query(`SELECT ${field.name} FROM users LIMIT 1`)
existCount++
} catch {
try {
await query(`ALTER TABLE users ADD COLUMN ${field.name} ${field.def}`)
addedCount++
} catch (e: any) {
if (e.code !== 'ER_DUP_FIELDNAME') {
results.push(`⚠️ 添加VIP字段 ${field.name} 失败: ${e.message}`)
}
}
}
}
// 扩展 orders.product_type 支持 vip
try {
await query(`ALTER TABLE orders MODIFY COLUMN product_type ENUM('section', 'fullbook', 'match', 'vip') NOT NULL`)
results.push('✅ orders.product_type 已支持 vip')
} catch (e: any) {
results.push(' orders.product_type 更新跳过: ' + e.message)
}
if (addedCount > 0) results.push(`✅ VIP字段新增 ${addedCount}`)
if (existCount > 0) results.push(` VIP字段已有 ${existCount} 个存在`)
}
// 用户标签定义表
if (!migration || migration === 'user_tag_definitions') {
try {
@@ -189,7 +230,7 @@ export async function GET() {
// 检查用户表字段
const userFields: Record<string, boolean> = {}
const checkFields = ['ckb_user_id', 'ckb_synced_at', 'ckb_tags', 'tags', 'merged_tags']
const checkFields = ['ckb_user_id', 'ckb_synced_at', 'ckb_tags', 'tags', 'merged_tags', 'is_vip', 'vip_expire_date', 'vip_name']
for (const field of checkFields) {
try {

View File

@@ -2,28 +2,63 @@
* 订单管理接口
* 开发: 卡若
* 技术支持: 存客宝
*
* GET /api/orders - 管理后台:返回全部订单(无 userId
* GET /api/orders?userId= - 按用户返回订单
*/
import { type NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
function rowToOrder(row: Record<string, unknown>) {
return {
id: row.id,
orderSn: row.order_sn,
userId: row.user_id,
openId: row.open_id,
productType: row.product_type,
productId: row.product_id,
amount: row.amount,
description: row.description,
status: row.status,
transactionId: row.transaction_id,
payTime: row.pay_time,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get("userId")
if (!userId) {
return NextResponse.json({ code: 400, message: "缺少用户ID" }, { status: 400 })
let rows: Record<string, unknown>[] = []
try {
if (userId) {
rows = (await query(
"SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC",
[userId]
)) as Record<string, unknown>[]
} else {
// 管理后台:无 userId 时返回全部订单
rows = (await query(
"SELECT * FROM orders ORDER BY created_at DESC"
)) as Record<string, unknown>[]
}
} catch (e) {
console.error("[Karuo] Orders query error:", e)
// 表可能未初始化,返回空列表
rows = []
}
// In production, fetch from database
// For now, return mock data
const orders = []
console.log("[Karuo] Fetching orders for user:", userId)
const orders = rows.map(rowToOrder)
return NextResponse.json({
code: 0,
message: "获取成功",
data: orders,
success: true,
orders,
})
} catch (error) {
console.error("[Karuo] Get orders error:", error)

View File

@@ -0,0 +1,65 @@
/**
* 微信支付 - 商家转账到零钱 结果通知
* 文档: 开发文档/提现功能完整技术文档.md
*/
import { NextRequest, NextResponse } from 'next/server'
import { decryptResource } from '@/lib/wechat-transfer'
import { query } from '@/lib/db'
const cfg = {
apiV3Key: process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || '',
}
export async function POST(request: NextRequest) {
try {
const rawBody = await request.text()
const data = JSON.parse(rawBody) as {
event_type?: string
resource?: { ciphertext: string; nonce: string; associated_data: string }
}
if (data.event_type !== 'MCHTRANSFER.BILL.FINISHED' || !data.resource) {
return NextResponse.json({ code: 'SUCCESS' })
}
const { ciphertext, nonce, associated_data } = data.resource
const decrypted = decryptResource(
ciphertext,
nonce,
associated_data,
cfg.apiV3Key
) as { out_bill_no?: string; state?: string; transfer_bill_no?: string }
const outBillNo = decrypted.out_bill_no
const state = decrypted.state
const transferBillNo = decrypted.transfer_bill_no || ''
if (!outBillNo) {
return NextResponse.json({ code: 'SUCCESS' })
}
const rows = await query('SELECT id, user_id, amount, status FROM withdrawals WHERE id = ?', [outBillNo]) as any[]
if (rows.length === 0) {
return NextResponse.json({ code: 'SUCCESS' })
}
const w = rows[0]
if (w.status !== 'processing') {
return NextResponse.json({ code: 'SUCCESS' })
}
if (state === 'SUCCESS') {
await query(`
UPDATE withdrawals SET status = 'success', processed_at = NOW(), transaction_id = ? WHERE id = ?
`, [transferBillNo, outBillNo])
await query(`
UPDATE users SET withdrawn_earnings = withdrawn_earnings + ?, pending_earnings = GREATEST(0, pending_earnings - ?) WHERE id = ?
`, [w.amount, w.amount, w.user_id])
} else {
await query(`
UPDATE withdrawals SET status = 'failed', processed_at = NOW(), error_message = ? WHERE id = ?
`, [state || '转账失败', outBillNo])
await query(`
UPDATE users SET pending_earnings = pending_earnings + ? WHERE id = ?
`, [w.amount, w.user_id])
}
return NextResponse.json({ code: 'SUCCESS' })
} catch (e) {
console.error('[WechatTransferNotify]', e)
return NextResponse.json({ code: 'FAIL', message: '处理失败' }, { status: 500 })
}
}

View File

@@ -0,0 +1,67 @@
/**
* VIP会员列表 - 用于「创业老板排行」展示
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
export async function GET(request: NextRequest) {
const limit = parseInt(new URL(request.url).searchParams.get('limit') || '20')
const memberId = new URL(request.url).searchParams.get('id')
try {
// 查询单个会员详情
if (memberId) {
const rows = await query(
`SELECT id, nickname, avatar, vip_name, vip_project, vip_contact, vip_avatar, vip_bio,
is_vip, vip_expire_date, created_at
FROM users WHERE id = ? AND is_vip = TRUE AND vip_expire_date > NOW()`,
[memberId]
) as any[]
if (!rows.length) {
return NextResponse.json({ success: false, error: '会员不存在或已过期' }, { status: 404 })
}
const m = rows[0]
return NextResponse.json({
success: true,
data: {
id: m.id,
name: m.vip_name || m.nickname || '创业者',
avatar: m.vip_avatar || m.avatar || '',
project: m.vip_project || '',
contact: m.vip_contact || '',
bio: m.vip_bio || '',
joinDate: m.created_at
}
})
}
// 获取VIP会员列表已填写资料的优先排前面
const members = await query(
`SELECT id, nickname, avatar, vip_name, vip_project, vip_avatar, vip_bio
FROM users
WHERE is_vip = TRUE AND vip_expire_date > NOW()
ORDER BY
CASE WHEN vip_name IS NOT NULL AND vip_name != '' THEN 0 ELSE 1 END,
vip_expire_date DESC
LIMIT ?`,
[limit]
) as any[]
return NextResponse.json({
success: true,
data: members.map((m: any) => ({
id: m.id,
name: m.vip_name || m.nickname || '创业者',
avatar: m.vip_avatar || m.avatar || '',
project: m.vip_project || '',
bio: m.vip_bio || ''
})),
total: members.length
})
} catch (error) {
console.error('[VIP Members]', error)
return NextResponse.json({ success: false, error: '查询失败', data: [], total: 0 })
}
}

View File

@@ -0,0 +1,77 @@
/**
* VIP会员资料填写/更新
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const { userId, name, project, contact, avatar, bio } = await request.json()
if (!userId) {
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
}
const users = await query('SELECT is_vip, vip_expire_date FROM users WHERE id = ?', [userId]) as any[]
if (!users.length) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
const user = users[0]
if (!user.is_vip || !user.vip_expire_date || new Date(user.vip_expire_date) <= new Date()) {
return NextResponse.json({ success: false, error: '仅VIP会员可填写资料' }, { status: 403 })
}
const updates: string[] = []
const params: any[] = []
if (name !== undefined) { updates.push('vip_name = ?'); params.push(name) }
if (project !== undefined) { updates.push('vip_project = ?'); params.push(project) }
if (contact !== undefined) { updates.push('vip_contact = ?'); params.push(contact) }
if (avatar !== undefined) { updates.push('vip_avatar = ?'); params.push(avatar) }
if (bio !== undefined) { updates.push('vip_bio = ?'); params.push(bio) }
if (!updates.length) {
return NextResponse.json({ success: false, error: '无更新内容' }, { status: 400 })
}
params.push(userId)
await query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
return NextResponse.json({ success: true, message: '资料已更新' })
} catch (error) {
console.error('[VIP Profile]', error)
return NextResponse.json({ success: false, error: '更新失败' }, { status: 500 })
}
}
export async function GET(request: NextRequest) {
const userId = new URL(request.url).searchParams.get('userId')
if (!userId) {
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
}
try {
const rows = await query(
'SELECT vip_name, vip_project, vip_contact, vip_avatar, vip_bio FROM users WHERE id = ?',
[userId]
) as any[]
if (!rows.length) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
return NextResponse.json({
success: true,
data: {
name: rows[0].vip_name || '',
project: rows[0].vip_project || '',
contact: rows[0].vip_contact || '',
avatar: rows[0].vip_avatar || '',
bio: rows[0].vip_bio || ''
}
})
} catch (error) {
console.error('[VIP Profile GET]', error)
return NextResponse.json({ success: false, error: '查询失败' }, { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
/**
* VIP会员购买 - 创建VIP订单
*/
import { NextRequest, NextResponse } from 'next/server'
import { query, getConfig } from '@/lib/db'
export async function POST(request: NextRequest) {
try {
const { userId } = await request.json()
if (!userId) {
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
}
const users = await query(
'SELECT id, open_id, is_vip, vip_expire_date FROM users WHERE id = ?',
[userId]
) as any[]
if (!users.length) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
const user = users[0]
// 如果已经是VIP且未过期
if (user.is_vip && user.vip_expire_date && new Date(user.vip_expire_date) > new Date()) {
return NextResponse.json({ success: false, error: '当前已是VIP会员' }, { status: 400 })
}
let vipPrice = 1980
try {
const config = await getConfig('vip_price')
if (config) vipPrice = Number(config) || 1980
} catch { /* 默认 */ }
const orderId = 'vip_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 6)
const orderSn = 'VIP' + Date.now() + Math.floor(Math.random() * 1000)
await query(
`INSERT INTO orders (id, order_sn, user_id, open_id, product_type, amount, description, status)
VALUES (?, ?, ?, ?, 'vip', ?, 'VIP年度会员', 'created')`,
[orderId, orderSn, userId, user.open_id || '', vipPrice]
)
return NextResponse.json({
success: true,
data: {
orderId,
orderSn,
amount: vipPrice,
productType: 'vip',
description: 'VIP年度会员365天'
}
})
} catch (error) {
console.error('[VIP Purchase]', error)
return NextResponse.json({ success: false, error: '创建订单失败' }, { status: 500 })
}
}

View File

@@ -0,0 +1,73 @@
/**
* VIP会员状态查询
*/
import { NextRequest, NextResponse } from 'next/server'
import { query, getConfig } from '@/lib/db'
export async function GET(request: NextRequest) {
const userId = new URL(request.url).searchParams.get('userId')
if (!userId) {
return NextResponse.json({ success: false, error: '缺少userId' }, { status: 400 })
}
try {
const rows = await query(
`SELECT is_vip, vip_expire_date, vip_name, vip_project, vip_contact, vip_avatar, vip_bio,
has_full_book, nickname, avatar
FROM users WHERE id = ?`,
[userId]
) as any[]
if (!rows.length) {
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
const user = rows[0]
const now = new Date()
const isVip = user.is_vip && user.vip_expire_date && new Date(user.vip_expire_date) > now
// 若过期则自动标记
if (user.is_vip && !isVip) {
await query('UPDATE users SET is_vip = FALSE WHERE id = ?', [userId]).catch(() => {})
}
let vipPrice = 1980
let vipRights: string[] = []
try {
const priceConfig = await getConfig('vip_price')
if (priceConfig) vipPrice = Number(priceConfig) || 1980
const rightsConfig = await getConfig('vip_rights')
if (rightsConfig) vipRights = Array.isArray(rightsConfig) ? rightsConfig : JSON.parse(rightsConfig)
} catch { /* 使用默认 */ }
if (!vipRights.length) {
vipRights = [
'解锁全部章节内容365天',
'匹配所有创业伙伴',
'创业老板排行榜展示',
'专属VIP标识'
]
}
return NextResponse.json({
success: true,
data: {
isVip,
expireDate: user.vip_expire_date,
daysRemaining: isVip ? Math.ceil((new Date(user.vip_expire_date).getTime() - now.getTime()) / 86400000) : 0,
profile: {
name: user.vip_name || '',
project: user.vip_project || '',
contact: user.vip_contact || '',
avatar: user.vip_avatar || user.avatar || '',
bio: user.vip_bio || ''
},
price: vipPrice,
rights: vipRights
}
})
} catch (error) {
console.error('[VIP Status]', error)
return NextResponse.json({ success: false, error: '查询失败' }, { status: 500 })
}
}

View File

@@ -52,15 +52,15 @@ export async function POST(request: NextRequest) {
const user = users[0]
// 检查是否绑定支付方式(微信号或支付宝
// 如果没有绑定,提示用户先绑定
// 微信零钱提现需要 open_id小程序/公众号登录获得
const openId = user.open_id || ''
const wechatId = user.wechat || user.wechat_id || ''
const alipayId = user.alipay || ''
if (!wechatId && !alipayId) {
if (!openId && !alipayId) {
return NextResponse.json({
success: false,
message: '请先在设置中绑定微信号或支付宝',
message: '提现到微信零钱需先使用微信登录;或绑定支付宝后提现到支付宝',
needBind: true
})
}
@@ -101,20 +101,24 @@ export async function POST(request: NextRequest) {
})
}
// 创建提现记录
// 创建提现记录(微信零钱需保存 wechat_openid 供后台批准时调用商家转账到零钱)
const withdrawId = `W${Date.now()}`
const accountType = alipayId ? 'alipay' : 'wechat'
const account = alipayId || wechatId
try {
await query(`
INSERT INTO withdrawals (id, user_id, amount, account_type, account, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', NOW())
`, [withdrawId, userId, amount, accountType, account])
INSERT INTO withdrawals (id, user_id, amount, status, wechat_openid, created_at)
VALUES (?, ?, ?, 'pending', ?, NOW())
`, [withdrawId, userId, amount, accountType === 'wechat' ? openId : null])
// TODO: 实际调用微信企业付款或支付宝转账API
// 这里先模拟成功
await query(`UPDATE withdrawals SET status = 'completed', completed_at = NOW() WHERE id = ?`, [withdrawId])
// 微信零钱由后台批准时调用「商家转账到零钱」;支付宝/无 openid 时仅标记成功(需线下打款)
if (accountType !== 'wechat' || !openId) {
await query(`UPDATE withdrawals SET status = 'success', processed_at = NOW() WHERE id = ?`, [withdrawId])
await query(`
UPDATE users SET withdrawn_earnings = withdrawn_earnings + ?, pending_earnings = GREATEST(0, pending_earnings - ?) WHERE id = ?
`, [amount, amount, userId])
}
} catch (e) {
console.log('[Withdraw] 创建提现记录失败:', e)
}

View File

@@ -41,7 +41,7 @@ export default function RootLayout({
<html lang="zh-CN">
<body className="bg-black">
<LayoutWrapper>{children}</LayoutWrapper>
<Analytics />
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>
)

View File

@@ -7,10 +7,11 @@
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Search, ChevronRight, BookOpen, Home, List, User, Users } from "lucide-react"
import { Search, ChevronRight, BookOpen } from "lucide-react"
import { useStore } from "@/lib/store"
import { bookData, getTotalSectionCount } from "@/lib/book-data"
import { SearchModal } from "@/components/search-modal"
import { BottomNav } from "@/components/bottom-nav"
export default function HomePage() {
const router = useRouter()
@@ -214,31 +215,8 @@ export default function HomePage() {
</div>
</main>
<nav className="fixed bottom-0 left-0 right-0 bg-[#1c1c1e]/95 backdrop-blur-xl border-t border-white/5 pb-safe-bottom">
<div className="px-4 py-2">
<div className="flex items-center justify-around">
<button className="flex flex-col items-center py-2 px-4">
<Home className="w-5 h-5 text-[#00CED1] mb-1" />
<span className="text-[#00CED1] text-xs font-medium"></span>
</button>
<button onClick={() => router.push("/chapters")} className="flex flex-col items-center py-2 px-4">
<List className="w-5 h-5 text-gray-500 mb-1" />
<span className="text-gray-500 text-xs"></span>
</button>
{/* 找伙伴按钮 */}
<button onClick={() => router.push("/match")} className="flex flex-col items-center py-2 px-6 -mt-4">
<div className="w-14 h-14 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center shadow-lg shadow-[#00CED1]/30">
<Users className="w-7 h-7 text-white" />
</div>
<span className="text-gray-500 text-xs mt-1"></span>
</button>
<button onClick={() => router.push("/my")} className="flex flex-col items-center py-2 px-4">
<User className="w-5 h-5 text-gray-500 mb-1" />
<span className="text-gray-500 text-xs"></span>
</button>
</div>
</div>
</nav>
{/* 使用统一的底部导航组件 */}
<BottomNav />
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
import { ChapterContent } from "@/components/chapter-content"
import { getSectionBySlug, getChapterBySectionSlug } from "@/lib/book-file-system"
import { specialSections, getSectionById } from "@/lib/book-data"
import { query } from "@/lib/db"
interface ReadPageProps {
params: Promise<{ id: string }>
@@ -10,6 +11,35 @@ interface ReadPageProps {
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
// 从数据库获取章节数据(包含最新的 isFree 状态)
async function getChapterFromDB(id: string) {
try {
const results = await query(
`SELECT id, part_title, chapter_title, section_title, content, is_free, price
FROM chapters
WHERE id = ? AND status = 'published'`,
[id]
) as any[]
if (results && results.length > 0) {
const chapter = results[0]
return {
id: chapter.id,
title: chapter.section_title,
price: chapter.price || 1,
isFree: chapter.is_free === 1 || chapter.price === 0,
filePath: '',
content: chapter.content,
partTitle: chapter.part_title,
chapterTitle: chapter.chapter_title,
}
}
} catch (error) {
console.error("[ReadPage] 从数据库获取章节失败:", error)
}
return null
}
export default async function ReadPage({ params }: ReadPageProps) {
const { id } = await params
@@ -29,7 +59,17 @@ export default async function ReadPage({ params }: ReadPageProps) {
}
try {
// 先从文件系统获取
// 🔥 优先从数据库获取(包含最新的 isFree 状态)
const dbChapter = await getChapterFromDB(id)
if (dbChapter) {
return <ChapterContent
section={dbChapter as any}
partTitle={dbChapter.partTitle || ""}
chapterTitle={dbChapter.chapterTitle || ""}
/>
}
// 如果数据库没有,再从文件系统获取(兼容旧数据)
const section = getSectionBySlug(id)
if (section) {
const context = getChapterBySectionSlug(id)
@@ -38,7 +78,7 @@ export default async function ReadPage({ params }: ReadPageProps) {
}
}
// 再从book-data获取
// 最后从 book-data 获取
const bookSection = getSectionById(id)
if (bookSection) {
return <ChapterContent section={bookSection as any} partTitle="" chapterTitle="" />

View File

@@ -1,219 +0,0 @@
"每个人都在梦想特斯拉帮他挣钱,我现在电动车帮我挣钱。"
2025年10月21日周一早上6点18分。
Soul派对房里进来一个人声音很稳。
他上麦之后,先听了十分钟。
然后说了一句话:"你讲的被动收入,我做了好几年了。"
我愣了一下。
Soul上吹牛的人太多但这个人的语气不像吹牛。
---
"那你是做什么的?"
"电动车。"
"电动车?卖车的?"
”不是,出租的。"
"出租电动车?"
"对在泉州我有1000辆电动车。"
派对房里,突然安静了。
---
"1000辆怎么做的"
他笑了。
"其实很简单。"
"你找一个工厂、工业园区,那里有很多工人,对吧?"
"工人上下班需要交通工具,骑电动车最方便。"
"但买一辆电动车要两三千块,很多人舍不得。"
"那我就租给他们。"
他停了一下。
"一个月三百六十几块,一天算下来才十几块钱。"
"工人觉得划算,我也稳定赚钱。"
---
派对房里,有人打字:"那你一个月能赚多少?"
他说:"1000辆车一个月就是三十多万流水。"
"扣掉成本、维护、人工,净利润大概十几万。"
"关键是,这是被动收入。"
"车放在那里,每个月都有钱进来。"
---
我问:"那你怎么找到这些工厂的?"
他说:"一开始是自己一家一家跑。"
"后来我发现,最好的办法是找做人力的人合作。"
"做人力的,手上有大量的工厂资源。"
"他给我介绍工厂,我给他分成。"
---
派对房里,有人问:"那你现在还在扩张吗?"
他说:"刚投了100多万在河源又铺了500辆。"
我有点惊讶。
"河源?那不是广东那边吗?"
他说:"对我在Soul上认识了一个小伙伴姓李大家叫他犟总。"
"他在河源那边有个工业园区5万多平工人非常多。"
"我们一聊,觉得这个事情可以做,就直接签了。"
---
派对房里,有人说:"等等你们是在Soul上认识的"
他说:"对,就是在这个派对房里。"
我笑了。
"这可能是我们派对房第一个真正落地的合作。"
他说:"可不是嘛。"
"犟总那边做人力,我这边有车,一拍即合。"
"他负责场地和工人,我负责车和运营。"
"500辆车拉过去直接就开始赚钱了。"
---
派对房里,有人问:"那你这个模式能复制吗?"
他说:"当然能。"
"你只要找到有大量人口的地方,工厂、学校、工业园区都行。"
"然后投车进去,租出去就完了。"
他停了一下。
"我现在还在看宝盖山那边。"
"石狮那个理工学校,有两万六的学生。"
"如果能摆电动车进去,又是一个新的点。"
---
我问:"那你这个生意最难的是什么?"
他想了一下。
"最难的是找到对的合作伙伴。"
"你一个人做不了这个事情,你需要有人帮你搞定场地。"
"场地有了,车铺进去,后面就是运营的事情了。"
他继续说:"所以我现在花很多时间在Soul上。"
"因为这里能认识各种各样的人。"
"做人力的、做地产的、做工厂的,什么人都有。"
"你多聊,总能找到合适的合作伙伴。"
---
派对房里,有人问:"那你还做什么?"
他说:"车身广告。"
"我1000辆电动车每辆车身上都可以贴广告。"
"一天一辆车才3毛钱一个月9块钱。"
"但1000辆车一个月就是9000块额外收入。"
"关键是,这个钱几乎没有成本,纯利润。"
---
我问:"所以你的生意模式是,车租出去赚租金,车身贴广告赚广告费?"
他说:"对,两条腿走路。"
"租金是主要收入,广告是锦上添花。"
"以后车多了,广告这块收入会越来越高。"
---
那天聊完已经快9点了。
我在派对房里总结了一下。
"刚才荷包分享的,是一个非常典型的被动收入模式。"
"什么叫被动收入?"
"就是你把资产放在那里,它自己给你赚钱。"
"可以是房子出租,可以是电动车出租,可以是任何有需求的资产。"
我停了一下。
"但被动收入不是躺着赚钱。"
"前期你要投入资金、要找合作伙伴、要铺设网络。"
"等这些都做好了,后面才能相对轻松。"
---
早上9点12分荷包说他要去准备出发了。
"今天500辆车都到河源了我要过去盯一下。"
"祝你顺利。"
"谢了。下次回来给大家汇报进展。"
派对房里有人说:"这才是Soul的正确用法。"
我笑了。
确实,在这里认识的人,在这里谈成的合作,在这里落地的项目。
这才是商业社会里社交的真正价值。
不是认识多少人,而是能不能和对的人一起做对的事。
荷包和犟总,一个有车,一个有场地。
两个人在Soul上认识在现实中落地。
这就是资源整合最简单的样子。

View File

@@ -1,213 +0,0 @@
"有些人手上没有一个项目,但他认识所有有项目的人。"
2025年10月25日周六早上6点15分。
这是我对老墨的第一印象。
Soul派对房里进来一个人声音很稳不像大多数人那样急着表达自己。
他上麦之后,先听了十分钟。
然后说了一句话:"你讲的资源整合我做了15年。"
我愣了一下。
---
"那你是做什么的?"
"财务。"
"财务公司?"
"对,但我不是做账的,我是做资源整合的。"
这句话,让我来了兴趣。
"你看,一家企业需要做账,对吧?"
"但做账只是一个入口。"
"企业还需要什么?税筹、退税、融资、法律、客户资源。"
"我手上有很多企业客户,每一家都有不同的需求。"
他停了一下。
"我的生意就是把A的需求对接给B的资源。"
"中间抽几个点。"
---
派对房里,有人打字:"这不就是中介吗?"
他说:"你可以这么理解。"
"但我不是普通的中介。"
"我是有背书的中介。"
"那你怎么找到这些资源的?"
"我每天花三个小时在Soul派对房里听人聊天。"
我有点惊讶。
"Soul"
他点点头。
"Soul上什么人都有。做税筹的做退税的做供应链的做融资的。"
"我每天上麦,不说话,就听。"
"听他们在讲什么项目,讲什么资源,讲什么需求。"
"然后我加他们微信,进飞书,慢慢聊。"
他停了一下。
"三个月我在Soul上认识了80个老板。"
"每个老板手上都有不同的资源。"
"我的工作,就是把他们链接起来。"
---
派对房里,有人问:"他们为什么愿意给你分钱?"
他说:"因为我给他们带客户。"
他给我们讲了一个案例。
"今年年初我在Soul上认识了一个做退税的老板。"
"他说,他的退税业务,只针对年流水比较大的企业。"
"但他找不到客户。"
他停了一下。
"我手上有很多企业客户,我一筛选,发现有一些符合条件。"
"我说,我把客户给你,你帮我分成。"
"他说,怎么分?"
"我说,你收服务费,分我一部分。"
"他说,没问题。"
---
派对房里,有人问:"那客户为什么愿意接受你的介绍?"
他说:"因为我给他们做账。"
"我每个月给他们发财务报表。"
"报表里面我会写您的企业今年缴税XX万我们有合作伙伴可以帮您优化税务预计可以节省XX万。"
"这句话一写,客户就会问我。"
"我说,我认识一个做税筹的朋友,很靠谱,要不要我介绍给你?"
"客户说,可以。"
他看着我。
"然后我就把客户介绍过去。"
"客户省了钱,我分了成,那个老板也赚了钱。"
"大家都开心。"
---
有人问:"那你怎么保证那个老板靠谱?"
他说:"我会先自己测试。"
"第一次合作,我不会介绍大客户。"
"我先介绍一个小客户,看他怎么做。"
"如果他做得好,客户满意,我再介绍大客户。"
"如果他做得不好,我就不再合作。"
他停了一下。
"所以我手上的资源,都是经过验证的。"
"客户信任我,是因为我只给他们推荐靠谱的人。"
---
派对房里,又是一阵沉默。
"为什么大部分人做不了资源整合?"
"因为他们不舍得分钱。"
"很多人觉得,我介绍客户给你,你应该感谢我。"
"但其实,应该是我感谢他。"
"因为他提供了服务,客户才满意。"
"我只是做了一个链接。"
他停了一下。
"所以我每次介绍客户,都会主动提出分成。"
"我不等他来找我分钱,我先说,这个项目我们怎么分?"
"这样,大家都觉得我很靠谱。"
派对房里,有人打字:"学到了。"
---
那天聊完已经快9点了。
我在派对房里总结了一下。
"刚才那位老板,给了我们一个很好的示范。"
"什么叫资源整合?"
"第一,你要认识足够多的人。不是泛泛之交,是知道他们手上有什么资源。"
"第二,你要有客户。资源整合的本质,是把需求对接给供给。没有需求,你整合不了。"
"第三,你要舍得分钱。不要想着自己吃肉,别人喝汤。你分得越多,大家越愿意跟你合作。"
我停了一下。
"最重要的是,你要让所有人都觉得,跟你合作是赚钱的,不是被你赚钱的。"
---
早上9点05分老墨说他要去见客户了。
临走前他说了一句话:"我手上没有一个项目,但我年入千万。"
我笑了。
这就是资源整合的魅力。
你不需要自己做项目,你只需要认识做项目的人。
然后把他们链接起来,让每个人都赚到钱。
你链接得越多,大家越信任你。
你越舍得分钱,大家越愿意跟你合作。
这不是什么高深的道理,但能做到的人,真的不多。

View File

@@ -1,25 +0,0 @@
#!/bin/bash
# 快速检查部署状态
NAS_USER="fnvtk"
NAS_IP="192.168.2.201"
NAS_PASSWORD="Zhiqun1984"
SUDO_PASSWORD="Zhiqun1984"
DOCKER_CMD="/volume1/@appstore/ContainerManager/usr/bin/docker"
PROJECT_DIR="/volume1/docker/soul-book"
expect << EOF
set timeout 30
spawn ssh -t -o KexAlgorithms=+diffie-hellman-group1-sha1 -o Ciphers=+aes128-cbc,3des-cbc,aes192-cbc,aes256-cbc $NAS_USER@$NAS_IP "sudo $DOCKER_CMD ps -a | grep soul; echo '---'; curl -s http://localhost:3000 | head -20 || echo '服务未响应'"
expect {
"password:" {
send "$NAS_PASSWORD\r"
exp_continue
}
"Password:" {
send "$SUDO_PASSWORD\r"
exp_continue
}
}
expect eof
EOF

View File

@@ -1,11 +1,14 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, List, User, Users } from "lucide-react"
export function BottomNav() {
const pathname = usePathname()
const [matchEnabled, setMatchEnabled] = useState(false) // 默认隐藏,等配置加载后再显示
const [configLoaded, setConfigLoaded] = useState(false) // 配置是否已加载
// 在文档页面、管理后台、阅读页面和关于页面不显示底部导航
if (
@@ -16,11 +19,32 @@ export function BottomNav() {
) {
return null
}
// 加载功能配置
useEffect(() => {
const loadConfig = async () => {
try {
const res = await fetch('/api/db/config')
const data = await res.json()
if (data.features) {
// 根据配置设置是否显示找伙伴按钮
setMatchEnabled(data.features.matchEnabled === true)
}
} catch (e) {
console.log('Load feature config error:', e)
// 加载失败时,默认不显示找伙伴按钮
setMatchEnabled(false)
} finally {
setConfigLoaded(true)
}
}
loadConfig()
}, [])
const navItems = [
{ href: "/", icon: Home, label: "首页" },
{ href: "/chapters", icon: List, label: "目录" },
{ href: "/match", icon: Users, label: "找伙伴", isCenter: true },
...(matchEnabled ? [{ href: "/match", icon: Users, label: "找伙伴", isCenter: true }] : []),
{ href: "/my", icon: User, label: "我的" },
]

View File

@@ -100,18 +100,32 @@ export function UserDetailModal({ open, onClose, userId, onUserUpdated }: UserDe
setEditTags(u.tags ? JSON.parse(u.tags) : [])
}
// 加载行为轨迹
const trackRes = await fetch(`/api/user/track?userId=${userId}&limit=50`)
const trackData = await trackRes.json()
if (trackData.success) {
setTracks(trackData.tracks || [])
// 🔥 加载行为轨迹(可能接口未实现,静默失败)
try {
const trackRes = await fetch(`/api/user/track?userId=${userId}&limit=50`)
if (trackRes.ok) {
const trackData = await trackRes.json()
if (trackData.success) {
setTracks(trackData.tracks || [])
}
}
} catch (err) {
console.log("行为轨迹接口暂未实现,显示占位内容")
setTracks([])
}
// 加载绑定关系
const refRes = await fetch(`/api/db/users/referrals?userId=${userId}`)
const refData = await refRes.json()
if (refData.success) {
setReferrals(refData.referrals || [])
// 🔥 加载绑定关系(静默失败)
try {
const refRes = await fetch(`/api/db/users/referrals?userId=${userId}`)
if (refRes.ok) {
const refData = await refRes.json()
if (refData.success) {
setReferrals(refData.referrals || [])
}
}
} catch (err) {
console.log("绑定关系加载失败,使用默认数据")
setReferrals([])
}
} catch (error) {
@@ -458,9 +472,21 @@ export function UserDetailModal({ open, onClose, userId, onUserUpdated }: UserDe
</div>
))
) : (
<div className="text-center py-12 text-gray-500">
<History className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p></p>
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-[#38bdac]/20 to-[#38bdac]/5 flex items-center justify-center">
<History className="w-10 h-10 text-[#38bdac]/40" />
</div>
<p className="text-gray-400 mb-2">📊 </p>
<p className="text-gray-600 text-sm"></p>
<div className="mt-6 p-4 bg-[#0a1628] rounded-lg text-left">
<p className="text-gray-500 text-xs mb-2"></p>
<ul className="space-y-1 text-gray-600 text-xs">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
)}
</div>

519
content-manager.html Normal file
View File

@@ -0,0 +1,519 @@
<!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>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0a0e17;color:#e0e6ed;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh}
a{color:#2dd4a8;text-decoration:none}
a:hover{text-decoration:underline}
.header{background:#111827;border-bottom:1px solid #1e293b;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}
.header h1{font-size:20px;font-weight:600}
.header .back{color:#94a3b8;font-size:14px}
.container{max-width:1200px;margin:0 auto;padding:24px}
.tabs{display:flex;gap:8px;margin-bottom:24px;flex-wrap:wrap}
.tab{padding:8px 20px;border-radius:8px;cursor:pointer;font-size:14px;border:1px solid #1e293b;background:#111827;color:#94a3b8;transition:all .2s}
.tab.active{background:#2dd4a8;color:#0a0e17;border-color:#2dd4a8;font-weight:600}
.tab:hover:not(.active){background:#1e293b}
.card{background:#111827;border:1px solid #1e293b;border-radius:12px;padding:20px;margin-bottom:16px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:24px}
.stat{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:16px;text-align:center}
.stat .num{font-size:28px;font-weight:700;color:#2dd4a8}
.stat .label{font-size:12px;color:#64748b;margin-top:4px}
.part-header{display:flex;justify-content:space-between;align-items:center;padding:12px 0;cursor:pointer;border-bottom:1px solid #1e293b}
.part-title{font-size:16px;font-weight:600;color:#2dd4a8}
.part-count{font-size:12px;color:#64748b;background:#1e293b;padding:2px 10px;border-radius:10px}
.chapter-group{padding:8px 0 8px 16px}
.chapter-title{font-size:14px;color:#94a3b8;margin:12px 0 8px;font-weight:500}
.section-item{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-radius:8px;transition:background .15s}
.section-item:hover{background:#1e293b}
.section-left{display:flex;align-items:center;gap:10px;flex:1;min-width:0}
.section-id{font-size:12px;color:#64748b;min-width:40px}
.section-title{font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.section-right{display:flex;align-items:center;gap:8px;flex-shrink:0}
.badge{font-size:11px;padding:2px 8px;border-radius:4px;font-weight:500}
.badge-free{background:rgba(45,212,168,.15);color:#2dd4a8}
.badge-paid{background:rgba(234,179,8,.15);color:#eab308}
.btn{padding:5px 12px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid #1e293b;background:#1e293b;color:#e0e6ed;transition:all .15s}
.btn:hover{background:#334155}
.btn-danger{border-color:#7f1d1d;color:#ef4444}
.btn-danger:hover{background:#7f1d1d}
.btn-primary{background:#2dd4a8;color:#0a0e17;border-color:#2dd4a8;font-weight:600}
.btn-primary:hover{background:#22b896}
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;color:#94a3b8;margin-bottom:6px;font-weight:500}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px 12px;background:#0a0e17;border:1px solid #1e293b;border-radius:8px;color:#e0e6ed;font-size:14px;outline:none;transition:border .2s}
.form-group input:focus,.form-group select:focus,.form-group textarea:focus{border-color:#2dd4a8}
.form-group textarea{min-height:200px;font-family:monospace;resize:vertical}
.form-row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.api-doc{font-family:monospace;font-size:13px;line-height:1.7}
.api-doc pre{background:#0a0e17;border:1px solid #1e293b;border-radius:8px;padding:14px;overflow-x:auto;margin:8px 0 16px}
.api-doc code{color:#2dd4a8}
.api-doc h3{color:#e0e6ed;font-size:15px;margin:20px 0 8px;padding-top:12px;border-top:1px solid #1e293b}
.api-doc h3:first-child{border-top:none;margin-top:0}
.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:14px;z-index:9999;animation:slideIn .3s}
.toast-success{background:#065f46;color:#6ee7b7}
.toast-error{background:#7f1d1d;color:#fca5a5}
@keyframes slideIn{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}
.loading{text-align:center;padding:40px;color:#64748b}
.empty{text-align:center;padding:60px;color:#475569}
.search-bar{display:flex;gap:12px;margin-bottom:20px}
.search-bar input{flex:1}
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100;display:flex;align-items:center;justify-content:center}
.modal{background:#111827;border:1px solid #1e293b;border-radius:16px;width:90%;max-width:700px;max-height:85vh;overflow-y:auto;padding:24px}
.modal h2{font-size:18px;margin-bottom:16px}
.modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}
.hidden{display:none}
</style>
</head>
<body>
<div class="header">
<h1>内容管理 · Soul创业派对</h1>
<a class="back" href="/">← 返回管理后台</a>
</div>
<div class="container">
<div class="tabs">
<div class="tab active" data-tab="chapters" onclick="switchTab('chapters')">章节管理</div>
<div class="tab" data-tab="upload" onclick="switchTab('upload')">上传内容</div>
<div class="tab" data-tab="api" onclick="switchTab('api')">API 接口文档</div>
</div>
<!-- 章节管理 -->
<div id="tab-chapters">
<div class="stats" id="stats"></div>
<div class="search-bar">
<input type="text" id="searchInput" placeholder="搜索章节标题..." oninput="filterSections()">
<button class="btn btn-primary" onclick="loadChapters()">刷新</button>
</div>
<div id="chapterList"><div class="loading">加载中...</div></div>
</div>
<!-- 上传内容 -->
<div id="tab-upload" class="hidden">
<div class="card">
<h2 style="margin-bottom:16px">上传新章节</h2>
<div class="form-row">
<div class="form-group">
<label>章节ID (如 1.6,留空自动生成)</label>
<input type="text" id="up_id" placeholder="自动生成">
</div>
<div class="form-group">
<label>定价 (0=免费)</label>
<input type="number" id="up_price" value="1" step="0.1" min="0">
</div>
</div>
<div class="form-group">
<label>标题 *</label>
<input type="text" id="up_title" placeholder="章节标题">
</div>
<div class="form-row">
<div class="form-group">
<label>所属篇</label>
<select id="up_part">
<option value="part-1">第一篇|真实的人</option>
<option value="part-2">第二篇|真实的行业</option>
<option value="part-3">第三篇|真实的错误</option>
<option value="part-4">第四篇|真实的赚钱</option>
<option value="part-5">第五篇|真实的社会</option>
<option value="appendix">附录</option>
<option value="intro">序言</option>
<option value="outro">尾声</option>
</select>
</div>
<div class="form-group">
<label>所属章</label>
<select id="up_chapter">
<option value="chapter-1">第1章人与人之间的底层逻辑</option>
<option value="chapter-2">第2章人性困境案例</option>
<option value="chapter-3">第3章电商篇</option>
<option value="chapter-4">第4章内容商业篇</option>
<option value="chapter-5">第5章传统行业篇</option>
<option value="chapter-6">第6章我人生错过的4件大钱</option>
<option value="chapter-7">第7章别人犯的错误</option>
<option value="chapter-8">第8章底层结构</option>
<option value="chapter-9">第9章我在Soul上亲访的赚钱案例</option>
<option value="chapter-10">第10章未来职业的变化趋势</option>
<option value="chapter-11">第11章中国社会商业生态的未来</option>
<option value="appendix">附录</option>
<option value="preface">序言</option>
<option value="epilogue">尾声</option>
</select>
</div>
</div>
<div class="form-group">
<label>内容 (Markdown格式) *</label>
<textarea id="up_content" placeholder="# 标题&#10;&#10;正文内容...&#10;&#10;图片用 {{image_1}} 占位"></textarea>
</div>
<div class="form-group">
<label>图片URL (每行一个,替换 {{image_1}}, {{image_2}}...)</label>
<textarea id="up_images" style="min-height:80px" placeholder="https://example.com/img1.png&#10;https://example.com/img2.png"></textarea>
</div>
<button class="btn btn-primary" style="width:100%;padding:12px;font-size:15px" onclick="uploadContent()">上传章节</button>
</div>
</div>
<!-- API 接口文档 -->
<div id="tab-api" class="hidden">
<div class="card api-doc">
<h2 style="margin-bottom:16px;font-family:sans-serif">内容管理 API 接口文档</h2>
<p style="color:#94a3b8;margin-bottom:20px;font-family:sans-serif">基础域名:<code>https://soulapi.quwanzhi.com</code>(正式)/ <code>https://souldev.quwanzhi.com</code>(开发)</p>
<h3>1. 获取所有章节</h3>
<pre>GET /api/book/all-chapters
# 无需认证,返回全部章节
curl https://soulapi.quwanzhi.com/api/book/all-chapters</pre>
<p>响应:<code>{"success": true, "data": [{"id":"1.1", "sectionTitle":"...", "isFree":true, "price":0, ...}]}</code></p>
<h3>2. 获取单章内容</h3>
<pre>GET /api/book/chapter/:id
curl https://soulapi.quwanzhi.com/api/book/chapter/1.1</pre>
<p>响应:<code>{"success": true, "data": {"id":"1.1", "content":"# 正文...", ...}}</code></p>
<h3>3. 管理员登录获取Token</h3>
<pre>POST /api/admin
Content-Type: application/json
{"username": "admin", "password": "admin123"}
# 响应包含 token后续请求需带 Authorization: Bearer {token}</pre>
<h3>4. 章节列表(管理员)</h3>
<pre>GET /api/db/book?action=list
Authorization: Bearer {token}
# 返回所有章节的元数据(不含正文)</pre>
<h3>5. 读取章节内容(管理员)</h3>
<pre>GET /api/db/book?action=read&id={section_id}
Authorization: Bearer {token}</pre>
<h3>6. 创建/更新章节(管理员)</h3>
<pre>POST /api/db/book
Authorization: Bearer {token}
Content-Type: application/json
{
"id": "1.6", // 章节ID不传则自动生成
"title": "章节标题",
"content": "Markdown正文",
"price": 1.0, // 定价0=免费
"partId": "part-1", // 所属篇
"chapterId": "chapter-1" // 所属章
}</pre>
<h3>7. 上传内容(数据库直写)</h3>
<p style="color:#94a3b8;font-family:sans-serif">支持从 Cursor Skill / 命令行 直接写入数据库:</p>
<pre># 命令行方式
python3 content_upload.py \
--title "标题" \
--price 1.0 \
--content "正文内容" \
--part part-1 \
--chapter chapter-1 \
--format markdown
# JSON方式
python3 content_upload.py --json '{
"title": "标题",
"price": 1.0,
"content": "正文...",
"part_id": "part-1",
"chapter_id": "chapter-1",
"images": ["https://img.com/1.png"]
}'
# 查看篇章结构
python3 content_upload.py --list-structure
# 列出所有章节
python3 content_upload.py --list-chapters</pre>
<h3>8. 删除章节</h3>
<pre>DELETE /api/admin/content/:id
Authorization: Bearer {token}
curl -X DELETE https://soulapi.quwanzhi.com/api/admin/content/1.6 \
-H "Authorization: Bearer {token}"</pre>
<h3>9. 数据库连接信息</h3>
<pre># 如需直连数据库
Host: 56b4c23f6853c.gz.cdb.myqcloud.com
Port: 14413
User: cdb_outerroot
DB: soul_miniprogram
表: chapters (mid自增主键, id章节号唯一索引)</pre>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div id="editModal" class="modal-overlay hidden">
<div class="modal">
<h2 id="editTitle">编辑章节</h2>
<div class="form-group">
<label>标题</label>
<input type="text" id="edit_title">
</div>
<div class="form-row">
<div class="form-group">
<label>定价</label>
<input type="number" id="edit_price" step="0.1" min="0">
</div>
<div class="form-group">
<label>免费</label>
<select id="edit_free"><option value="1"></option><option value="0"></option></select>
</div>
</div>
<div class="form-group">
<label>内容 (Markdown)</label>
<textarea id="edit_content" style="min-height:300px"></textarea>
</div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">取消</button>
<button class="btn btn-primary" onclick="saveEdit()">保存</button>
</div>
</div>
</div>
<script>
const API_PROD = 'https://soulapi.quwanzhi.com';
const API_DEV = 'https://souldev.quwanzhi.com';
const DB_API = 'https://souldev.quwanzhi.com';
let token = localStorage.getItem('admin_token') || '';
let allSections = [];
let editingId = null;
async function api(method, path, body, base) {
const url = (base || DB_API) + path;
const opts = {method, headers: {'Content-Type':'application/json'}};
if (token) opts.headers['Authorization'] = 'Bearer ' + token;
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
return r.json();
}
async function ensureAuth() {
if (token) {
const r = await api('GET', '/api/admin');
if (r.success) return true;
}
const r = await api('POST', '/api/admin', {username:'admin', password:'admin123'});
if (r.success && r.token) {
token = r.token;
localStorage.setItem('admin_token', token);
return true;
}
showToast('登录失败', 'error');
return false;
}
async function loadChapters() {
document.getElementById('chapterList').innerHTML = '<div class="loading">加载中...</div>';
if (!await ensureAuth()) return;
const r = await api('GET', '/api/db/book?action=list');
let items = r.sections || r.data || r.chapters || [];
allSections = items;
const parts = {};
items.forEach(s => {
const pk = s.partId || s.part_id || 'unknown';
const pt = s.partTitle || s.part_title || pk;
const ck = s.chapterId || s.chapter_id || 'unknown';
const ct = s.chapterTitle || s.chapter_title || ck;
if (!parts[pk]) parts[pk] = {title: pt, chapters: {}};
if (!parts[pk].chapters[ck]) parts[pk].chapters[ck] = {title: ct, sections: []};
parts[pk].chapters[ck].sections.push(s);
});
const partOrder = ['intro','part-1','part-2','part-3','part-4','part-5','outro','appendix'];
const sortedParts = Object.entries(parts).sort((a,b) => {
const ia = partOrder.indexOf(a[0]), ib = partOrder.indexOf(b[0]);
return (ia===-1?99:ia) - (ib===-1?99:ib);
});
const totalParts = sortedParts.length;
const freeCount = items.filter(s => s.isFree || s.is_free).length;
const paidCount = items.length - freeCount;
document.getElementById('stats').innerHTML = `
<div class="stat"><div class="num">${totalParts}</div><div class="label">篇</div></div>
<div class="stat"><div class="num">${items.length}</div><div class="label">节</div></div>
<div class="stat"><div class="num">${freeCount}</div><div class="label">免费</div></div>
<div class="stat"><div class="num">${paidCount}</div><div class="label">付费</div></div>
`;
let html = '';
let partIdx = 0;
sortedParts.forEach(([pk, pv]) => {
partIdx++;
const totalSec = Object.values(pv.chapters).reduce((s,c) => s + c.sections.length, 0);
html += `<div class="card">
<div class="part-header" onclick="this.nextElementSibling.classList.toggle('hidden')">
<span class="part-title">${String(partIdx).padStart(2,'0')} ${pv.title}</span>
<span class="part-count">${totalSec} 节</span>
</div>
<div class="chapter-group">`;
Object.entries(pv.chapters).forEach(([ck, cv]) => {
html += `<div class="chapter-title">${cv.title}</div>`;
cv.sections.forEach(s => {
const isFree = s.isFree || s.is_free;
const price = s.price || 0;
const title = s.sectionTitle || s.section_title || s.title || '';
html += `<div class="section-item" data-title="${title.toLowerCase()}" data-id="${s.id}">
<div class="section-left">
<span class="section-id">${s.id}</span>
<span class="section-title">${title}</span>
</div>
<div class="section-right">
<span class="badge ${isFree?'badge-free':'badge-paid'}">${isFree?'免费':'¥'+price}</span>
<button class="btn" onclick="editSection('${s.id}')">编辑</button>
<button class="btn btn-danger" onclick="deleteSection('${s.id}','${title.replace(/'/g,"\\'")}')">删除</button>
</div>
</div>`;
});
});
html += '</div></div>';
});
document.getElementById('chapterList').innerHTML = html || '<div class="empty">暂无内容</div>';
}
function filterSections() {
const q = document.getElementById('searchInput').value.toLowerCase();
document.querySelectorAll('.section-item').forEach(el => {
el.style.display = el.dataset.title.includes(q) ? '' : 'none';
});
}
async function editSection(id) {
if (!await ensureAuth()) return;
showToast('加载中...');
const r = await api('GET', `/api/db/book?action=read&id=${id}`);
const s = r.data || r.section || r;
editingId = id;
document.getElementById('editTitle').textContent = `编辑: ${id}`;
document.getElementById('edit_title').value = s.sectionTitle || s.section_title || s.title || '';
document.getElementById('edit_price').value = s.price || 0;
document.getElementById('edit_free').value = (s.isFree || s.is_free) ? '1' : '0';
document.getElementById('edit_content').value = s.content || '';
document.getElementById('editModal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('editModal').classList.add('hidden');
editingId = null;
}
async function saveEdit() {
if (!editingId) return;
const data = {
id: editingId,
title: document.getElementById('edit_title').value,
content: document.getElementById('edit_content').value,
price: parseFloat(document.getElementById('edit_price').value) || 0,
isFree: document.getElementById('edit_free').value === '1'
};
const r = await api('POST', '/api/db/book', data);
if (r.success !== false) {
showToast('保存成功');
closeModal();
loadChapters();
} else {
showToast(r.error || '保存失败', 'error');
}
}
async function deleteSection(id, title) {
if (!confirm(`确定删除章节「${title}」(${id})?此操作不可恢复!`)) return;
if (!await ensureAuth()) return;
let r = await api('DELETE', `/api/admin/content/${id}`);
if (r.success === false && r.error) {
r = await api('POST', '/api/db/book', {action:'delete', id});
}
if (r.success !== false) {
showToast('已删除');
loadChapters();
} else {
const ok = confirm('API删除失败是否通过数据库直接删除');
if (ok) {
showToast('正在通过数据库删除...');
try {
const resp = await fetch(DB_API + `/api/db/book?action=delete&id=${id}`, {
method: 'DELETE',
headers: {'Authorization': 'Bearer ' + token}
});
const d = await resp.json();
if (d.success !== false) { showToast('已删除'); loadChapters(); }
else showToast('删除失败: ' + (d.error||''), 'error');
} catch(e) { showToast('删除失败', 'error'); }
}
}
}
async function uploadContent() {
const title = document.getElementById('up_title').value.trim();
const content = document.getElementById('up_content').value.trim();
if (!title) return showToast('请填写标题', 'error');
if (!content) return showToast('请填写内容', 'error');
const images = document.getElementById('up_images').value.trim().split('\n').filter(Boolean);
let processedContent = content;
images.forEach((url, i) => {
processedContent = processedContent.replace(`{{image_${i+1}}}`, `![图片${i+1}](${url.trim()})`);
});
const price = parseFloat(document.getElementById('up_price').value) || 0;
const data = {
id: document.getElementById('up_id').value.trim() || undefined,
title: title,
content: processedContent,
price: price,
isFree: price === 0,
partId: document.getElementById('up_part').value,
chapterId: document.getElementById('up_chapter').value
};
if (!await ensureAuth()) return;
showToast('上传中...');
const r = await api('POST', '/api/db/book', data);
if (r.success !== false) {
showToast('上传成功!');
document.getElementById('up_title').value = '';
document.getElementById('up_content').value = '';
document.getElementById('up_images').value = '';
document.getElementById('up_id').value = '';
switchTab('chapters');
loadChapters();
} else {
showToast('上传失败: ' + (r.error || ''), 'error');
}
}
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
['chapters','upload','api'].forEach(t => {
document.getElementById('tab-' + t).classList.toggle('hidden', t !== name);
});
}
function showToast(msg, type='success') {
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
loadChapters();
</script>
</body>
</html>

275
content_upload.py Normal file
View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
Soul 内容上传接口
可从 Cursor Skill / 命令行直接调用,将新内容写入数据库
用法:
python3 content_upload.py --title "标题" --price 1.0 --content "正文" \
--part part-1 --chapter chapter-1 --format markdown
python3 content_upload.py --json '{
"title": "标题",
"price": 1.0,
"content": "正文内容...",
"part_id": "part-1",
"chapter_id": "chapter-1",
"format": "markdown",
"images": ["https://xxx.com/img1.png"]
}'
python3 content_upload.py --list-structure # 查看篇章结构
环境依赖: pip install pymysql
"""
import argparse
import json
import sys
import re
from datetime import datetime
try:
import pymysql
except ImportError:
print("需要安装 pymysql: pip3 install pymysql")
sys.exit(1)
DB_CONFIG = {
"host": "56b4c23f6853c.gz.cdb.myqcloud.com",
"port": 14413,
"user": "cdb_outerroot",
"password": "Zhiqun1984",
"database": "soul_miniprogram",
"charset": "utf8mb4",
}
PART_MAP = {
"part-1": "第一篇|真实的人",
"part-2": "第二篇|真实的行业",
"part-3": "第三篇|真实的错误",
"part-4": "第四篇|真实的赚钱",
"part-5": "第五篇|真实的社会",
"appendix": "附录",
"intro": "序言",
"outro": "尾声",
}
CHAPTER_MAP = {
"chapter-1": "第1章人与人之间的底层逻辑",
"chapter-2": "第2章人性困境案例",
"chapter-3": "第3章电商篇",
"chapter-4": "第4章内容商业篇",
"chapter-5": "第5章传统行业篇",
"chapter-6": "第6章我人生错过的4件大钱",
"chapter-7": "第7章别人犯的错误",
"chapter-8": "第8章底层结构",
"chapter-9": "第9章我在Soul上亲访的赚钱案例",
"chapter-10": "第10章未来职业的变化趋势",
"chapter-11": "第11章中国社会商业生态的未来",
"appendix": "附录",
"preface": "序言",
"epilogue": "尾声",
}
def get_connection():
return pymysql.connect(**DB_CONFIG)
def list_structure():
conn = get_connection()
cur = conn.cursor()
cur.execute("""
SELECT part_id, part_title, chapter_id, chapter_title, COUNT(*) as sections
FROM chapters
GROUP BY part_id, part_title, chapter_id, chapter_title
ORDER BY part_id, chapter_id
""")
rows = cur.fetchall()
print("篇章结构:")
for part_id, part_title, ch_id, ch_title, cnt in rows:
print(f" {part_id} ({part_title}) / {ch_id} ({ch_title}) - {cnt}")
cur.execute("SELECT COUNT(*) FROM chapters")
total = cur.fetchone()[0]
print(f"\n总计: {total}")
conn.close()
def generate_section_id(cur, chapter_id):
"""根据 chapter 编号自动生成下一个 section id"""
ch_num = re.search(r"\d+", chapter_id)
if not ch_num:
cur.execute("SELECT MAX(CAST(REPLACE(id, '.', '') AS UNSIGNED)) FROM chapters")
max_id = cur.fetchone()[0] or 0
return str(max_id + 1)
prefix = ch_num.group()
cur.execute(
"SELECT id FROM chapters WHERE id LIKE %s ORDER BY CAST(SUBSTRING_INDEX(id, '.', -1) AS UNSIGNED) DESC LIMIT 1",
(f"{prefix}.%",),
)
row = cur.fetchone()
if row:
last_num = int(row[0].split(".")[-1])
return f"{prefix}.{last_num + 1}"
return f"{prefix}.1"
def upload_content(data):
title = data.get("title", "").strip()
if not title:
print("错误: 标题不能为空")
return False
content = data.get("content", "").strip()
if not content:
print("错误: 内容不能为空")
return False
price = float(data.get("price", 1.0))
is_free = 1 if price == 0 else 0
part_id = data.get("part_id", "part-1")
chapter_id = data.get("chapter_id", "chapter-1")
fmt = data.get("format", "markdown")
images = data.get("images", [])
section_id = data.get("id", "")
if images:
for i, img_url in enumerate(images):
placeholder = f"{{{{image_{i+1}}}}}"
if placeholder in content:
if fmt == "markdown":
content = content.replace(placeholder, f"![图片{i+1}]({img_url})")
else:
content = content.replace(placeholder, img_url)
word_count = len(re.sub(r"\s+", "", content))
part_title = PART_MAP.get(part_id, part_id)
chapter_title = CHAPTER_MAP.get(chapter_id, chapter_id)
conn = get_connection()
cur = conn.cursor()
if not section_id:
section_id = generate_section_id(cur, chapter_id)
cur.execute("SELECT mid FROM chapters WHERE id = %s", (section_id,))
existing = cur.fetchone()
try:
if existing:
cur.execute("""
UPDATE chapters SET
section_title = %s, content = %s, word_count = %s,
is_free = %s, price = %s, part_id = %s, part_title = %s,
chapter_id = %s, chapter_title = %s, status = 'published'
WHERE id = %s
""", (title, content, word_count, is_free, price, part_id, part_title,
chapter_id, chapter_title, section_id))
action = "更新"
else:
cur.execute("SELECT COALESCE(MAX(sort_order), 0) + 1 FROM chapters")
next_order = cur.fetchone()[0]
cur.execute("""
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title,
section_title, content, word_count, is_free, price, sort_order, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'published')
""", (section_id, part_id, part_title, chapter_id, chapter_title,
title, content, word_count, is_free, price, next_order))
action = "创建"
conn.commit()
result = {
"success": True,
"action": action,
"data": {
"id": section_id,
"title": title,
"part": f"{part_id} ({part_title})",
"chapter": f"{chapter_id} ({chapter_title})",
"price": price,
"is_free": bool(is_free),
"word_count": word_count,
"format": fmt,
"images_count": len(images),
}
}
print(json.dumps(result, ensure_ascii=False, indent=2))
return True
except pymysql.err.IntegrityError as e:
print(json.dumps({"success": False, "error": f"ID冲突: {e}"}, ensure_ascii=False))
return False
except Exception as e:
conn.rollback()
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False))
return False
finally:
conn.close()
def main():
parser = argparse.ArgumentParser(description="Soul 内容上传接口")
parser.add_argument("--json", help="JSON格式的完整数据")
parser.add_argument("--title", help="标题")
parser.add_argument("--price", type=float, default=1.0, help="定价(0=免费)")
parser.add_argument("--content", help="内容正文")
parser.add_argument("--content-file", help="从文件读取内容")
parser.add_argument("--format", default="markdown", choices=["markdown", "text", "html"])
parser.add_argument("--part", default="part-1", help="所属篇 (part-1 ~ part-5)")
parser.add_argument("--chapter", default="chapter-1", help="所属章 (chapter-1 ~ chapter-11)")
parser.add_argument("--id", help="指定 section ID (如 1.6),不指定则自动生成")
parser.add_argument("--images", nargs="*", help="图片URL列表")
parser.add_argument("--list-structure", action="store_true", help="查看篇章结构")
parser.add_argument("--list-chapters", action="store_true", help="列出所有章节")
args = parser.parse_args()
if args.list_structure:
list_structure()
return
if args.list_chapters:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT id, section_title, is_free, price FROM chapters ORDER BY sort_order")
for row in cur.fetchall():
free_tag = "[免费]" if row[2] else f"{row[3]}]"
print(f" {row[0]} {row[1]} {free_tag}")
conn.close()
return
if args.json:
data = json.loads(args.json)
else:
if not args.title or (not args.content and not args.content_file):
parser.print_help()
print("\n错误: 需要 --title 和 --content (或 --content-file)")
sys.exit(1)
content = args.content
if args.content_file:
with open(args.content_file, "r", encoding="utf-8") as f:
content = f.read()
data = {
"title": args.title,
"price": args.price,
"content": content,
"format": args.format,
"part_id": args.part,
"chapter_id": args.chapter,
"images": args.images or [],
}
if args.id:
data["id"] = args.id
upload_content(data)
if __name__ == "__main__":
main()

225
deploy_miniprogram.py Normal file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Soul创业派对 - 小程序一键部署脚本
功能:
1. 打开微信开发者工具
2. 自动编译小程序
3. 上传到微信平台
4. 显示审核指引
"""
import os
import sys
import time
import subprocess
from pathlib import Path
# 修复Windows控制台编码问题
if sys.platform == 'win32':
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
# 配置信息
CONFIG = {
'appid': 'wxb8bbb2b10dec74aa',
'project_path': Path(__file__).parent / 'miniprogram',
'version': '1.0.1',
'desc': 'Soul创业派对 - 1:1完整还原Web功能'
}
# 微信开发者工具可能的路径
DEVTOOLS_PATHS = [
r"D:\微信web开发者工具\微信开发者工具.exe",
r"C:\Program Files (x86)\Tencent\微信web开发者工具\微信开发者工具.exe",
r"C:\Program Files\Tencent\微信web开发者工具\微信开发者工具.exe",
]
def print_banner():
"""打印横幅"""
print("\n" + "=" * 70)
print(" 🚀 Soul创业派对 - 小程序一键部署")
print("=" * 70 + "\n")
def find_devtools():
"""查找微信开发者工具"""
print("🔍 正在查找微信开发者工具...")
for devtools_path in DEVTOOLS_PATHS:
if os.path.exists(devtools_path):
print(f"✅ 找到微信开发者工具: {devtools_path}\n")
return devtools_path
print("❌ 未找到微信开发者工具")
print("\n请确保已安装微信开发者工具")
print("下载地址: https://developers.weixin.qq.com/miniprogram/dev/devtools/download.html\n")
return None
def open_devtools(devtools_path):
"""打开微信开发者工具"""
print("📱 正在打开微信开发者工具...")
try:
# 使用项目路径打开开发者工具
subprocess.Popen([devtools_path, str(CONFIG['project_path'])])
print("✅ 微信开发者工具已打开\n")
print("⏳ 等待开发者工具启动10秒...")
time.sleep(10)
return True
except Exception as e:
print(f"❌ 打开失败: {e}")
return False
def check_private_key():
"""检查上传密钥"""
key_path = CONFIG['project_path'] / 'private.key'
if not key_path.exists():
print("\n" + "" * 35)
print("\n❌ 未找到上传密钥文件 private.key\n")
print("📥 获取密钥步骤:")
print(" 1. 访问 https://mp.weixin.qq.com/")
print(" 2. 登录小程序后台")
print(" 3. 开发管理 → 开发设置 → 小程序代码上传密钥")
print(" 4. 点击「生成」,下载密钥文件")
print(" 5. 将下载的 private.*.key 重命名为 private.key")
print(f" 6. 放到目录: {CONFIG['project_path']}")
print("\n💡 温馨提示:")
print(" - 密钥只能生成一次,请妥善保管")
print(" - 如需重新生成,需要到后台重置密钥")
print("\n" + "" * 35 + "\n")
return False
print(f"✅ 找到密钥文件: private.key\n")
return True
def upload_miniprogram():
"""上传小程序"""
print("\n" + "-" * 70)
print("📦 准备上传小程序到微信平台...")
print("-" * 70 + "\n")
print(f"📂 项目路径: {CONFIG['project_path']}")
print(f"🆔 AppID: {CONFIG['appid']}")
print(f"📌 版本号: {CONFIG['version']}")
print(f"📝 描述: {CONFIG['desc']}\n")
# 检查密钥
if not check_private_key():
return False
# 切换到miniprogram目录执行上传脚本
upload_script = CONFIG['project_path'] / '上传小程序.py'
if not upload_script.exists():
print(f"❌ 未找到上传脚本: {upload_script}")
return False
print("⏳ 正在执行上传脚本...\n")
try:
result = subprocess.run(
[sys.executable, str(upload_script)],
cwd=CONFIG['project_path'],
capture_output=False, # 直接显示输出
text=True
)
return result.returncode == 0
except Exception as e:
print(f"❌ 上传出错: {e}")
return False
def show_next_steps():
"""显示后续步骤"""
print("\n" + "=" * 70)
print("✅ 部署完成!")
print("=" * 70 + "\n")
print("📱 后续操作:")
print("\n1⃣ 在微信开发者工具中:")
print(" - 查看编译结果")
print(" - 使用模拟器或真机预览测试")
print(" - 确认所有功能正常")
print("\n2⃣ 提交审核:")
print(" - 访问 https://mp.weixin.qq.com/")
print(" - 登录小程序后台")
print(" - 版本管理 → 开发版本")
print(" - 选择刚上传的版本 → 提交审核")
print("\n3⃣ 审核材料准备:")
print(" - 小程序演示视频(可选)")
print(" - 测试账号(如有登录功能)")
print(" - 功能说明(突出核心功能)")
print("\n4⃣ 审核通过后:")
print(" - 在后台点击「发布」")
print(" - 用户即可在微信中搜索使用")
print("\n" + "=" * 70 + "\n")
def main():
"""主函数"""
print_banner()
# 1. 查找微信开发者工具
devtools_path = find_devtools()
if not devtools_path:
print("💡 请先安装微信开发者工具,然后重新运行本脚本")
return False
# 2. 打开微信开发者工具
if not open_devtools(devtools_path):
print("❌ 无法打开微信开发者工具")
return False
print("\n✅ 微信开发者工具已打开,项目已加载")
print("\n💡 现在你可以:")
print(" 1. 在开发者工具中查看和测试小程序")
print(" 2. 使用模拟器或扫码真机预览")
print(" 3. 确认功能正常后,准备上传\n")
# 3. 询问是否立即上传
print("-" * 70)
user_input = input("\n是否立即上传到微信平台?(y/n默认n): ").strip().lower()
if user_input == 'y':
if upload_miniprogram():
show_next_steps()
return True
else:
print("\n❌ 上传失败")
print("\n💡 你可以:")
print(" 1. 检查 private.key 是否正确")
print(" 2. 确保已开启开发者工具的「服务端口」")
print(" 3. 或在开发者工具中手动点击「上传」按钮\n")
return False
else:
print("\n✅ 开发者工具已就绪,你可以:")
print(" 1. 在开发者工具中测试小程序")
print(" 2. 准备好后,运行本脚本并选择上传")
print(" 3. 或直接在开发者工具中点击「上传」按钮\n")
return True
if __name__ == '__main__':
try:
success = main()
sys.exit(0 if success else 1)
except KeyboardInterrupt:
print("\n\n⚠️ 用户取消操作")
sys.exit(1)
except Exception as e:
print(f"\n❌ 发生错误: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

19
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,19 @@
/**
* PM2 配置:用于 standalone 部署的服务器
* 启动方式node server.js不要用 npm start / next startstandalone 无 next 命令)
* 使用pm2 start ecosystem.config.cjs 或 PORT=3006 pm2 start server.js --name soul
*/
module.exports = {
apps: [
{
name: 'soul',
script: 'server.js',
interpreter: 'node',
env: {
NODE_ENV: 'production',
PORT: 3006,
},
cwd: undefined, // 以当前目录为准,部署时在 /www/wwwroot/soul
},
],
};

92
lib/admin-auth.ts Normal file
View File

@@ -0,0 +1,92 @@
/**
* 后台管理员登录鉴权:生成/校验签名 Cookie不暴露账号密码
* 账号密码从环境变量读取,默认 admin / key123456与 .cursorrules 一致)
*/
import { createHmac, timingSafeEqual } from 'crypto'
const COOKIE_NAME = 'admin_session'
const MAX_AGE_SEC = 7 * 24 * 3600 // 7 天
const SECRET = process.env.ADMIN_SESSION_SECRET || 'soul-admin-secret-change-in-prod'
export function getAdminCredentials() {
return {
username: process.env.ADMIN_USERNAME || 'admin',
password: process.env.ADMIN_PASSWORD || 'key123456',
}
}
export function verifyAdminCredentials(username: string, password: string): boolean {
const { username: u, password: p } = getAdminCredentials()
return username === u && password === p
}
function sign(payload: string): string {
return createHmac('sha256', SECRET).update(payload).digest('base64url')
}
/** 生成签名 token写入 Cookie 用 */
export function createAdminToken(): string {
const exp = Math.floor(Date.now() / 1000) + MAX_AGE_SEC
const payload = `${exp}`
const sig = sign(payload)
return `${payload}.${sig}`
}
/** 校验 Cookie 中的 token */
export function verifyAdminToken(token: string | null | undefined): boolean {
if (!token || typeof token !== 'string') return false
const dot = token.indexOf('.')
if (dot === -1) return false
const payload = token.slice(0, dot)
const sig = token.slice(dot + 1)
const exp = parseInt(payload, 10)
if (Number.isNaN(exp) || exp < Math.floor(Date.now() / 1000)) return false
const expected = sign(payload)
if (typeof expected !== 'string' || typeof sig !== 'string') return false
if (sig.length !== expected.length) return false
try {
return timingSafeEqual(Buffer.from(sig, 'base64url'), Buffer.from(expected, 'base64url'))
} catch {
return false
}
}
export function getAdminCookieName() {
return COOKIE_NAME
}
export function getAdminCookieOptions() {
return {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
maxAge: MAX_AGE_SEC,
path: '/',
}
}
/** 从请求中读取 admin cookie 并校验,未通过时返回 null */
export function getAdminTokenFromRequest(request: Request): string | null {
const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) return null
const name = COOKIE_NAME + '='
const start = cookieHeader.indexOf(name)
if (start === -1) return null
const valueStart = start + name.length
const end = cookieHeader.indexOf(';', valueStart)
const value = end === -1 ? cookieHeader.slice(valueStart) : cookieHeader.slice(valueStart, end)
return value.trim() || null
}
/** 若未登录则返回 401 Response供各 admin API 使用 */
export function requireAdminResponse(request: Request): Response | null {
const token = getAdminTokenFromRequest(request)
if (!verifyAdminToken(token)) {
return new Response(JSON.stringify({ error: '未授权访问,请先登录' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
return null
}

View File

@@ -510,6 +510,20 @@ export const bookData: Part[] = [
isFree: false,
filePath: "book/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例/9.14 大健康私域一个月150万的70后.md",
},
{
id: "9.15",
title: "第102场今年第一个红包你发给谁",
price: 1,
isFree: false,
filePath: "book/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例/9.15 第102场今年第一个红包你发给谁.md",
},
{
id: "9.16",
title: "第103场号商、某客与炸房",
price: 1,
isFree: false,
filePath: "book/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例/9.16 第103场号商、某客与炸房.md",
},
],
},
],

182
lib/db.ts
View File

@@ -1,31 +1,47 @@
/**
* 数据库连接配置
* 使用MySQL数据库存储用户、订单、推广关系等数据
* 优先从环境变量读取,便于本地/部署分离;未设置时使用默认值
*/
import mysql from 'mysql2/promise'
// 腾讯云外网数据库配置
const DB_CONFIG = {
host: '56b4c23f6853c.gz.cdb.myqcloud.com',
port: 14413,
user: 'cdb_outerroot',
password: 'Zhiqun1984',
database: 'soul_miniprogram',
host: process.env.MYSQL_HOST || '56b4c23f6853c.gz.cdb.myqcloud.com',
port: Number(process.env.MYSQL_PORT || '14413'),
user: process.env.MYSQL_USER || 'cdb_outerroot',
password: process.env.MYSQL_PASSWORD || 'Zhiqun1984',
database: process.env.MYSQL_DATABASE || 'soul_miniprogram',
charset: 'utf8mb4',
timezone: '+08:00',
acquireTimeout: 60000,
timeout: 60000,
connectTimeout: 10000, // 10 秒,连接不可达时快速失败,避免长时间挂起
acquireTimeout: 15000,
reconnect: true
}
// 本地无数据库时可通过 SKIP_DB=1 跳过连接,接口将使用默认配置
const SKIP_DB = process.env.SKIP_DB === '1' || process.env.SKIP_DB === 'true'
// 连接池
let pool: mysql.Pool | null = null
// 连接类错误只打一次日志,避免刷屏
let connectionErrorLogged = false
function isConnectionError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException)?.code
return (
code === 'ETIMEDOUT' ||
code === 'ECONNREFUSED' ||
code === 'PROTOCOL_CONNECTION_LOST' ||
code === 'ENOTFOUND'
)
}
/**
* 获取数据库连接池
* 获取数据库连接池SKIP_DB 时不创建)
*/
export function getPool() {
export function getPool(): mysql.Pool | null {
if (SKIP_DB) return null
if (!pool) {
pool = mysql.createPool({
...DB_CONFIG,
@@ -41,12 +57,30 @@ export function getPool() {
* 执行SQL查询
*/
export async function query(sql: string, params?: any[]) {
const connection = getPool()
if (!connection) {
throw new Error('数据库未配置或已跳过 (SKIP_DB)')
}
// mysql2 内部会读 params.length不能传 undefined
const safeParams = Array.isArray(params) ? params : []
try {
const connection = getPool()
const [results] = await connection.execute(sql, params)
return results
const [results] = await connection.execute(sql, safeParams)
// 确保调用方拿到的始终是数组,避免 undefined.length 报错
if (Array.isArray(results)) return results
if (results != null) return [results]
return []
} catch (error) {
console.error('数据库查询错误:', error)
if (isConnectionError(error)) {
if (!connectionErrorLogged) {
connectionErrorLogged = true
console.warn(
'[DB] 数据库连接不可用,将使用本地默认配置。',
(error as Error).message
)
}
} else {
console.error('数据库查询错误:', error)
}
throw error
}
}
@@ -57,7 +91,7 @@ export async function query(sql: string, params?: any[]) {
export async function initDatabase() {
try {
console.log('开始初始化数据库表结构...')
// 用户表(完整字段)
await query(`
CREATE TABLE IF NOT EXISTS users (
@@ -88,46 +122,53 @@ export async function initDatabase() {
INDEX idx_referred_by (referred_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 尝试添加可能缺失的字段(用于升级已有数据库)
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS session_key VARCHAR(100)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(100)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by VARCHAR(50)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS match_count_today INT DEFAULT 0')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_match_date DATE')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawn_earnings DECIMAL(10,2) DEFAULT 0')
} catch (e) { /* 忽略 */ }
// 兼容 MySQL 5.7IF NOT EXISTS 在 5.7 不支持,先检查列是否存在
const addColumnIfMissing = async (colName: string, colDef: string) => {
try {
const rows = await query(
"SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = ?",
[colName]
) as any[]
if (!rows?.length) {
await query(`ALTER TABLE users ADD COLUMN ${colName} ${colDef}`)
}
} catch (e) { /* 忽略 */ }
}
await addColumnIfMissing('session_key', 'VARCHAR(100)')
await addColumnIfMissing('password', 'VARCHAR(100)')
await addColumnIfMissing('referred_by', 'VARCHAR(50)')
await addColumnIfMissing('is_admin', 'BOOLEAN DEFAULT FALSE')
await addColumnIfMissing('match_count_today', 'INT DEFAULT 0')
await addColumnIfMissing('last_match_date', 'DATE')
await addColumnIfMissing('withdrawn_earnings', 'DECIMAL(10,2) DEFAULT 0')
await addColumnIfMissing('is_vip', "BOOLEAN DEFAULT FALSE COMMENT 'VIP会员'")
await addColumnIfMissing('vip_expire_date', "TIMESTAMP NULL COMMENT 'VIP到期时间'")
await addColumnIfMissing('vip_name', "VARCHAR(100) COMMENT '会员真实姓名'")
await addColumnIfMissing('vip_project', "VARCHAR(200) COMMENT '会员项目名称'")
await addColumnIfMissing('vip_contact', "VARCHAR(100) COMMENT '会员联系方式'")
await addColumnIfMissing('vip_avatar', "VARCHAR(500) COMMENT '会员展示头像'")
await addColumnIfMissing('vip_bio', "VARCHAR(500) COMMENT '会员简介'")
console.log('用户表初始化完成')
// 订单表
// 订单表(含 referrer_id/referral_code、status 含 created/expired
await query(`
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(50) PRIMARY KEY,
order_sn VARCHAR(50) UNIQUE NOT NULL,
user_id VARCHAR(50) NOT NULL,
open_id VARCHAR(100) NOT NULL,
product_type ENUM('section', 'fullbook', 'match') NOT NULL,
product_type ENUM('section', 'fullbook', 'match', 'vip') NOT NULL,
product_id VARCHAR(50),
amount DECIMAL(10,2) NOT NULL,
description VARCHAR(200),
status ENUM('pending', 'paid', 'cancelled', 'refunded') DEFAULT 'pending',
status ENUM('created', 'pending', 'paid', 'cancelled', 'refunded', 'expired') DEFAULT 'created',
transaction_id VARCHAR(100),
pay_time TIMESTAMP NULL,
referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID用于分销归属',
referral_code VARCHAR(20) NULL COMMENT '下单时使用的邀请码,便于对账与展示',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
@@ -136,7 +177,7 @@ export async function initDatabase() {
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 推广绑定关系表
await query(`
CREATE TABLE IF NOT EXISTS referral_bindings (
@@ -162,7 +203,7 @@ export async function initDatabase() {
INDEX idx_expiry_date (expiry_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 匹配记录表
await query(`
CREATE TABLE IF NOT EXISTS match_records (
@@ -181,7 +222,7 @@ export async function initDatabase() {
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 推广访问记录表(用于统计「通过链接进的人数」)
await query(`
CREATE TABLE IF NOT EXISTS referral_visits (
@@ -197,7 +238,7 @@ export async function initDatabase() {
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 系统配置表
await query(`
CREATE TABLE IF NOT EXISTS system_config (
@@ -210,7 +251,7 @@ export async function initDatabase() {
INDEX idx_config_key (config_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
// 章节内容表 - 存储书籍所有章节
await query(`
CREATE TABLE IF NOT EXISTS chapters (
@@ -234,12 +275,12 @@ export async function initDatabase() {
INDEX idx_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
console.log('数据库表结构初始化完成')
// 插入默认配置
await initDefaultConfig()
} catch (error) {
console.error('初始化数据库失败:', error)
throw error
@@ -267,13 +308,13 @@ async function initDefaultConfig() {
maxMatchesPerDay: 10
}
}
await query(`
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
`, ['match_config', JSON.stringify(matchConfig), '匹配功能配置'])
// 推广配置
const referralConfig = {
distributorShare: 90, // 推广者分成比例
@@ -281,15 +322,15 @@ async function initDefaultConfig() {
bindingDays: 30, // 绑定有效期(天)
userDiscount: 5 // 用户优惠比例
}
await query(`
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE config_value = VALUES(config_value)
`, ['referral_config', JSON.stringify(referralConfig), '推广功能配置'])
console.log('默认配置初始化完成')
} catch (error) {
console.error('初始化默认配置失败:', error)
}
@@ -297,20 +338,23 @@ async function initDefaultConfig() {
/**
* 获取系统配置
* 连接不可达时返回 null由上层使用本地默认配置不重复打日志
*/
export async function getConfig(key: string) {
try {
const results = await query(
'SELECT config_value FROM system_config WHERE config_key = ?',
[key]
) as any[]
if (results.length > 0) {
return results[0].config_value
)
const rows = Array.isArray(results) ? results : (results != null ? [results] : [])
if (rows != null && rows.length > 0) {
return (rows[0] as any)?.config_value ?? null
}
return null
} catch (error) {
console.error('获取配置失败:', error)
if (!isConnectionError(error)) {
console.error('获取配置失败:', error)
}
return null
}
}
@@ -321,13 +365,13 @@ export async function getConfig(key: string) {
export async function setConfig(key: string, value: any, description?: string) {
try {
await query(`
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
config_value = VALUES(config_value),
description = COALESCE(VALUES(description), description)
`, [key, JSON.stringify(value), description])
return true
} catch (error) {
console.error('设置配置失败:', error)
@@ -336,4 +380,4 @@ export async function setConfig(key: string, value: any, description?: string) {
}
// 导出数据库实例
export default { getPool, query, initDatabase, getConfig, setConfig }
export default { getPool, query, initDatabase, getConfig, setConfig }

56
lib/password.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* 密码哈希与校验(仅用于 Web 用户注册/登录,与后台管理员密码无关)
* 使用 Node crypto.scrypt存储格式 saltHex:hashHex兼容旧明文密码
*/
import { scryptSync, timingSafeEqual, randomFillSync } from 'crypto'
const SALT_LEN = 16
const KEYLEN = 32
function bufferToHex(buf: Buffer): string {
return buf.toString('hex')
}
function hexToBuffer(hex: string): Buffer {
return Buffer.from(hex, 'hex')
}
/**
* 对明文密码做哈希,存入数据库
* 格式: saltHex:hashHex约 97 字符,适配 VARCHAR(100)
* 与 verifyPassword 一致:内部先 trim保证注册/登录/重置用同一套规则
*/
export function hashPassword(plain: string): string {
const trimmed = String(plain).trim()
const salt = Buffer.allocUnsafe(SALT_LEN)
randomFillSync(salt)
const hash = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
return bufferToHex(salt) + ':' + bufferToHex(hash)
}
/**
* 校验密码支持新格式salt:hash与旧明文兼容历史数据
* 与 hashPassword 一致:对输入先 trim 再参与校验
*/
export function verifyPassword(plain: string, stored: string | null | undefined): boolean {
const trimmed = String(plain).trim()
if (stored == null || stored === '') {
return trimmed === ''
}
if (stored.includes(':')) {
const [saltHex, hashHex] = stored.split(':')
if (!saltHex || !hashHex || saltHex.length !== SALT_LEN * 2 || hashHex.length !== KEYLEN * 2) {
return false
}
try {
const salt = hexToBuffer(saltHex)
const expected = hexToBuffer(hashHex)
const derived = scryptSync(trimmed, salt, KEYLEN, { N: 16384, r: 8, p: 1 })
return derived.length === expected.length && timingSafeEqual(derived, expected)
} catch {
return false
}
}
return trimmed === stored
}

212
lib/wechat-transfer.ts Normal file
View File

@@ -0,0 +1,212 @@
/**
* 微信支付 V3 - 商家转账到零钱
* 文档: 开发文档/提现功能完整技术文档.md
*/
import crypto from 'crypto'
import fs from 'fs'
import path from 'path'
const BASE_URL = 'https://api.mch.weixin.qq.com'
export interface WechatTransferConfig {
mchId: string
appId: string
apiV3Key: string
privateKeyPath?: string
privateKeyContent?: string
certSerialNo: string
}
function getConfig(): WechatTransferConfig {
const mchId = process.env.WECHAT_MCH_ID || process.env.WECHAT_MCHID || ''
const appId = process.env.WECHAT_APP_ID || process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
const apiV3Key = process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || ''
const keyPath = process.env.WECHAT_KEY_PATH || process.env.WECHAT_MCH_PRIVATE_KEY_PATH || ''
const keyContent = process.env.WECHAT_MCH_PRIVATE_KEY || ''
const certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || ''
return {
mchId,
appId,
apiV3Key,
privateKeyPath: keyPath,
privateKeyContent: keyContent,
certSerialNo,
}
}
function getPrivateKey(): string {
const cfg = getConfig()
if (cfg.privateKeyContent) {
const key = cfg.privateKeyContent.replace(/\\n/g, '\n')
if (!key.includes('BEGIN')) {
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`
}
return key
}
if (cfg.privateKeyPath) {
const p = path.isAbsolute(cfg.privateKeyPath) ? cfg.privateKeyPath : path.join(process.cwd(), cfg.privateKeyPath)
return fs.readFileSync(p, 'utf8')
}
throw new Error('微信商户私钥未配置: WECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_PATH')
}
function generateNonce(length = 32): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let s = ''
for (let i = 0; i < length; i++) {
s += chars.charAt(Math.floor(Math.random() * chars.length))
}
return s
}
/** 生成请求签名 */
function buildSignature(method: string, urlPath: string, timestamp: string, nonce: string, body: string): string {
const message = `${method}\n${urlPath}\n${timestamp}\n${nonce}\n${body}\n`
const key = getPrivateKey()
const sign = crypto.createSign('RSA-SHA256')
sign.update(message)
return sign.sign(key, 'base64')
}
/** 构建 Authorization 头 */
function buildAuthorization(timestamp: string, nonce: string, signature: string): string {
const cfg = getConfig()
return `WECHATPAY2-SHA256-RSA2048 mchid="${cfg.mchId}",nonce_str="${nonce}",signature="${signature}",timestamp="${timestamp}",serial_no="${cfg.certSerialNo}"`
}
export interface CreateTransferParams {
openid: string
amountFen: number
outDetailNo: string
outBatchNo?: string
transferRemark?: string
}
export interface CreateTransferResult {
success: boolean
outBatchNo?: string
batchId?: string
createTime?: string
batchStatus?: string
errorCode?: string
errorMessage?: string
}
/**
* 发起商家转账到零钱
*/
export async function createTransfer(params: CreateTransferParams): Promise<CreateTransferResult> {
const cfg = getConfig()
if (!cfg.mchId || !cfg.appId || !cfg.apiV3Key || !cfg.certSerialNo) {
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整' }
}
const urlPath = '/v3/transfer/batches'
const outBatchNo = params.outBatchNo || `B${Date.now()}${Math.random().toString(36).slice(2, 8)}`
const body = {
appid: cfg.appId,
out_batch_no: outBatchNo,
batch_name: '提现',
batch_remark: params.transferRemark || '用户提现',
total_amount: params.amountFen,
total_num: 1,
transfer_detail_list: [
{
out_detail_no: params.outDetailNo,
transfer_amount: params.amountFen,
transfer_remark: params.transferRemark || '提现',
openid: params.openid,
},
],
transfer_scene_id: '1005',
transfer_scene_report_infos: [
{ info_type: '岗位类型', info_content: '兼职人员' },
{ info_type: '报酬说明', info_content: '当日兼职费' },
],
}
const bodyStr = JSON.stringify(body)
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = generateNonce()
const signature = buildSignature('POST', urlPath, timestamp, nonce, bodyStr)
const authorization = buildAuthorization(timestamp, nonce, signature)
const res = await fetch(`${BASE_URL}${urlPath}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: authorization,
'User-Agent': 'Soul-Withdraw/1.0',
},
body: bodyStr,
})
const data = (await res.json()) as Record<string, unknown>
if (res.ok && res.status >= 200 && res.status < 300) {
return {
success: true,
outBatchNo: data.out_batch_no as string,
batchId: data.batch_id as string,
createTime: data.create_time as string,
batchStatus: data.batch_status as string,
}
}
return {
success: false,
errorCode: (data.code as string) || 'UNKNOWN',
errorMessage: (data.message as string) || (data.error as string) as string || '请求失败',
}
}
/**
* 解密回调 resourceAEAD_AES_256_GCM
*/
export function decryptResource(
ciphertext: string,
nonce: string,
associatedData: string,
apiV3Key: string
): Record<string, unknown> {
if (apiV3Key.length !== 32) {
throw new Error('APIv3密钥必须为32字节')
}
const key = Buffer.from(apiV3Key, 'utf8')
const ct = Buffer.from(ciphertext, 'base64')
const authTag = ct.subarray(ct.length - 16)
const data = ct.subarray(0, ct.length - 16)
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(nonce, 'utf8'))
decipher.setAuthTag(authTag)
decipher.setAAD(Buffer.from(associatedData, 'utf8'))
const dec = decipher.update(data) as Buffer
const final = decipher.final() as Buffer
const json = Buffer.concat([dec, final]).toString('utf8')
return JSON.parse(json) as Record<string, unknown>
}
/**
* 验证回调签名(需平台公钥,可选)
*/
export function verifyCallbackSignature(
timestamp: string,
nonce: string,
body: string,
signature: string,
publicKeyPem: string
): boolean {
const message = `${timestamp}\n${nonce}\n${body}\n`
const sigBuf = Buffer.from(signature, 'base64')
const verify = crypto.createVerify('RSA-SHA256')
verify.update(message)
return verify.verify(publicKeyPem, sigBuf)
}
export interface TransferNotifyDecrypted {
mch_id: string
out_bill_no: string
transfer_bill_no?: string
transfer_amount?: number
state: string
openid?: string
create_time?: string
update_time?: string
}

27
middleware.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const ALLOWED_ORIGINS = [
'https://souladmin.quwanzhi.com',
'http://localhost:5174',
'http://127.0.0.1:5174',
]
export function middleware(request: NextRequest) {
const origin = request.headers.get('origin')
const res = NextResponse.next()
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.headers.set('Access-Control-Allow-Origin', origin)
}
res.headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
res.headers.set('Access-Control-Allow-Headers', 'Content-Type,Authorization')
res.headers.set('Access-Control-Allow-Credentials', 'true')
if (request.method === 'OPTIONS') {
return new NextResponse(null, { status: 204, headers: res.headers })
}
return res
}
export const config = {
matcher: '/api/:path*',
}

View File

@@ -1,14 +1,10 @@
# Windows
[Dd]esktop.ini
Thumbs.db
$RECYCLE.BIN/
# 小程序上传密钥(敏感信息,请勿上传)
private.key
private.*.key
# macOS
# 预览二维码
preview.jpg
# 微信开发者工具生成的文件
.DS_Store
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
# Node.js
node_modules/

View File

@@ -9,7 +9,9 @@
"pages/referral/referral",
"pages/purchases/purchases",
"pages/settings/settings",
"pages/search/search"
"pages/search/search",
"pages/vip/vip",
"pages/member-detail/member-detail"
],
"window": {
"backgroundTextStyle": "light",
@@ -57,4 +59,4 @@
"lazyCodeLoading": "requiredComponents",
"style": "v2",
"sitemapLocation": "sitemap.json"
}
}

View File

@@ -23,21 +23,52 @@ Component({
pagePath: '/pages/match/match',
text: '找伙伴',
iconType: 'match',
isSpecial: true
isSpecial: true,
hidden: true // 默认隐藏,等配置加载后根据后台设置显示
},
{
pagePath: '/pages/my/my',
text: '我的',
iconType: 'user'
}
]
],
matchEnabled: false // 找伙伴功能开关(默认隐藏,等待后台配置加载)
},
attached() {
// 初始化时获取当前页面
this.loadFeatureConfig()
},
methods: {
// 加载功能配置
async loadFeatureConfig() {
try {
const app = getApp()
const res = await app.request('/api/db/config')
if (res.success && res.features) {
const matchEnabled = res.features.matchEnabled === true
this.setData({ matchEnabled })
// 更新list隐藏或显示找伙伴
const list = this.data.list.map(item => {
if (item.iconType === 'match') {
return { ...item, hidden: !matchEnabled }
}
return item
})
this.setData({ list })
console.log('[TabBar] 功能配置加载成功,找伙伴功能:', matchEnabled ? '开启' : '关闭')
}
} catch (e) {
console.log('[TabBar] 加载功能配置失败:', e)
// 失败时默认隐藏找伙伴与Web版保持一致
this.setData({ matchEnabled: false })
}
},
switchTab(e) {
const data = e.currentTarget.dataset
const url = data.path

View File

@@ -30,8 +30,8 @@
<view class="tab-bar-text" style="color: {{selected === 1 ? selectedColor : color}}">{{list[1].text}}</view>
</view>
<!-- 找伙伴 - 中间突出按钮 -->
<view class="tab-bar-item special-item" data-path="{{list[2].pagePath}}" data-index="2" bindtap="switchTab">
<!-- 找伙伴 - 中间突出按钮(可通过后台隐藏) -->
<view wx:if="{{matchEnabled}}" class="tab-bar-item special-item" data-path="{{list[2].pagePath}}" data-index="2" bindtap="switchTab">
<view class="special-button {{selected === 2 ? 'special-active' : ''}}">
<view class="icon-users">
<view class="user-circle user-1"></view>

View File

@@ -1,190 +1,166 @@
/**
* Soul创业派对 - 首页
* 开发: 卡若
* 技术支持: 存客宝
*/
const app = getApp()
Page({
data: {
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
// 用户信息
isLoggedIn: false,
hasFullBook: false,
purchasedCount: 0,
// 书籍数据
totalSections: 62,
bookData: [],
// 推荐章节
featuredSections: [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
],
// 最新章节(动态计算)
featuredSections: [],
latestSection: null,
latestLabel: '最新更新',
// 内容概览
partsList: [
{ id: 'part-1', number: '一', title: '真实的人', subtitle: '人与人之间的底层逻辑' },
{ id: 'part-2', number: '二', title: '真实的行业', subtitle: '电商、内容、传统行业解析' },
{ id: 'part-3', number: '三', title: '真实的错误', subtitle: '我和别人犯过的错' },
{ id: 'part-4', number: '四', title: '真实的赚钱', subtitle: '底层结构与真实案例' },
{ id: 'part-5', number: '五', title: '真实的社会', subtitle: '未来职业与商业生态' }
],
// 加载状态
// 超级个体VIP会员展示
vipMembers: [],
loading: true
},
onLoad(options) {
// 获取系统信息
this.setData({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight
})
// 处理分享参数(推荐码绑定)
if (options && options.ref) {
console.log('[Index] 检测到推荐码:', options.ref)
app.handleReferralCode({ query: options })
}
// 初始化数据
this.initData()
},
onShow() {
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 0 })
}
// 更新用户状态
this.updateUserStatus()
},
// 初始化数据
async initData() {
this.setData({ loading: true })
try {
// 获取书籍数据
await this.loadBookData()
// 计算推荐章节
this.computeLatestSection()
await Promise.all([
this.loadBookData(),
this.loadLatestSection(),
this.loadHotSections(),
this.loadVipMembers()
])
} catch (e) {
console.error('初始化失败:', e)
this.computeLatestSectionFallback()
} finally {
this.setData({ loading: false })
}
},
// 计算推荐章节根据用户ID随机、优先未付款
computeLatestSection() {
const { hasFullBook, purchasedSections } = app.globalData
const userId = app.globalData.userInfo?.id || wx.getStorageSync('userId') || 'guest'
// 所有章节列表
const allSections = [
{ id: '9.14', title: '大健康私域一个月150万的70后', part: '真实的赚钱' },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱' },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱' },
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', part: '真实的赚钱' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱' },
{ id: '3.1', title: '3000万流水如何跑出来', part: '真实的行业' },
{ id: '5.1', title: '拍卖行抱朴一天240万的摇号生意', part: '真实的行业' },
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', part: '真实的行业' }
]
// 用户ID生成的随机种子同一用户每天看到的不同
const today = new Date().toISOString().split('T')[0]
const seed = (userId + today).split('').reduce((a, b) => a + b.charCodeAt(0), 0)
// 筛选未付款章节
let candidates = allSections
if (!hasFullBook) {
const purchased = purchasedSections || []
const unpurchased = allSections.filter(s => !purchased.includes(s.id))
if (unpurchased.length > 0) {
candidates = unpurchased
// 从hot接口获取精选推荐按阅读量排序
async loadHotSections() {
try {
const res = await app.request('/api/book/hot')
if (res?.success && res.chapters?.length) {
this.setData({ featuredSections: res.chapters.slice(0, 5) })
}
} catch (e) {
console.log('[Index] 热门章节加载失败', e)
}
// 根据种子选择章节
const index = seed % candidates.length
const selected = candidates[index]
// 设置标签(如果有新增章节显示"最新更新",否则显示"推荐阅读"
const label = candidates === allSections ? '推荐阅读' : '为你推荐'
this.setData({
latestSection: selected,
latestLabel: label
})
},
// 加载书籍数据
// 加载VIP会员列表
async loadVipMembers() {
try {
const res = await app.request('/api/vip/members?limit=8')
if (res?.success && res.data?.length) {
this.setData({ vipMembers: res.data })
}
} catch (e) {
console.log('[Index] VIP会员加载失败', e)
}
},
async loadLatestSection() {
try {
const res = await app.request('/api/book/latest-chapters')
if (res?.success && res.banner) {
this.setData({ latestSection: res.banner, latestLabel: res.label || '最新更新' })
return
}
} catch (e) {
console.warn('latest-chapters API 失败:', e.message)
}
this.computeLatestSectionFallback()
},
computeLatestSectionFallback() {
const bookData = app.globalData.bookData || this.data.bookData || []
let sections = []
if (Array.isArray(bookData)) {
sections = bookData.map(s => ({
id: s.id, title: s.title || s.sectionTitle,
part: s.part || s.sectionTitle || '真实的行业',
isFree: s.isFree, price: s.price
}))
}
const free = sections.filter(s => s.isFree !== false && (s.price === 0 || !s.price))
const candidates = free.length > 0 ? free : sections
if (!candidates.length) {
this.setData({ latestSection: { id: '1.1', title: '开始阅读', part: '真实的人' }, latestLabel: '为你推荐' })
return
}
const idx = Math.floor(Math.random() * candidates.length)
this.setData({ latestSection: candidates[idx], latestLabel: '为你推荐' })
},
async loadBookData() {
try {
const res = await app.request('/api/book/all-chapters')
if (res && res.data) {
if (res?.data) {
this.setData({
bookData: res.data,
totalSections: res.totalSections || 62
totalSections: res.totalSections || res.data?.length || 62
})
}
} catch (e) {
console.error('加载书籍数据失败:', e)
}
} catch (e) { console.error('加载书籍数据失败:', e) }
},
// 更新用户状态
updateUserStatus() {
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
this.setData({
isLoggedIn,
hasFullBook,
isLoggedIn, hasFullBook,
purchasedCount: hasFullBook ? this.data.totalSections : (purchasedSections?.length || 0)
})
},
// 跳转到目录
goToChapters() {
wx.switchTab({ url: '/pages/chapters/chapters' })
},
// 跳转到搜索页
goToSearch() {
wx.navigateTo({ url: '/pages/search/search' })
},
// 跳转到阅读页
// 阅读时记录行为轨迹
goToRead(e) {
const id = e.currentTarget.dataset.id
// 记录阅读行为(异步,不阻塞跳转)
const userId = app.globalData.userInfo?.id
if (userId) {
app.request('/api/user/track', {
method: 'POST',
data: { userId, action: 'view_chapter', target: id }
}).catch(() => {})
}
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
},
// 跳转到匹配页
goToMatch() {
wx.switchTab({ url: '/pages/match/match' })
goToChapters() { wx.switchTab({ url: '/pages/chapters/chapters' }) },
goToSearch() { wx.navigateTo({ url: '/pages/search/search' }) },
goToMatch() { wx.switchTab({ url: '/pages/match/match' }) },
goToMy() { wx.switchTab({ url: '/pages/my/my' }) },
goToMemberDetail(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${id}` })
},
// 跳转到我的页面
goToMy() {
wx.switchTab({ url: '/pages/my/my' })
},
// 下拉刷新
async onPullDownRefresh() {
await this.initData()
this.updateUserStatus()

View File

@@ -1,16 +1,11 @@
<!--pages/index/index.wxml-->
<!--Soul创业派对 - 首页 1:1还原Web版本-->
<!--Soul创业派对 - 首页-->
<view class="page page-transition">
<!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部区域 -->
<view class="header" style="padding-top: {{statusBarHeight}}px;">
<view class="header-content">
<view class="logo-section">
<view class="logo-icon">
<text class="logo-text">S</text>
</view>
<view class="logo-icon"><text class="logo-text">S</text></view>
<view class="logo-info">
<view class="logo-title">
<text class="text-white">Soul</text>
@@ -23,23 +18,17 @@
<view class="chapter-badge">{{totalSections}}章</view>
</view>
</view>
<!-- 搜索栏 -->
<view class="search-bar" bindtap="goToSearch">
<view class="search-icon">
<view class="search-circle"></view>
<view class="search-handle"></view>
</view>
<view class="search-icon"><view class="search-circle"></view><view class="search-handle"></view></view>
<text class="search-placeholder">搜索章节标题或内容...</text>
</view>
</view>
<!-- 主内容区 -->
<view class="main-content">
<!-- Banner卡片 - 最新章节 -->
<!-- Banner - 最新章节 -->
<view class="banner-card" bindtap="goToRead" data-id="{{latestSection.id}}">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">{{latestLabel}}</view>
<view class="banner-title">{{latestSection.title}}</view>
<view class="banner-part">{{latestSection.part}}</view>
<view class="banner-action">
@@ -79,27 +68,20 @@
</view>
</view>
<!-- 精选推荐 -->
<!-- 精选推荐(按阅读量排序) -->
<view class="section">
<view class="section-header">
<text class="section-title">精选推荐</text>
<view class="section-more" bindtap="goToChapters">
<text class="more-text">查看全部</text>
<text class="more-arrow">→</text>
<text class="more-text">查看全部</text><text class="more-arrow">→</text>
</view>
</view>
<view class="featured-list">
<view
class="featured-item"
wx:for="{{featuredSections}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
>
<view class="featured-item" wx:for="{{featuredSections}}" wx:key="id" bindtap="goToRead" data-id="{{item.id}}">
<view class="featured-content">
<view class="featured-meta">
<text class="featured-id brand-color">{{item.id}}</text>
<text class="tag {{item.tagClass}}">{{item.tag}}</text>
<text class="tag {{item.tagClass || 'tag-pink'}}">{{item.tag || '热门'}}</text>
</view>
<text class="featured-title">{{item.title}}</text>
<text class="featured-part">{{item.part}}</text>
@@ -109,24 +91,22 @@
</view>
</view>
<!-- 内容概览 -->
<view class="section">
<text class="section-title">内容概览</text>
<view class="parts-list">
<view
class="part-item"
wx:for="{{partsList}}"
wx:key="id"
bindtap="goToChapters"
>
<view class="part-icon">
<text class="part-number">{{item.number}}</text>
<!-- 超级个体VIP会员展示 -->
<view class="section" wx:if="{{vipMembers.length > 0}}">
<view class="section-header">
<text class="section-title">超级个体</text>
</view>
<view class="members-grid">
<view class="member-cell" wx:for="{{vipMembers}}" wx:key="id" bindtap="goToMemberDetail" data-id="{{item.id}}">
<view class="member-avatar-wrap">
<image class="member-avatar" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<view class="member-avatar-placeholder" wx:else>
<text>{{item.name[0] || '创'}}</text>
</view>
<view class="member-vip-dot">V</view>
</view>
<view class="part-info">
<text class="part-title">{{item.title}}</text>
<text class="part-subtitle">{{item.subtitle}}</text>
</view>
<view class="part-arrow">→</view>
<text class="member-name">{{item.name}}</text>
<text class="member-project" wx:if="{{item.project}}">{{item.project}}</text>
</view>
</view>
</view>
@@ -141,6 +121,5 @@
</view>
</view>
<!-- 底部留白 -->
<view class="bottom-space"></view>
</view>

View File

@@ -498,6 +498,80 @@
color: rgba(255, 255, 255, 0.6);
}
/* ===== 创业老板排行 ===== */
.members-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
padding: 0 8rpx;
}
.member-cell {
width: calc(25% - 15rpx);
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 0;
}
.member-avatar-wrap {
position: relative;
width: 100rpx;
height: 100rpx;
margin-bottom: 10rpx;
}
.member-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
border: 3rpx solid #FFD700;
}
.member-avatar-placeholder {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background: linear-gradient(135deg, #1c1c1e, #2c2c2e);
border: 3rpx solid #FFD700;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
color: #FFD700;
}
.member-vip-dot {
position: absolute;
bottom: 0;
right: 0;
width: 30rpx;
height: 30rpx;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #000;
font-size: 16rpx;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid #000;
}
.member-name {
font-size: 24rpx;
color: rgba(255,255,255,0.9);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140rpx;
}
.member-project {
font-size: 20rpx;
color: rgba(255,255,255,0.4);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140rpx;
margin-top: 4rpx;
}
/* ===== 底部留白 ===== */
.bottom-space {
height: 40rpx;

View File

@@ -0,0 +1,37 @@
const app = getApp()
Page({
data: {
statusBarHeight: 44,
member: null,
loading: true
},
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
if (options.id) this.loadMember(options.id)
},
async loadMember(id) {
try {
const res = await app.request(`/api/vip/members?id=${id}`)
if (res?.success) {
this.setData({ member: res.data, loading: false })
} else {
this.setData({ loading: false })
wx.showToast({ title: '会员不存在', icon: 'none' })
}
} catch (e) {
this.setData({ loading: false })
wx.showToast({ title: '加载失败', icon: 'none' })
}
},
copyContact() {
const contact = this.data.member?.contact
if (!contact) { wx.showToast({ title: '暂无联系方式', icon: 'none' }); return }
wx.setClipboardData({ data: contact, success: () => wx.showToast({ title: '已复制', icon: 'success' }) })
},
goBack() { wx.navigateBack() }
})

View File

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

View File

@@ -0,0 +1,38 @@
<!--会员详情-->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><text class="back-icon"></text></view>
<text class="nav-title">创业伙伴</text>
<view class="nav-placeholder-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<view class="detail-content" wx:if="{{member}}">
<view class="detail-hero">
<view class="detail-avatar-wrap">
<image class="detail-avatar" wx:if="{{member.avatar}}" src="{{member.avatar}}" mode="aspectFill"/>
<view class="detail-avatar-ph" wx:else><text>{{member.name[0] || '创'}}</text></view>
<view class="detail-vip-badge">VIP</view>
</view>
<text class="detail-name">{{member.name}}</text>
<text class="detail-project" wx:if="{{member.project}}">{{member.project}}</text>
</view>
<view class="detail-card" wx:if="{{member.bio}}">
<text class="detail-card-title">简介</text>
<text class="detail-card-text">{{member.bio}}</text>
</view>
<view class="detail-card" wx:if="{{member.contact}}">
<text class="detail-card-title">联系方式</text>
<view class="detail-contact-row">
<text class="detail-card-text">{{member.contact}}</text>
<view class="copy-btn" bindtap="copyContact">复制</view>
</view>
</view>
</view>
<view class="loading-state" wx:if="{{loading}}">
<text class="loading-text">加载中...</text>
</view>
</view>

View File

@@ -0,0 +1,24 @@
.page { background: #000; min-height: 100vh; color: #fff; }
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; height: 44px; padding: 0 24rpx; background: rgba(0,0,0,0.9); }
.nav-back { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.back-icon { font-size: 44rpx; color: #fff; }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; }
.nav-placeholder-r { width: 60rpx; }
.detail-content { padding: 24rpx; }
.detail-hero { display: flex; flex-direction: column; align-items: center; padding: 48rpx 0 32rpx; }
.detail-avatar-wrap { position: relative; margin-bottom: 20rpx; }
.detail-avatar { width: 160rpx; height: 160rpx; border-radius: 50%; border: 4rpx solid #FFD700; }
.detail-avatar-ph { width: 160rpx; height: 160rpx; border-radius: 50%; background: #1c1c1e; border: 4rpx solid #FFD700; display: flex; align-items: center; justify-content: center; font-size: 60rpx; color: #FFD700; }
.detail-vip-badge { position: absolute; bottom: 4rpx; right: 4rpx; background: linear-gradient(135deg, #FFD700, #FFA500); color: #000; font-size: 20rpx; font-weight: bold; padding: 4rpx 12rpx; border-radius: 14rpx; }
.detail-name { font-size: 40rpx; font-weight: bold; color: #fff; }
.detail-project { font-size: 26rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; }
.detail-card { background: #1c1c1e; border-radius: 20rpx; padding: 28rpx; margin-top: 24rpx; }
.detail-card-title { font-size: 24rpx; color: rgba(255,255,255,0.5); display: block; margin-bottom: 12rpx; }
.detail-card-text { font-size: 30rpx; color: rgba(255,255,255,0.9); }
.detail-contact-row { display: flex; align-items: center; justify-content: space-between; }
.copy-btn { background: #00CED1; color: #000; font-size: 24rpx; font-weight: 600; padding: 8rpx 24rpx; border-radius: 20rpx; }
.loading-state { display: flex; justify-content: center; padding: 100rpx 0; }
.loading-text { color: rgba(255,255,255,0.4); font-size: 28rpx; }

View File

@@ -1,47 +1,44 @@
/**
* Soul创业派对 - 我的页面
* 开发: 卡若
* 技术支持: 存客宝
*/
const app = getApp()
Page({
data: {
// 系统信息
statusBarHeight: 44,
navBarHeight: 88,
// 用户状态
isLoggedIn: false,
userInfo: null,
// 统计数据
totalSections: 62,
purchasedCount: 0,
referralCount: 0,
earnings: 0,
pendingEarnings: 0,
// VIP状态
isVip: false,
vipExpireDate: '',
vipDaysRemaining: 0,
vipPrice: 1980,
// 阅读统计
totalReadTime: 0,
matchHistory: 0,
// Tab切换
activeTab: 'overview', // overview | footprint
// 最近阅读
activeTab: 'overview',
recentChapters: [],
// 菜单列表
// 章节映射表id->title
chapterMap: {},
menuList: [
{ id: 'orders', title: '我的订单', icon: '📦', count: 0 },
{ id: 'referral', title: '推广中心', icon: '🎁', badge: '' },
{ id: 'about', title: '关于作者', icon: '👤', iconBg: 'brand' },
{ id: 'settings', title: '设置', icon: '⚙️', iconBg: 'gray' }
{ id: 'about', title: '关于作者', icon: '👤', iconBg: 'brand' }
],
// 登录弹窗
showLoginModal: false,
isLoggingIn: false
},
@@ -51,35 +48,62 @@ Page({
statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight
})
this.loadChapterMap()
this.initUserStatus()
},
onShow() {
// 设置TabBar选中状态
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
this.getTabBar().setData({ selected: 3 })
}
this.initUserStatus()
},
// 加载章节名称映射
async loadChapterMap() {
try {
const res = await app.request('/api/book/all-chapters')
if (res && res.data) {
const map = {}
const sections = Array.isArray(res.data) ? res.data : []
sections.forEach(s => {
if (s.id) map[s.id] = s.title || s.sectionTitle || `章节 ${s.id}`
})
this.setData({ chapterMap: map })
// 有了映射后刷新最近阅读
this.refreshRecentChapters()
}
} catch (e) {
console.log('[My] 加载章节数据失败', e)
}
},
// 刷新最近阅读列表(用真实标题)
refreshRecentChapters() {
const { purchasedSections } = app.globalData
const map = this.data.chapterMap
const recentList = (purchasedSections || []).slice(-5).reverse().map(id => ({
id,
title: map[id] || `章节 ${id}`
}))
this.setData({ recentChapters: recentList })
},
// 初始化用户状态
initUserStatus() {
async initUserStatus() {
const { isLoggedIn, userInfo, hasFullBook, purchasedSections } = app.globalData
if (isLoggedIn && userInfo) {
// 转换为对象数组
const recentList = (purchasedSections || []).slice(-5).map(id => ({
id: id,
title: `章节 ${id}`
const map = this.data.chapterMap
const recentList = (purchasedSections || []).slice(-5).reverse().map(id => ({
id,
title: map[id] || `章节 ${id}`
}))
// 截短用户ID显示
const userId = userInfo.id || ''
const userIdShort = userId.length > 20 ? userId.slice(0, 10) + '...' + userId.slice(-6) : userId
// 获取微信号(优先显示)
const userWechat = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
this.setData({
isLoggedIn: true,
userInfo,
@@ -92,74 +116,58 @@ Page({
recentChapters: recentList,
totalReadTime: Math.floor(Math.random() * 200) + 50
})
// 查询VIP状态
this.loadVipStatus(userId)
} else {
this.setData({
isLoggedIn: false,
userInfo: null,
userIdShort: '',
purchasedCount: 0,
referralCount: 0,
earnings: 0,
pendingEarnings: 0,
recentChapters: []
isLoggedIn: false, userInfo: null, userIdShort: '',
purchasedCount: 0, referralCount: 0, earnings: 0, pendingEarnings: 0,
recentChapters: [], isVip: false
})
}
},
// 微信原生获取头像button open-type="chooseAvatar" 回调)
// 查询VIP状态
async loadVipStatus(userId) {
try {
const res = await app.request(`/api/vip/status?userId=${userId}`)
if (res && res.success) {
this.setData({
isVip: res.data.isVip,
vipExpireDate: res.data.expireDate || '',
vipDaysRemaining: res.data.daysRemaining || 0,
vipPrice: res.data.price || 1980
})
}
} catch (e) {
console.log('[My] VIP状态查询失败', e)
}
},
// 微信原生获取头像
async onChooseAvatar(e) {
const avatarUrl = e.detail.avatarUrl
if (!avatarUrl) return
wx.showLoading({ title: '更新中...', mask: true })
try {
const userInfo = this.data.userInfo
userInfo.avatar = avatarUrl
this.setData({ userInfo })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 同步到服务器
await app.request('/api/user/update', {
method: 'POST',
data: { userId: userInfo.id, avatar: avatarUrl }
method: 'POST', data: { userId: userInfo.id, avatar: avatarUrl }
})
wx.hideLoading()
wx.showToast({ title: '头像已获取', icon: 'success' })
} catch (e) {
wx.hideLoading()
console.log('同步头像失败', e)
wx.showToast({ title: '头像已更新', icon: 'success' })
}
},
// 微信原生获取昵称input type="nickname" 回调)
async onNicknameInput(e) {
const nickname = e.detail.value
if (!nickname || nickname === this.data.userInfo?.nickname) return
try {
const userInfo = this.data.userInfo
userInfo.nickname = nickname
this.setData({ userInfo })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 同步到服务器
await app.request('/api/user/update', {
method: 'POST',
data: { userId: userInfo.id, nickname }
})
wx.showToast({ title: '昵称已获取', icon: 'success' })
} catch (e) {
console.log('同步昵称失败', e)
}
},
// 点击昵称修改(备用)
// 点击昵称修改
editNickname() {
wx.showModal({
title: '修改昵称',
@@ -169,69 +177,36 @@ Page({
if (res.confirm && res.content) {
const newNickname = res.content.trim()
if (newNickname.length < 1 || newNickname.length > 20) {
wx.showToast({ title: '昵称1-20个字符', icon: 'none' })
return
wx.showToast({ title: '昵称1-20个字符', icon: 'none' }); return
}
// 更新本地
const userInfo = this.data.userInfo
userInfo.nickname = newNickname
this.setData({ userInfo })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
// 同步到服务器
try {
await app.request('/api/user/update', {
method: 'POST',
data: { userId: userInfo.id, nickname: newNickname }
method: 'POST', data: { userId: userInfo.id, nickname: newNickname }
})
} catch (e) {
console.log('同步昵称到服务器失败', e)
}
} catch (e) { console.log('同步昵称失败', e) }
wx.showToast({ title: '昵称已更新', icon: 'success' })
}
}
})
},
// 复制用户ID
copyUserId() {
const userId = this.data.userInfo?.id || ''
if (!userId) {
wx.showToast({ title: '暂无ID', icon: 'none' })
return
}
wx.setClipboardData({
data: userId,
success: () => {
wx.showToast({ title: 'ID已复制', icon: 'success' })
}
})
if (!userId) { wx.showToast({ title: '暂无ID', icon: 'none' }); return }
wx.setClipboardData({ data: userId, success: () => wx.showToast({ title: 'ID已复制', icon: 'success' }) })
},
// 切换Tab
switchTab(e) {
const tab = e.currentTarget.dataset.tab
this.setData({ activeTab: tab })
},
switchTab(e) { this.setData({ activeTab: e.currentTarget.dataset.tab }) },
showLogin() { this.setData({ showLoginModal: true }) },
closeLoginModal() { if (!this.data.isLoggingIn) this.setData({ showLoginModal: false }) },
// 显示登录弹窗
showLogin() {
this.setData({ showLoginModal: true })
},
// 关闭登录弹窗
closeLoginModal() {
if (this.data.isLoggingIn) return
this.setData({ showLoginModal: false })
},
// 微信登录
async handleWechatLogin() {
this.setData({ isLoggingIn: true })
try {
const result = await app.login()
if (result) {
@@ -242,24 +217,13 @@ Page({
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
} catch (e) {
console.error('微信登录错误:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally {
this.setData({ isLoggingIn: false })
}
} finally { this.setData({ isLoggingIn: false }) }
},
// 手机号登录(需要用户授权)
async handlePhoneLogin(e) {
// 检查是否有授权code
if (!e.detail.code) {
// 用户拒绝授权或获取失败,尝试使用微信登录
console.log('手机号授权失败,尝试微信登录')
return this.handleWechatLogin()
}
if (!e.detail.code) return this.handleWechatLogin()
this.setData({ isLoggingIn: true })
try {
const result = await app.loginWithPhone(e.detail.code)
if (result) {
@@ -270,69 +234,71 @@ Page({
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
}
} catch (e) {
console.error('手机号登录错误:', e)
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
} finally {
this.setData({ isLoggingIn: false })
}
} finally { this.setData({ isLoggingIn: false }) }
},
// 点击菜单
handleMenuTap(e) {
const id = e.currentTarget.dataset.id
if (!this.data.isLoggedIn && id !== 'about') {
this.showLogin()
return
}
const routes = {
orders: '/pages/purchases/purchases',
referral: '/pages/referral/referral',
about: '/pages/about/about',
settings: '/pages/settings/settings'
}
if (routes[id]) {
wx.navigateTo({ url: routes[id] })
}
if (!this.data.isLoggedIn && id !== 'about') { this.showLogin(); return }
const routes = { orders: '/pages/purchases/purchases', about: '/pages/about/about' }
if (routes[id]) wx.navigateTo({ url: routes[id] })
},
// 跳转到阅读页
goToRead(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
// 跳转VIP页面
goToVip() {
if (!this.data.isLoggedIn) { this.showLogin(); return }
wx.navigateTo({ url: '/pages/vip/vip' })
},
// 跳转到目录
goToChapters() {
wx.switchTab({ url: '/pages/chapters/chapters' })
bindWechat() {
wx.showModal({
title: '绑定微信号', editable: true, placeholderText: '请输入微信号',
success: async (res) => {
if (res.confirm && res.content) {
const wechat = res.content.trim()
if (!wechat) return
try {
wx.setStorageSync('user_wechat', wechat)
const userInfo = this.data.userInfo
userInfo.wechat = wechat
this.setData({ userInfo, userWechat: wechat })
app.globalData.userInfo = userInfo
wx.setStorageSync('userInfo', userInfo)
await app.request('/api/user/update', {
method: 'POST', data: { userId: userInfo.id, wechat }
})
wx.showToast({ title: '绑定成功', icon: 'success' })
} catch (e) { wx.showToast({ title: '已保存到本地', icon: 'success' }) }
}
}
})
},
// 跳转到关于页
goToAbout() {
wx.navigateTo({ url: '/pages/about/about' })
clearCache() {
wx.showModal({
title: '清除缓存', content: '确定要清除本地缓存吗?不会影响账号数据',
success: (res) => {
if (res.confirm) {
const userInfo = wx.getStorageSync('userInfo')
const token = wx.getStorageSync('token')
wx.clearStorageSync()
if (userInfo) wx.setStorageSync('userInfo', userInfo)
if (token) wx.setStorageSync('token', token)
wx.showToast({ title: '缓存已清除', icon: 'success' })
}
}
})
},
// 跳转到匹配
goToMatch() {
wx.switchTab({ url: '/pages/match/match' })
},
goToRead(e) { wx.navigateTo({ url: `/pages/read/read?id=${e.currentTarget.dataset.id}` }) },
goToChapters() { wx.switchTab({ url: '/pages/chapters/chapters' }) },
goToAbout() { wx.navigateTo({ url: '/pages/about/about' }) },
goToMatch() { wx.switchTab({ url: '/pages/match/match' }) },
// 跳转到推广中心
goToReferral() {
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
wx.navigateTo({ url: '/pages/referral/referral' })
},
// 退出登录
handleLogout() {
wx.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
title: '退出登录', content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
app.logout()
@@ -343,6 +309,5 @@ Page({
})
},
// 阻止冒泡
stopPropagation() {}
})

View File

@@ -1,17 +1,13 @@
<!--pages/my/my.wxml-->
<!--Soul创业实验 - 我的页面 1:1还原Web版本-->
<!--Soul创业实验 - 我的页面-->
<view class="page page-transition">
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<text class="nav-title brand-color">我的</text>
</view>
</view>
<!-- 导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 用户卡片 - 未登录状态 - 只显示登录提示 -->
<!-- 未登录 -->
<view class="user-card card-gradient login-card" wx:if="{{!isLoggedIn}}">
<view class="login-prompt">
<view class="login-icon-large">🔐</view>
@@ -24,22 +20,21 @@
</view>
</view>
<!-- 用户卡片 - 已登录状态 -->
<!-- 已登录 - 用户卡片 -->
<view class="user-card card-gradient" wx:else>
<view class="user-header-row">
<!-- 头像 - 点击选择头像 -->
<button class="avatar-btn-simple" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar">
<view class="avatar {{isVip ? 'avatar-vip' : 'avatar-normal'}}">
<image class="avatar-img" wx:if="{{userInfo.avatar}}" src="{{userInfo.avatar}}" mode="aspectFill"/>
<text class="avatar-text" wx:else>{{userInfo.nickname[0] || '微'}}</text>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
</view>
</button>
<!-- 用户信息 -->
<view class="user-info-block">
<view class="user-name-row">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<text class="edit-icon-small">✎</text>
<view class="vip-tag" wx:if="{{isVip}}">创业伙伴</view>
</view>
<view class="user-id-row" bindtap="copyUserId">
<text class="user-id">{{userWechat ? '微信: ' + userWechat : 'ID: ' + userIdShort}}</text>
@@ -47,7 +42,7 @@
</view>
</view>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value brand-color">{{purchasedCount}}</text>
@@ -58,65 +53,87 @@
<text class="stat-label">推荐好友</text>
</view>
<view class="stat-item">
<text class="stat-value gold-color">{{earnings > 0 ? '¥' + earnings : '--'}}</text>
<text class="stat-label">待领收益</text>
<text class="stat-value gold-color">{{pendingEarnings > 0 ? '¥' + pendingEarnings : '--'}}</text>
<text class="stat-label">我的收益</text>
</view>
</view>
</view>
<!-- Tab切换 - 仅登录用户显示 -->
<view class="tab-bar-custom" wx:if="{{isLoggedIn}}">
<view
class="tab-item {{activeTab === 'overview' ? 'tab-active' : ''}}"
bindtap="switchTab"
data-tab="overview"
>概览</view>
<view
class="tab-item {{activeTab === 'footprint' ? 'tab-active' : ''}}"
bindtap="switchTab"
data-tab="footprint"
>
<text class="tab-icon">👣</text>
<text>我的足迹</text>
<!-- VIP入口卡片 -->
<view class="vip-card" wx:if="{{isLoggedIn}}" bindtap="goToVip">
<view class="vip-card-inner" wx:if="{{!isVip}}">
<view class="vip-card-left">
<text class="vip-card-icon">👑</text>
<view class="vip-card-info">
<text class="vip-card-title">开通VIP会员</text>
<text class="vip-card-desc">解锁全部章节 · 匹配创业伙伴</text>
</view>
</view>
<view class="vip-card-price">
<text class="vip-price-num">¥{{vipPrice}}</text>
<text class="vip-price-unit">/年</text>
</view>
</view>
<view class="vip-card-inner vip-active" wx:else>
<view class="vip-card-left">
<text class="vip-card-icon">👑</text>
<view class="vip-card-info">
<text class="vip-card-title gold-color">VIP会员</text>
<text class="vip-card-desc">剩余 {{vipDaysRemaining}} 天</text>
</view>
</view>
<text class="vip-manage-btn">管理 →</text>
</view>
</view>
<!-- Tab切换 -->
<view class="tab-bar-custom" wx:if="{{isLoggedIn}}">
<view class="tab-item {{activeTab === 'overview' ? 'tab-active' : ''}}" bindtap="switchTab" data-tab="overview">概览</view>
<view class="tab-item {{activeTab === 'footprint' ? 'tab-active' : ''}}" bindtap="switchTab" data-tab="footprint">
<text class="tab-icon">👣</text><text>我的足迹</text>
</view>
</view>
<!-- 概览内容 - 仅登录用户显示 -->
<!-- 概览内容 -->
<view class="tab-content" wx:if="{{activeTab === 'overview' && isLoggedIn}}">
<!-- 菜单列表 -->
<view class="menu-card card">
<view
class="menu-item"
wx:for="{{menuList}}"
wx:key="id"
bindtap="handleMenuTap"
data-id="{{item.id}}"
>
<view class="menu-item" wx:for="{{menuList}}" wx:key="id" bindtap="handleMenuTap" data-id="{{item.id}}">
<view class="menu-left">
<view class="menu-icon {{item.iconBg === 'brand' ? 'icon-brand' : item.iconBg === 'gray' ? 'icon-gray' : ''}}">
{{item.icon}}
</view>
<view class="menu-icon {{item.iconBg === 'brand' ? 'icon-brand' : ''}}">{{item.icon}}</view>
<text class="menu-title">{{item.title}}</text>
</view>
<view class="menu-right">
<text class="menu-count" wx:if="{{item.count !== undefined}}">{{item.count}}笔</text>
<text class="menu-badge gold-color" wx:if="{{item.badge}}">{{item.badge}}</text>
<text class="menu-arrow">→</text>
</view>
</view>
</view>
<view class="settings-card card">
<view class="card-title"><text class="title-icon">⚙️</text><text>账号设置</text></view>
<view class="settings-list">
<view class="settings-item" bindtap="bindWechat">
<text class="settings-label">绑定微信号</text>
<view class="settings-right">
<text class="settings-value">{{userWechat || '未绑定'}}</text>
<text class="menu-arrow">→</text>
</view>
</view>
<view class="settings-item" bindtap="clearCache">
<text class="settings-label">清除缓存</text>
<text class="menu-arrow">→</text>
</view>
<view class="settings-item logout-item" bindtap="handleLogout">
<text class="settings-label logout-text">退出登录</text>
</view>
</view>
</view>
</view>
<!-- 足迹内容 -->
<view class="tab-content" wx:if="{{activeTab === 'footprint' && isLoggedIn}}">
<!-- 阅读统计 -->
<view class="stats-card card">
<view class="card-title">
<text class="title-icon">👁️</text>
<text>阅读统计</text>
</view>
<view class="card-title"><text class="title-icon">👁️</text><text>阅读统计</text></view>
<view class="stats-row">
<view class="stat-box">
<text class="stat-icon brand-color">📖</text>
@@ -136,20 +153,10 @@
</view>
</view>
<!-- 最近阅读 -->
<view class="recent-card card">
<view class="card-title">
<text class="title-icon">📖</text>
<text>最近阅读</text>
</view>
<view class="card-title"><text class="title-icon">📖</text><text>最近阅读</text></view>
<view class="recent-list" wx:if="{{recentChapters.length > 0}}">
<view
class="recent-item"
wx:for="{{recentChapters}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
>
<view class="recent-item" wx:for="{{recentChapters}}" wx:key="id" bindtap="goToRead" data-id="{{item.id}}">
<view class="recent-left">
<text class="recent-index">{{index + 1}}</text>
<text class="recent-title">{{item.title}}</text>
@@ -164,12 +171,8 @@
</view>
</view>
<!-- 匹配记录 -->
<view class="match-card card">
<view class="card-title">
<text class="title-icon">👥</text>
<text>匹配记录</text>
</view>
<view class="card-title"><text class="title-icon">👥</text><text>匹配记录</text></view>
<view class="empty-state">
<text class="empty-icon">👥</text>
<text class="empty-text">暂无匹配记录</text>
@@ -178,27 +181,20 @@
</view>
</view>
<!-- 登录弹窗 - 只保留微信登录 -->
<!-- 登录弹窗 -->
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
<view class="modal-content" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeLoginModal">✕</view>
<view class="login-icon">🔐</view>
<text class="login-title">登录 Soul创业实验</text>
<text class="login-desc">登录后可购买章节、解锁更多内容</text>
<button
class="btn-wechat"
bindtap="handleWechatLogin"
disabled="{{isLoggingIn}}"
>
<button class="btn-wechat" bindtap="handleWechatLogin" disabled="{{isLoggingIn}}">
<text class="btn-wechat-icon">微</text>
<text>{{isLoggingIn ? '登录中...' : '微信快捷登录'}}</text>
</button>
<text class="login-notice">登录即表示同意《用户协议》和《隐私政策》</text>
</view>
</view>
<!-- 底部留白 -->
<view class="bottom-space"></view>
</view>

View File

@@ -994,3 +994,142 @@
font-size: 28rpx;
color: #FFD700;
}
/* 账号设置 */
.settings-card {
margin-top: 24rpx;
}
.settings-list {
margin-top: 16rpx;
}
.settings-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 28rpx 0;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.settings-item:last-child {
border-bottom: none;
}
.settings-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
}
.settings-right {
display: flex;
align-items: center;
gap: 12rpx;
}
.settings-value {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.4);
}
.logout-item {
justify-content: center;
margin-top: 16rpx;
border-bottom: none;
}
.logout-text {
color: #ff4d4f;
font-size: 28rpx;
}
/* === VIP 头像标识 === */
.avatar-normal {
border: 4rpx solid rgba(255,255,255,0.2);
}
.avatar-vip {
border: 4rpx solid #FFD700;
box-shadow: 0 0 16rpx rgba(255,215,0,0.5);
position: relative;
}
.vip-badge {
position: absolute;
bottom: -4rpx;
right: -4rpx;
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #000;
font-size: 18rpx;
font-weight: bold;
padding: 2rpx 10rpx;
border-radius: 12rpx;
line-height: 1.4;
}
.vip-tag {
background: linear-gradient(135deg, #FFD700, #FFA500);
color: #000;
font-size: 20rpx;
font-weight: 600;
padding: 4rpx 14rpx;
border-radius: 16rpx;
margin-left: 12rpx;
}
/* === VIP入口卡片 === */
.vip-card {
margin: 16rpx 24rpx;
border-radius: 20rpx;
overflow: hidden;
}
.vip-card-inner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
background: linear-gradient(135deg, rgba(255,215,0,0.12), rgba(255,165,0,0.08));
border: 1rpx solid rgba(255,215,0,0.25);
border-radius: 20rpx;
}
.vip-card-inner.vip-active {
background: linear-gradient(135deg, rgba(255,215,0,0.2), rgba(255,165,0,0.12));
border-color: rgba(255,215,0,0.4);
}
.vip-card-left {
display: flex;
align-items: center;
gap: 16rpx;
}
.vip-card-icon {
font-size: 44rpx;
}
.vip-card-info {
display: flex;
flex-direction: column;
}
.vip-card-title {
font-size: 30rpx;
font-weight: 600;
color: rgba(255,255,255,0.95);
}
.vip-card-desc {
font-size: 22rpx;
color: rgba(255,255,255,0.5);
margin-top: 4rpx;
}
.vip-card-price {
display: flex;
align-items: baseline;
}
.vip-price-num {
font-size: 36rpx;
font-weight: bold;
color: #FFD700;
}
.vip-price-unit {
font-size: 22rpx;
color: rgba(255,215,0,0.7);
margin-left: 4rpx;
}
.vip-manage-btn {
font-size: 26rpx;
color: #FFD700;
}

View File

@@ -15,7 +15,6 @@ Page({
phoneNumber: '',
wechatId: '',
alipayAccount: '',
address: '',
// 自动提现(默认开启)
autoWithdrawEnabled: true,
@@ -47,7 +46,6 @@ Page({
const phoneNumber = wx.getStorageSync('user_phone') || userInfo.phone || ''
const wechatId = wx.getStorageSync('user_wechat') || userInfo.wechat || ''
const alipayAccount = wx.getStorageSync('user_alipay') || userInfo.alipay || ''
const address = wx.getStorageSync('user_address') || userInfo.address || ''
// 默认开启自动提现
const autoWithdrawEnabled = wx.getStorageSync('auto_withdraw_enabled') !== false
@@ -57,73 +55,11 @@ Page({
phoneNumber,
wechatId,
alipayAccount,
address,
autoWithdrawEnabled
})
}
},
// 一键获取收货地址
getAddress() {
wx.chooseAddress({
success: (res) => {
console.log('[Settings] 获取地址成功:', res)
const fullAddress = `${res.provinceName || ''}${res.cityName || ''}${res.countyName || ''}${res.detailInfo || ''}`
if (fullAddress.trim()) {
wx.setStorageSync('user_address', fullAddress)
this.setData({ address: fullAddress })
// 更新用户信息
if (app.globalData.userInfo) {
app.globalData.userInfo.address = fullAddress
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
// 同步到服务器
this.syncAddressToServer(fullAddress)
wx.showToast({ title: '地址已获取', icon: 'success' })
}
},
fail: (e) => {
console.log('[Settings] 获取地址失败:', e)
if (e.errMsg?.includes('cancel')) {
// 用户取消,不提示
return
}
if (e.errMsg?.includes('auth deny') || e.errMsg?.includes('authorize')) {
wx.showModal({
title: '需要授权',
content: '请在设置中允许获取收货地址',
confirmText: '去设置',
success: (res) => {
if (res.confirm) wx.openSetting()
}
})
} else {
wx.showToast({ title: '获取失败,请重试', icon: 'none' })
}
}
})
},
// 同步地址到服务器
async syncAddressToServer(address) {
try {
const userId = app.globalData.userInfo?.id
if (!userId) return
await app.request('/api/user/update', {
method: 'POST',
data: { userId, address }
})
console.log('[Settings] 地址已同步到服务器')
} catch (e) {
console.log('[Settings] 同步地址失败:', e)
}
},
// 切换自动提现
async toggleAutoWithdraw(e) {
const enabled = e.detail.value

View File

@@ -58,20 +58,6 @@
</view>
</view>
<!-- 收货地址 - 微信一键获取 -->
<view class="bind-item" bindtap="getAddress">
<view class="bind-left">
<view class="bind-icon address-icon">📍</view>
<view class="bind-info">
<text class="bind-label">收货地址</text>
<text class="bind-value address-text">{{address || '未绑定'}}</text>
</view>
</view>
<view class="bind-right">
<text class="bind-check" wx:if="{{address}}">✓</text>
<text class="bind-btn" wx:else>一键获取</text>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,107 @@
const app = getApp()
Page({
data: {
statusBarHeight: 44,
isVip: false,
daysRemaining: 0,
expireDateStr: '',
price: 1980,
rights: [],
profile: { name: '', project: '', contact: '', bio: '' },
purchasing: false
},
onLoad() {
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
this.loadVipInfo()
},
async loadVipInfo() {
const userId = app.globalData.userInfo?.id
if (!userId) return
try {
const res = await app.request(`/api/vip/status?userId=${userId}`)
if (res?.success) {
const d = res.data
let expStr = ''
if (d.expireDate) {
const dt = new Date(d.expireDate)
expStr = `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
}
this.setData({
isVip: d.isVip,
daysRemaining: d.daysRemaining,
expireDateStr: expStr,
price: d.price || 1980,
rights: d.rights || ['解锁全部章节内容365天','匹配所有创业伙伴','创业老板排行榜展示','专属VIP标识']
})
if (d.isVip) this.loadProfile(userId)
}
} catch (e) {
console.log('[VIP] 加载失败', e)
this.setData({ rights: ['解锁全部章节内容365天','匹配所有创业伙伴','创业老板排行榜展示','专属VIP标识'] })
}
},
async loadProfile(userId) {
try {
const res = await app.request(`/api/vip/profile?userId=${userId}`)
if (res?.success) this.setData({ profile: res.data })
} catch (e) { console.log('[VIP] 资料加载失败', e) }
},
async handlePurchase() {
const userId = app.globalData.userInfo?.id
if (!userId) { wx.showToast({ title: '请先登录', icon: 'none' }); return }
this.setData({ purchasing: true })
try {
const res = await app.request('/api/vip/purchase', { method: 'POST', data: { userId } })
if (res?.success) {
// 调用微信支付
const payRes = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: { orderSn: res.data.orderSn, openId: app.globalData.openId }
})
if (payRes?.success && payRes.payParams) {
wx.requestPayment({
...payRes.payParams,
success: () => {
wx.showToast({ title: 'VIP开通成功', icon: 'success' })
this.loadVipInfo()
},
fail: () => wx.showToast({ title: '支付取消', icon: 'none' })
})
} else {
wx.showToast({ title: '支付参数获取失败', icon: 'none' })
}
} else {
wx.showToast({ title: res?.error || '创建订单失败', icon: 'none' })
}
} catch (e) {
console.error('[VIP] 购买失败', e)
wx.showToast({ title: '购买失败', icon: 'none' })
} finally { this.setData({ purchasing: false }) }
},
onNameInput(e) { this.setData({ 'profile.name': e.detail.value }) },
onProjectInput(e) { this.setData({ 'profile.project': e.detail.value }) },
onContactInput(e) { this.setData({ 'profile.contact': e.detail.value }) },
onBioInput(e) { this.setData({ 'profile.bio': e.detail.value }) },
async saveProfile() {
const userId = app.globalData.userInfo?.id
if (!userId) return
const p = this.data.profile
try {
const res = await app.request('/api/vip/profile', {
method: 'POST',
data: { userId, name: p.name, project: p.project, contact: p.contact, bio: p.bio }
})
if (res?.success) wx.showToast({ title: '资料已保存', icon: 'success' })
else wx.showToast({ title: res?.error || '保存失败', icon: 'none' })
} catch (e) { wx.showToast({ title: '保存失败', icon: 'none' }) }
},
goBack() { wx.navigateBack() }
})

View File

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

View File

@@ -0,0 +1,60 @@
<!--VIP会员页-->
<view class="page">
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-back" bindtap="goBack"><text class="back-icon"></text></view>
<text class="nav-title">VIP会员</text>
<view class="nav-placeholder-r"></view>
</view>
<view style="height: {{statusBarHeight + 44}}px;"></view>
<!-- VIP状态卡片 -->
<view class="vip-hero {{isVip ? 'vip-hero-active' : ''}}">
<view class="vip-hero-icon">👑</view>
<text class="vip-hero-title" wx:if="{{!isVip}}">开通VIP年度会员</text>
<text class="vip-hero-title gold" wx:else>VIP会员</text>
<text class="vip-hero-sub" wx:if="{{isVip}}">有效期至 {{expireDateStr}}(剩余{{daysRemaining}}天)</text>
<text class="vip-hero-sub" wx:else>¥{{price}}/年 · 365天全部权益</text>
</view>
<!-- 权益列表 -->
<view class="rights-card">
<text class="rights-title">会员权益</text>
<view class="rights-list">
<view class="rights-item" wx:for="{{rights}}" wx:key="*this">
<text class="rights-check">✓</text>
<text class="rights-text">{{item}}</text>
</view>
</view>
</view>
<!-- 购买按钮 -->
<view class="buy-section" wx:if="{{!isVip}}">
<button class="buy-btn" bindtap="handlePurchase" disabled="{{purchasing}}">
{{purchasing ? '处理中...' : '立即开通 ¥' + price}}
</button>
</view>
<!-- VIP资料填写仅VIP可见 -->
<view class="profile-card" wx:if="{{isVip}}">
<text class="profile-title">会员资料(展示在创业老板排行)</text>
<view class="form-group">
<text class="form-label">姓名</text>
<input class="form-input" placeholder="您的真实姓名" value="{{profile.name}}" bindinput="onNameInput"/>
</view>
<view class="form-group">
<text class="form-label">项目名称</text>
<input class="form-input" placeholder="您的项目/公司名称" value="{{profile.project}}" bindinput="onProjectInput"/>
</view>
<view class="form-group">
<text class="form-label">联系方式</text>
<input class="form-input" placeholder="微信号或手机号" value="{{profile.contact}}" bindinput="onContactInput"/>
</view>
<view class="form-group">
<text class="form-label">一句话简介</text>
<input class="form-input" placeholder="简要描述您的业务" value="{{profile.bio}}" bindinput="onBioInput"/>
</view>
<button class="save-btn" bindtap="saveProfile">保存资料</button>
</view>
<view class="bottom-space"></view>
</view>

View File

@@ -0,0 +1,38 @@
.page { background: #000; min-height: 100vh; color: #fff; }
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; display: flex; align-items: center; justify-content: space-between; height: 44px; padding: 0 24rpx; background: rgba(0,0,0,0.9); }
.nav-back { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.back-icon { font-size: 44rpx; color: #fff; }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; }
.nav-placeholder-r { width: 60rpx; }
.vip-hero {
margin: 24rpx; padding: 48rpx 32rpx; text-align: center;
background: linear-gradient(135deg, rgba(255,215,0,0.1), rgba(255,165,0,0.06));
border: 1rpx solid rgba(255,215,0,0.2); border-radius: 24rpx;
}
.vip-hero-active { border-color: rgba(255,215,0,0.5); background: linear-gradient(135deg, rgba(255,215,0,0.18), rgba(255,165,0,0.1)); }
.vip-hero-icon { font-size: 80rpx; }
.vip-hero-title { display: block; font-size: 40rpx; font-weight: bold; color: #fff; margin-top: 16rpx; }
.vip-hero-title.gold { color: #FFD700; }
.vip-hero-sub { display: block; font-size: 26rpx; color: rgba(255,255,255,0.5); margin-top: 12rpx; }
.rights-card { margin: 24rpx; padding: 28rpx; background: #1c1c1e; border-radius: 20rpx; }
.rights-title { font-size: 30rpx; font-weight: 600; color: rgba(255,255,255,0.9); }
.rights-list { margin-top: 20rpx; }
.rights-item { display: flex; align-items: center; gap: 16rpx; padding: 16rpx 0; border-bottom: 1rpx solid rgba(255,255,255,0.06); }
.rights-item:last-child { border-bottom: none; }
.rights-check { color: #00CED1; font-size: 28rpx; font-weight: bold; }
.rights-text { font-size: 28rpx; color: rgba(255,255,255,0.8); }
.buy-section { padding: 32rpx 24rpx; }
.buy-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, #FFD700, #FFA500); color: #000; font-size: 32rpx; font-weight: bold; border-radius: 44rpx; border: none; }
.buy-btn[disabled] { opacity: 0.5; }
.profile-card { margin: 24rpx; padding: 28rpx; background: #1c1c1e; border-radius: 20rpx; }
.profile-title { font-size: 30rpx; font-weight: 600; color: rgba(255,255,255,0.9); display: block; margin-bottom: 24rpx; }
.form-group { margin-bottom: 20rpx; }
.form-label { font-size: 24rpx; color: rgba(255,255,255,0.5); display: block; margin-bottom: 8rpx; }
.form-input { background: rgba(255,255,255,0.06); border: 1rpx solid rgba(255,255,255,0.1); border-radius: 12rpx; padding: 16rpx 20rpx; font-size: 28rpx; color: #fff; }
.save-btn { margin-top: 24rpx; width: 100%; height: 80rpx; line-height: 80rpx; background: #00CED1; color: #000; font-size: 30rpx; font-weight: 600; border-radius: 40rpx; border: none; }
.bottom-space { height: 120rpx; }

144
miniprogram/upload.js Normal file
View File

@@ -0,0 +1,144 @@
/**
* 小程序自动上传脚本
* 使用前请先安装: npm install miniprogram-ci --save-dev
*/
const ci = require('miniprogram-ci')
const path = require('path')
// 配置信息
const config = {
// 小程序AppID
appid: 'wxb8bbb2b10dec74aa',
// 项目路径
projectPath: path.resolve(__dirname),
// 私钥路径(需要从微信公众平台下载)
// 下载地址:微信公众平台 -> 开发管理 -> 开发设置 -> 小程序代码上传密钥
privateKeyPath: path.resolve(__dirname, './private.key'),
// 版本号(请根据实际情况修改)
version: '1.0.0',
// 版本描述
desc: 'Soul创业派对 - 首次发布',
// 编译设置
setting: {
es6: true,
es7: true,
minifyJS: true,
minifyWXML: true,
minifyWXSS: true,
minify: true,
codeProtect: false,
autoPrefixWXSS: true
}
}
/**
* 上传小程序代码
*/
async function upload() {
console.log('🚀 开始上传小程序...')
console.log('📦 项目路径:', config.projectPath)
console.log('🆔 AppID:', config.appid)
console.log('📌 版本号:', config.version)
try {
// 创建项目实例
const project = new ci.Project({
appid: config.appid,
type: 'miniProgram',
projectPath: config.projectPath,
privateKeyPath: config.privateKeyPath,
ignores: ['node_modules/**/*']
})
console.log('✅ 项目实例创建成功')
// 上传代码
console.log('⏳ 正在上传代码...')
const uploadResult = await ci.upload({
project,
version: config.version,
desc: config.desc,
setting: config.setting,
onProgressUpdate: (info) => {
console.log('📊 上传进度:', info)
}
})
console.log('🎉 上传成功!')
console.log('📝 上传结果:', uploadResult)
console.log('')
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
console.log('✅ 代码已上传到微信公众平台')
console.log('📱 请前往微信公众平台提交审核:')
console.log(' https://mp.weixin.qq.com/')
console.log(' 登录 → 版本管理 → 开发版本 → 提交审核')
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━')
} catch (error) {
console.error('❌ 上传失败:', error.message)
if (error.message.includes('private.key')) {
console.log('')
console.log('⚠️ 缺少密钥文件 private.key')
console.log('📥 请按以下步骤获取:')
console.log(' 1. 访问 https://mp.weixin.qq.com/')
console.log(' 2. 登录小程序后台')
console.log(' 3. 开发管理 → 开发设置 → 小程序代码上传密钥')
console.log(' 4. 点击"生成",下载密钥文件')
console.log(' 5. 将 private.*.key 重命名为 private.key')
console.log(' 6. 放到 miniprogram 目录下')
}
process.exit(1)
}
}
/**
* 预览小程序
*/
async function preview() {
console.log('👀 生成预览二维码...')
try {
const project = new ci.Project({
appid: config.appid,
type: 'miniProgram',
projectPath: config.projectPath,
privateKeyPath: config.privateKeyPath,
ignores: ['node_modules/**/*']
})
const previewResult = await ci.preview({
project,
desc: config.desc,
setting: config.setting,
qrcodeFormat: 'terminal',
qrcodeOutputDest: path.resolve(__dirname, './preview.jpg'),
onProgressUpdate: (info) => {
console.log('📊 生成进度:', info)
}
})
console.log('✅ 二维码已生成:', './miniprogram/preview.jpg')
console.log('📱 使用微信扫码即可预览')
} catch (error) {
console.error('❌ 生成预览失败:', error.message)
process.exit(1)
}
}
// 命令行参数
const command = process.argv[2]
if (command === 'preview') {
preview()
} else {
upload()
}

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Soul创业派对 - 小程序自动上传脚本
使用Python调用微信开发者工具CLI上传小程序
"""
import os
import sys
import subprocess
import json
from pathlib import Path
from datetime import datetime
# 配置信息
CONFIG = {
'appid': 'wxb8bbb2b10dec74aa',
'project_path': Path(__file__).parent.absolute(),
'version': '1.0.0',
'desc': 'Soul创业派对 - 首次发布',
}
# 微信开发者工具CLI可能的路径Mac 优先,再 Windows
CLI_PATHS = [
'/Applications/wechatwebdevtools.app/Contents/MacOS/cli',
os.path.expanduser('~/Applications/wechatwebdevtools.app/Contents/MacOS/cli'),
r"D:\微信web开发者工具\cli.bat",
r"C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat",
r"C:\Program Files\Tencent\微信web开发者工具\cli.bat",
os.path.join(os.environ.get('LOCALAPPDATA', ''), '微信web开发者工具', 'cli.bat'),
]
def print_banner():
"""打印横幅"""
print("\n" + "=" * 60)
print(" 🚀 Soul创业派对 - 小程序自动上传")
print("=" * 60 + "\n")
def find_cli():
"""查找微信开发者工具CLI"""
print("🔍 正在查找微信开发者工具...")
for cli_path in CLI_PATHS:
if os.path.exists(cli_path):
print(f"✅ 找到CLI: {cli_path}\n")
return cli_path
print("❌ 未找到微信开发者工具CLI")
print("\n请确保已安装微信开发者工具,并开启服务端口:")
print(" 1. 打开微信开发者工具")
print(" 2. 设置 → 安全设置")
print(" 3. 勾选「开启服务端口」\n")
return None
def check_private_key():
"""检查上传密钥"""
key_path = CONFIG['project_path'] / 'private.key'
if not key_path.exists():
print("❌ 未找到上传密钥文件 private.key\n")
print("📥 请按以下步骤获取密钥:")
print(" 1. 访问 https://mp.weixin.qq.com/")
print(" 2. 登录小程序后台")
print(" 3. 开发管理 → 开发设置 → 小程序代码上传密钥")
print(" 4. 点击「生成」,下载密钥文件")
print(" 5. 将 private.*.key 重命名为 private.key")
print(f" 6. 放到目录: {CONFIG['project_path']}\n")
return False
print(f"✅ 找到密钥文件: private.key\n")
return True
def check_node_installed():
"""检查Node.js是否安装"""
try:
result = subprocess.run(['node', '--version'],
capture_output=True,
text=True)
if result.returncode == 0:
print(f"✅ Node.js版本: {result.stdout.strip()}")
return True
except FileNotFoundError:
pass
print("❌ 未找到Node.js")
print("\n请先安装Node.js: https://nodejs.org/\n")
return False
def check_miniprogram_ci():
"""检查miniprogram-ci是否安装"""
print("\n🔍 检查上传工具...")
node_modules = CONFIG['project_path'].parent / 'node_modules' / 'miniprogram-ci'
if node_modules.exists():
print("✅ miniprogram-ci已安装\n")
return True
print("⚠️ miniprogram-ci未安装")
print("\n正在安装miniprogram-ci...")
try:
# 切换到项目根目录安装
parent_dir = CONFIG['project_path'].parent
result = subprocess.run(
['npm', 'install', 'miniprogram-ci', '--save-dev'],
cwd=parent_dir,
capture_output=True,
text=True
)
if result.returncode == 0:
print("✅ miniprogram-ci安装成功\n")
return True
else:
print(f"❌ 安装失败: {result.stderr}")
return False
except Exception as e:
print(f"❌ 安装出错: {e}")
return False
def upload_with_nodejs():
"""使用Node.js脚本上传"""
print("📦 使用Node.js上传...")
print(f"📂 项目路径: {CONFIG['project_path']}")
print(f"🆔 AppID: {CONFIG['appid']}")
print(f"📌 版本号: {CONFIG['version']}")
print(f"📝 描述: {CONFIG['desc']}\n")
upload_js = CONFIG['project_path'] / 'upload.js'
if not upload_js.exists():
print(f"❌ 未找到上传脚本: {upload_js}")
return False
try:
print("⏳ 正在上传代码...\n")
result = subprocess.run(
['node', str(upload_js)],
cwd=CONFIG['project_path'],
capture_output=True,
text=True,
timeout=300 # 5分钟超时
)
# 显示输出
if result.stdout:
print(result.stdout)
if result.returncode == 0:
print("\n" + "=" * 60)
print("✅ 上传成功!")
print("=" * 60)
print("\n📱 下一步:")
print(" 1. 访问 https://mp.weixin.qq.com/")
print(" 2. 登录小程序后台")
print(" 3. 版本管理 → 开发版本 → 提交审核")
print("=" * 60 + "\n")
return True
else:
print(f"\n❌ 上传失败")
if result.stderr:
print(f"错误信息: {result.stderr}")
return False
except subprocess.TimeoutExpired:
print("❌ 上传超时超过5分钟")
return False
except Exception as e:
print(f"❌ 上传出错: {e}")
return False
def upload_with_cli(cli_path):
"""使用微信开发者工具CLI上传"""
print("📦 使用微信开发者工具CLI上传...")
print(f"📂 项目路径: {CONFIG['project_path']}")
print(f"🆔 AppID: {CONFIG['appid']}")
print(f"📌 版本号: {CONFIG['version']}")
print(f"📝 描述: {CONFIG['desc']}\n")
key_path = CONFIG['project_path'] / 'private.key'
try:
print("⏳ 正在上传代码...\n")
# 构建上传命令
cmd = [
cli_path,
'upload',
'--project', str(CONFIG['project_path']),
'--version', CONFIG['version'],
'--desc', CONFIG['desc'],
'--pkp', str(key_path)
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300, # 5分钟超时
encoding='utf-8',
errors='ignore'
)
# 显示输出
if result.stdout:
print(result.stdout)
if result.returncode == 0 or '成功' in result.stdout:
print("\n" + "=" * 60)
print("✅ 上传成功!")
print("=" * 60)
print("\n📱 下一步:")
print(" 1. 访问 https://mp.weixin.qq.com/")
print(" 2. 登录小程序后台")
print(" 3. 版本管理 → 开发版本 → 提交审核")
print("=" * 60 + "\n")
return True
else:
print(f"\n❌ 上传失败")
if result.stderr:
print(f"错误信息: {result.stderr}")
return False
except subprocess.TimeoutExpired:
print("❌ 上传超时超过5分钟")
return False
except Exception as e:
print(f"❌ 上传出错: {e}")
return False
def main():
"""主函数"""
print_banner()
# 检查必要条件
print("🔍 检查上传条件...\n")
# 1. 检查密钥
if not check_private_key():
sys.exit(1)
# 2. 检查Node.js
has_node = check_node_installed()
# 3. 查找CLI
cli_path = find_cli()
# 如果没有Node.js也没有CLI退出
if not has_node and not cli_path:
print("❌ 无法上传需要Node.js或微信开发者工具CLI")
sys.exit(1)
print("\n" + "-" * 60 + "\n")
# 优先使用Node.js方式更稳定
if has_node:
if check_miniprogram_ci():
if upload_with_nodejs():
sys.exit(0)
else:
print("\n⚠️ Node.js上传失败尝试使用CLI...\n")
# 备选使用CLI
if cli_path:
if upload_with_cli(cli_path):
sys.exit(0)
print("\n❌ 所有上传方式都失败了")
print("\n💡 建议:")
print(" 1. 确保微信开发者工具已打开")
print(" 2. 确保已开启「服务端口」")
print(" 3. 确保private.key文件正确")
print(" 4. 或手动使用微信开发者工具上传\n")
sys.exit(1)
if __name__ == '__main__':
try:
main()
except KeyboardInterrupt:
print("\n\n⚠️ 用户取消上传")
sys.exit(1)
except Exception as e:
print(f"\n❌ 发生错误: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@@ -0,0 +1,29 @@
@echo off
chcp 65001 >nul
echo.
echo ========================================
echo Soul创业派对 - 快速上传小程序
echo ========================================
echo.
REM 检查Python
python --version >nul 2>&1
if errorlevel 1 (
echo ❌ 未找到Python
echo.
echo 请先安装Python: https://www.python.org/
echo.
pause
exit /b 1
)
echo ✅ Python已安装
echo.
REM 运行上传脚本
echo 🚀 开始上传...
echo.
python "%~dp0上传小程序.py"
echo.
pause

View File

@@ -0,0 +1,74 @@
@echo off
chcp 65001 >nul
echo ==================================
echo Soul派对小程序 - 编译脚本
echo ==================================
echo.
:: 设置项目路径
set "PROJECT_PATH=%~dp0"
set "PROJECT_PATH=%PROJECT_PATH:~0,-1%"
:: 微信开发者工具可能的安装路径
set "CLI1=C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat"
set "CLI2=C:\Program Files\Tencent\微信web开发者工具\cli.bat"
set "CLI3=%LOCALAPPDATA%\微信web开发者工具\cli.bat"
:: 查找CLI
set "CLI="
if exist "%CLI1%" set "CLI=%CLI1%"
if exist "%CLI2%" set "CLI=%CLI2%"
if exist "%CLI3%" set "CLI=%CLI3%"
if "%CLI%"=="" (
echo ❌ 未找到微信开发者工具CLI
echo.
echo 请手动操作:
echo 1. 打开微信开发者工具
echo 2. 点击"导入项目"
echo 3. 选择目录: %PROJECT_PATH%
echo 4. 点击"编译"按钮
echo.
pause
exit /b 1
)
echo ✅ 找到微信开发者工具: %CLI%
echo 项目路径: %PROJECT_PATH%
echo.
:: 1. 打开项目
echo 📂 步骤1打开项目...
call "%CLI%" open --project "%PROJECT_PATH%"
timeout /t 3 /nobreak >nul
echo ✅ 项目已打开
echo.
:: 2. 编译项目
echo 🔨 步骤2编译项目...
call "%CLI%" build-npm --project "%PROJECT_PATH%"
timeout /t 2 /nobreak >nul
echo ✅ 编译完成
echo.
:: 3. 生成预览二维码
echo 📱 步骤3生成预览二维码...
call "%CLI%" preview --project "%PROJECT_PATH%" --qr-format image --qr-output "%PROJECT_PATH%\preview.png"
if exist "%PROJECT_PATH%\preview.png" (
echo ✅ 二维码已生成: %PROJECT_PATH%\preview.png
start "" "%PROJECT_PATH%\preview.png"
) else (
echo ⚠️ 二维码生成失败,请在开发者工具中手动点击"预览"
)
echo.
echo ==================================
echo 🎉 编译完成!
echo ==================================
echo.
echo 下一步操作:
echo 1. 在模拟器中查看效果
echo 2. 点击"预览"生成二维码,用微信扫码测试
echo 3. 点击"上传"提交到微信后台
echo.
pause

View File

@@ -0,0 +1,94 @@
# Soul派对小程序 - Windows编译脚本
Write-Host "==================================" -ForegroundColor Cyan
Write-Host " Soul派对小程序 - 编译脚本" -ForegroundColor Cyan
Write-Host "==================================" -ForegroundColor Cyan
Write-Host ""
# 设置项目路径
$ProjectPath = Split-Path -Parent $MyInvocation.MyCommand.Path
# 微信开发者工具可能的安装路径(优先使用 D 盘)
$cliPaths = @(
"D:\微信web开发者工具\cli.bat",
"C:\Program Files (x86)\Tencent\微信web开发者工具\cli.bat",
"C:\Program Files\Tencent\微信web开发者工具\cli.bat",
"$env:LOCALAPPDATA\微信web开发者工具\cli.bat"
)
# 查找CLI
$cli = $null
foreach ($path in $cliPaths) {
if (Test-Path $path) {
$cli = $path
break
}
}
if (-not $cli) {
Write-Host "未找到微信开发者工具CLI" -ForegroundColor Yellow
Write-Host ""
Write-Host "请手动操作:" -ForegroundColor Cyan
Write-Host "1. 打开微信开发者工具" -ForegroundColor White
Write-Host "2. 点击 '导入项目'" -ForegroundColor White
Write-Host "3. 选择目录: $ProjectPath" -ForegroundColor White
Write-Host "4. 点击 '编译' 按钮" -ForegroundColor White
Write-Host ""
# 尝试启动微信开发者工具
$devToolsPaths = @(
"C:\Program Files (x86)\Tencent\微信web开发者工具\微信开发者工具.exe",
"C:\Program Files\Tencent\微信web开发者工具\微信开发者工具.exe"
)
foreach ($toolPath in $devToolsPaths) {
if (Test-Path $toolPath) {
Write-Host "正在启动微信开发者工具..." -ForegroundColor Green
Start-Process $toolPath
break
}
}
exit 1
}
Write-Host "找到微信开发者工具: $cli" -ForegroundColor Green
Write-Host "项目路径: $ProjectPath" -ForegroundColor Gray
Write-Host ""
# 1. 打开项目
Write-Host "步骤1打开项目..." -ForegroundColor Cyan
& cmd /c "`"$cli`" open --project `"$ProjectPath`""
Start-Sleep -Seconds 3
Write-Host "项目已打开" -ForegroundColor Green
Write-Host ""
# 2. 编译项目
Write-Host "步骤2编译项目..." -ForegroundColor Cyan
& cmd /c "`"$cli`" build-npm --project `"$ProjectPath`""
Start-Sleep -Seconds 2
Write-Host "编译完成" -ForegroundColor Green
Write-Host ""
# 3. 生成预览二维码
Write-Host "步骤3生成预览二维码..." -ForegroundColor Cyan
$previewPath = Join-Path $ProjectPath "preview.png"
& cmd /c "`"$cli`" preview --project `"$ProjectPath`" --qr-format image --qr-output `"$previewPath`""
if (Test-Path $previewPath) {
Write-Host "二维码已生成: $previewPath" -ForegroundColor Green
Start-Process $previewPath
} else {
Write-Host "二维码生成失败,请在开发者工具中手动点击'预览'" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "==================================" -ForegroundColor Cyan
Write-Host " 编译完成!" -ForegroundColor Green
Write-Host "==================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "下一步操作:" -ForegroundColor Cyan
Write-Host "1. 在模拟器中查看效果" -ForegroundColor White
Write-Host "2. 点击'预览'生成二维码,用微信扫码测试" -ForegroundColor White
Write-Host "3. 点击'上传'提交到微信后台" -ForegroundColor White
Write-Host ""

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -12,6 +12,18 @@ const nextConfig = {
buildActivity: false,
appIsrStatus: false,
},
async headers() {
return [
{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE,OPTIONS' },
{ key: 'Access-Control-Allow-Headers', value: 'Content-Type,Authorization' },
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
],
},
]
},
}
export default nextConfig

View File

@@ -7,7 +7,7 @@
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"start": "next start -p 3006"
"start": "PORT=3006 HOSTNAME=0.0.0.0 node .next/standalone/server.js"
},
"dependencies": {
"@emotion/is-prop-valid": "latest",

View File

@@ -1 +0,0 @@
16d770afdc8b7273eb7a93814af01b23

3
requirements-deploy.txt Normal file
View File

@@ -0,0 +1,3 @@
# 仅用于「部署到宝塔」脚本,非项目运行依赖
# 使用: pip install -r requirements-deploy.txt
paramiko>=2.9.0

View File

@@ -0,0 +1,4 @@
# 飞书应用凭证,用于上传海报图片到飞书群
# 复制为 .env.feishu 并填写(需与 webhook 同租户的应用)
FEISHU_APP_ID=your_app_id
FEISHU_APP_SECRET=your_app_secret

370
scripts/deploy_baota.py Normal file
View File

@@ -0,0 +1,370 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Soul 创业派对 - 宝塔一键部署(跨平台)
一键执行: python scripts/deploy_baota.py
依赖: pip install paramiko
流程:本地 pnpm build -> 打包 .next/standalone -> 上传 -> 服务器解压 -> PM2 运行 node server.js
(不从 git 拉取,不在服务器安装依赖或构建。)
"""
from __future__ import print_function
import os
import sys
import getpass
import shutil
import subprocess
import tarfile
import tempfile
import threading
from pathlib import Path
if sys.platform == 'win32':
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
def log(msg, step=None):
"""输出并立即刷新,便于看到进度"""
if step is not None:
print('[步骤 %s] %s' % (step, msg))
else:
print(msg)
sys.stdout.flush()
sys.stderr.flush()
def log_err(msg):
print('>>> 错误: %s' % msg, file=sys.stderr)
sys.stderr.flush()
try:
import paramiko
except ImportError:
log('请先安装: pip install paramiko')
sys.exit(1)
# 默认配置(与 开发文档/服务器管理 一致)
# 应用端口须与 端口配置表 及 Nginx proxy_pass 一致soul -> 3006
CFG = {
'host': os.environ.get('DEPLOY_HOST', '42.194.232.22'),
'port': int(os.environ.get('DEPLOY_PORT', '22')),
'app_port': int(os.environ.get('DEPLOY_APP_PORT', '3006')),
'user': os.environ.get('DEPLOY_USER', 'root'),
'pwd': os.environ.get('DEPLOY_PASSWORD', 'Zhiqun1984'),
'path': os.environ.get('DEPLOY_PROJECT_PATH', '/www/wwwroot/soul'),
'branch': os.environ.get('DEPLOY_BRANCH', 'soul-content'),
'pm2': os.environ.get('DEPLOY_PM2_APP', 'soul'),
'url': os.environ.get('DEPLOY_SITE_URL', 'https://soul.quwanzhi.com'),
'key': os.environ.get('DEPLOY_SSH_KEY') or None,
}
EXCLUDE = {
'node_modules', '.next', '.git', '.gitignore', '.cursorrules',
'scripts', 'miniprogram', '开发文档', 'addons', 'book',
'__pycache__', '.DS_Store', '*.log', 'deploy_config.json',
'requirements-deploy.txt', '*.bat', '*.ps1',
}
def run(ssh, cmd, desc, step_label=None, ignore_err=False):
"""执行远程命令,打印完整输出,失败时明确标出错误和退出码"""
if step_label:
log(desc, step_label)
else:
log(desc)
print(' $ %s' % (cmd[:100] + '...' if len(cmd) > 100 else cmd))
sys.stdout.flush()
stdin, stdout, stderr = ssh.exec_command(cmd, get_pty=True)
out = stdout.read().decode('utf-8', errors='replace')
err = stderr.read().decode('utf-8', errors='replace')
code = stdout.channel.recv_exit_status()
if out:
print(out)
sys.stdout.flush()
if err:
print(err, file=sys.stderr)
sys.stderr.flush()
if code != 0:
log_err('退出码: %s | %s' % (code, desc))
if err and len(err.strip()) > 0:
for line in err.strip().split('\n')[-5:]:
print(' stderr: %s' % line, file=sys.stderr)
sys.stderr.flush()
return ignore_err
return True
def _read_and_print(stream, prefix=' ', is_stderr=False):
"""后台线程:不断读 stream 并打印,用于实时输出"""
import threading
out = sys.stderr if is_stderr else sys.stdout
try:
while True:
line = stream.readline()
if not line:
break
s = line.decode('utf-8', errors='replace').rstrip()
if s:
print('%s%s' % (prefix, s), file=out)
out.flush()
except Exception:
pass
def run_stream(ssh, cmd, desc, step_label=None, ignore_err=False):
"""执行远程命令并实时输出npm install / build 不卡住、能看到进度)"""
if step_label:
log(desc, step_label)
else:
log(desc)
print(' $ %s' % (cmd[:100] + '...' if len(cmd) > 100 else cmd))
sys.stdout.flush()
stdin, stdout, stderr = ssh.exec_command(cmd, get_pty=True)
t1 = threading.Thread(target=_read_and_print, args=(stdout, ' ', False))
t2 = threading.Thread(target=_read_and_print, args=(stderr, ' [stderr] ', True))
t1.daemon = True
t2.daemon = True
t1.start()
t2.start()
t1.join()
t2.join()
code = stdout.channel.recv_exit_status()
if code != 0:
log_err('退出码: %s | %s' % (code, desc))
return ignore_err
return True
def _tar_filter(ti):
n = ti.name.replace('\\', '/')
if 'node_modules' in n or '.next' in n or '.git' in n:
return None
if '/scripts/' in n or n.startswith('scripts/'):
return None
if '/miniprogram/' in n or n.startswith('miniprogram/'):
return None
if '/开发文档/' in n or '开发文档/' in n:
return None
if '/addons/' in n or '/book/' in n:
return None
return ti
def make_tarball(root_dir):
root = Path(root_dir).resolve()
tmp = tempfile.NamedTemporaryFile(suffix='.tar.gz', delete=False)
tmp.close()
with tarfile.open(tmp.name, 'w:gz') as tar:
for item in root.iterdir():
name = item.name
if name in EXCLUDE or name.endswith('.md') or (name.startswith('.') and name != '.cursorrules'):
continue
if name.startswith('deploy_config') or name.endswith('.bat') or name.endswith('.ps1'):
continue
arcname = name
tar.add(str(item), arcname=arcname, filter=_tar_filter)
return tmp.name
def run_local_build(local_root, step_label=None):
"""本地执行 pnpm build实时输出"""
root = Path(local_root).resolve()
if step_label:
log('本地构建 pnpm buildstandalone', step_label)
else:
log('本地构建 pnpm buildstandalone')
cmd_str = 'pnpm build'
print(' $ %s' % cmd_str)
sys.stdout.flush()
try:
# Windows 下用 shell=True否则子进程 PATH 里可能没有 pnpm
use_shell = sys.platform == 'win32'
p = subprocess.Popen(
cmd_str if use_shell else ['pnpm', 'build'],
cwd=str(root),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
universal_newlines=True,
encoding='utf-8',
errors='replace',
shell=use_shell,
)
for line in p.stdout:
print(' %s' % line.rstrip())
sys.stdout.flush()
code = p.wait()
if code != 0:
log_err('本地构建失败,退出码 %s' % code)
return False
return True
except Exception as e:
log_err('本地构建异常: %s' % e)
return False
def make_standalone_tarball(local_root):
"""
在 next.config 已设置 output: 'standalone' 且已执行 pnpm build 的前提下,
将 .next/static 和 public 复制进 .next/standalone再打包 .next/standalone 目录内容。
返回生成的 tar.gz 路径。
"""
root = Path(local_root).resolve()
standalone_dir = root / '.next' / 'standalone'
static_src = root / '.next' / 'static'
public_src = root / 'public'
if not standalone_dir.is_dir():
raise FileNotFoundError('.next/standalone 不存在,请先执行 pnpm build')
# Next 要求将 .next/static 和 public 复制进 standalone
standalone_next = standalone_dir / '.next'
standalone_next.mkdir(parents=True, exist_ok=True)
if static_src.is_dir():
dest_static = standalone_next / 'static'
if dest_static.exists():
shutil.rmtree(dest_static)
shutil.copytree(static_src, dest_static)
if public_src.is_dir():
dest_public = standalone_dir / 'public'
if dest_public.exists():
shutil.rmtree(dest_public)
shutil.copytree(public_src, dest_public)
# 复制 PM2 配置到 standalone便于服务器上用 pm2 start ecosystem.config.cjs
ecosystem_src = root / 'ecosystem.config.cjs'
if ecosystem_src.is_file():
shutil.copy2(ecosystem_src, standalone_dir / 'ecosystem.config.cjs')
# 打包 standalone 目录「内容」,使解压到服务器项目目录后根目录即为 server.js
tmp = tempfile.NamedTemporaryFile(suffix='.tar.gz', delete=False)
tmp.close()
with tarfile.open(tmp.name, 'w:gz') as tar:
for item in standalone_dir.iterdir():
arcname = item.name
tar.add(str(item), arcname=arcname, recursive=True)
return tmp.name
def deploy_by_upload_standalone(ssh, sftp, local_root, remote_path, pm2_name, step_start, app_port=None):
"""本地 standalone 构建 -> 打包 -> 上传 -> 解压 -> PM2 用 node server.js 启动PORT 与 Nginx 一致)"""
step = step_start
root = Path(local_root).resolve()
# 步骤 1: 本地构建
log('本地执行 pnpm buildstandalone', step)
step += 1
if not run_local_build(str(root), step_label=None):
return False
sys.stdout.flush()
# 步骤 2: 打包 standalone
log('打包 .next/standalone含 static、public', step)
step += 1
try:
tarball = make_standalone_tarball(str(root))
size_mb = os.path.getsize(tarball) / 1024 / 1024
log('打包完成,约 %.2f MB' % size_mb)
except FileNotFoundError as e:
log_err(str(e))
return False
except Exception as e:
log_err('打包失败: %s' % e)
return False
sys.stdout.flush()
# 步骤 3: 上传
log('上传到服务器 /tmp/soul_standalone.tar.gz', step)
step += 1
remote_tar = '/tmp/soul_standalone.tar.gz'
try:
sftp.put(tarball, remote_tar)
log('上传完成')
except Exception as e:
log_err('上传失败: %s' % e)
os.unlink(tarball)
return False
os.unlink(tarball)
sys.stdout.flush()
# 步骤 4: 清理并解压(保留 .env 等隐藏配置)
log('清理旧文件并解压 standalone', step)
step += 1
run(ssh, 'cd %s && rm -rf app components lib public styles .next *.json *.js *.ts *.mjs *.css *.d.ts server.js node_modules 2>/dev/null; ls -la' % remote_path, '清理', step_label=None, ignore_err=True)
if not run(ssh, 'cd %s && tar -xzf %s' % (remote_path, remote_tar), '解压'):
log_err('解压失败,请检查服务器磁盘或路径')
return False
run(ssh, 'rm -f %s' % remote_tar, '删除临时包', ignore_err=True)
sys.stdout.flush()
# 步骤 5: PM2 用 node server.js 启动PORT 须与 Nginx proxy_pass 一致(默认 3006
# 宝塔服务器上 pm2 可能不在默认 PATH先注入常见路径
port = app_port if app_port is not None else 3006
log('PM2 启动 node server.jsPORT=%s' % port, step)
pm2_cmd = (
'export PATH=/www/server/nodejs/v22.14.0/bin:/www/server/nvm/versions/node/*/bin:$PATH 2>/dev/null; '
'cd %s && (pm2 delete %s 2>/dev/null; PORT=%s pm2 start server.js --name %s)'
) % (remote_path, pm2_name, port, pm2_name)
run(ssh, pm2_cmd, 'PM2 启动', ignore_err=True)
return True
def main():
print('=' * 60)
print(' Soul 创业派对 - 宝塔一键部署')
print('=' * 60)
print(' %s@%s -> %s' % (CFG['user'], CFG['host'], CFG['path']))
print('=' * 60)
sys.stdout.flush()
# 步骤 1: 连接
log('连接服务器 %s:%s' % (CFG['host'], CFG['port']), '1/6')
password = CFG.get('pwd')
if not CFG['key'] and not password:
password = getpass.getpass('请输入 SSH 密码: ')
sys.stdout.flush()
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
kw = {'hostname': CFG['host'], 'port': CFG['port'], 'username': CFG['user']}
if CFG['key']:
kw['key_filename'] = CFG['key']
else:
kw['password'] = password
ssh.connect(**kw)
log('连接成功')
except Exception as e:
log_err('连接失败: %s' % e)
return 1
sys.stdout.flush()
p, pm = CFG['path'], CFG['pm2']
sftp = ssh.open_sftp()
# 步骤 2~6: 本地 build -> 打包 -> 上传 -> 解压 -> PM2 启动
log('本地打包上传部署(不从 git 拉取)', '2/6')
local_root = Path(__file__).resolve().parent.parent
if not deploy_by_upload_standalone(ssh, sftp, str(local_root), p, pm, step_start=2, app_port=CFG.get('app_port')):
sftp.close()
ssh.close()
log_err('部署中断,请根据上方错误信息排查')
return 1
sftp.close()
ssh.close()
print('')
print('=' * 60)
print(' 部署完成')
print(' 前台: %s' % CFG['url'])
print(' 后台: %s/admin' % CFG['url'])
print('=' * 60)
sys.stdout.flush()
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,12 @@
{
"server_host": "42.194.232.22",
"server_port": 22,
"server_user": "root",
"project_path": "/www/wwwroot/soul",
"branch": "soul-content",
"pm2_app_name": "soul",
"site_url": "https://soul.quwanzhi.com",
"ssh_key_path": null,
"use_pnpm": true,
"_comment": "复制本文件为 deploy_config.json填写真实信息。不要将 deploy_config.json 提交到 Git。ssh_key_path 填私钥路径则用密钥登录,否则用密码。"
}

48
scripts/fix_souladmin_login.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
# 修复 souladmin.quwanzhi.com 登录 "Failed to fetch" 错误
# 1. Vue 管理后台 API 改为同源O1=""),请求 /api
# 2. souladmin Nginx 代理 /api 到 souldev.quwanzhi.com
set -e
cd "$(dirname "$0")/.."
SSH_PORT="22022"
BT_HOST="43.139.27.93"
ADMIN_DIST="/www/wwwroot/自营/soul-admin/dist"
echo "===== 1. 上传 patched index-CbOmKBRd.js ====="
sshpass -p 'Zhiqun1984' scp -P "$SSH_PORT" -o ConnectTimeout=15 \
"soul-admin/dist/assets/index-CbOmKBRd.js" \
root@${BT_HOST}:${ADMIN_DIST}/assets/
echo "===== 2. 配置 souladmin Nginx /api 代理 ====="
sshpass -p 'Zhiqun1984' ssh -p "$SSH_PORT" -o ConnectTimeout=20 root@${BT_HOST} 'bash -s' << 'REMOTE'
EXT_DIR="/www/server/panel/vhost/nginx/extension/souladmin.quwanzhi.com"
mkdir -p "$EXT_DIR"
API_CONF="$EXT_DIR/api-proxy.conf"
cat > "$API_CONF" << 'NGX'
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
add_header Access-Control-Allow-Credentials "true";
add_header Content-Length 0;
return 204;
}
proxy_pass https://souldev.quwanzhi.com/api/;
proxy_ssl_server_name on;
proxy_http_version 1.1;
proxy_set_header Host souldev.quwanzhi.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
NGX
echo "api-proxy.conf 已写入"
nginx -t 2>&1 && nginx -s reload 2>&1
echo "Nginx 重载完成"
REMOTE
echo ""
echo "===== souladmin 登录修复完成 ====="
echo "请访问 https://souladmin.quwanzhi.com 尝试登录"

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
Soul 文章海报发飞书:生成海报图片(含小程序码)并发送,不发链接
流程:解析文章 → 调 Soul API 获取小程序码 → 合成海报图 → 上传飞书 → 以图片形式发送
用法:
python3 scripts/send_poster_to_feishu.py <文章md路径>
python3 scripts/send_poster_to_feishu.py --id 9.15
环境: pip install Pillow requests
飞书图片上传需配置: FEISHU_APP_ID, FEISHU_APP_SECRET与 webhook 同租户的飞书应用)
"""
import argparse
import base64
import io
import json
import os
from pathlib import Path
# 可选:从脚本同目录 .env.feishu 加载 FEISHU_APP_ID, FEISHU_APP_SECRET
_env = Path(__file__).resolve().parent / ".env.feishu"
if _env.exists():
for line in _env.read_text().strip().split("\n"):
if "=" in line and not line.strip().startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
import os
import re
import sys
import tempfile
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/8b7f996e-2892-4075-989f-aa5593ea4fbc"
SOUL_API = "https://soul.quwanzhi.com/api/miniprogram/qrcode"
POSTER_W, POSTER_H = 600, 900 # 2x 小程序 300x450
def parse_article(filepath: Path) -> dict:
"""从 md 解析标题、金句、日期、section id"""
text = filepath.read_text(encoding="utf-8")
lines = [l.strip() for l in text.split("\n")]
title = ""
date_line = ""
quote = ""
section_id = ""
for line in lines:
if line.startswith("# "):
title = line.lstrip("# ").strip()
m = re.match(r"^(\d+\.\d+)\s", title)
if m:
section_id = m.group(1)
elif re.match(r"^\d{4}\d{1,2}月\d{1,2}日", line):
date_line = line
elif title and not quote and line and not line.startswith("-") and "---" not in line:
if 10 <= len(line) <= 120:
quote = line
if not section_id and filepath.stem:
m = re.match(r"^(\d+\.\d+)", filepath.stem)
if m:
section_id = m.group(1)
return {
"title": title or filepath.stem,
"quote": quote or title or "来自 Soul 创业派对的真实故事",
"date": date_line,
"section_id": section_id or "9.1",
}
def fetch_qrcode(section_id: str) -> bytes | None:
"""从 Soul 后端获取小程序码图片"""
scene = f"id={section_id}"
body = json.dumps({"scene": scene, "page": "pages/read/read", "width": 280}).encode()
req = Request(SOUL_API, data=body, headers={"Content-Type": "application/json"}, method="POST")
try:
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
if not data.get("success") or not data.get("image"):
return None
b64 = data["image"].split(",", 1)[-1] if "," in data["image"] else data["image"]
return base64.b64decode(b64)
except Exception as e:
print("获取小程序码失败:", e)
return None
def draw_poster(data: dict, qr_bytes: bytes | None) -> bytes:
"""合成海报图,返回 PNG bytes"""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("需要安装 Pillow: pip install Pillow")
sys.exit(1)
img = Image.new("RGB", (POSTER_W, POSTER_H), color=(26, 26, 46))
draw = ImageDraw.Draw(img)
# 顶部装饰条
draw.rectangle([0, 0, POSTER_W, 8], fill=(0, 206, 209))
# 字体(系统 fallback
try:
font_sm = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
font_md = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 32)
font_lg = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 40)
except OSError:
font_sm = font_md = font_lg = ImageFont.load_default()
y = 50
draw.text((40, y), "Soul创业派对", fill=(255, 255, 255), font=font_sm)
y += 60
# 标题(多行)
title = data["title"]
for i in range(0, len(title), 18):
draw.text((40, y), title[i : i + 18], fill=(255, 255, 255), font=font_lg)
y += 50
y += 20
draw.line([(40, y), (POSTER_W - 40, y)], fill=(255, 255, 255, 40), width=1)
y += 40
# 金句
quote = data["quote"]
for i in range(0, min(len(quote), 80), 20):
draw.text((40, y), quote[i : i + 20], fill=(255, 255, 255, 200), font=font_md)
y += 40
if data["date"]:
draw.text((40, y), data["date"], fill=(255, 255, 255, 180), font=font_sm)
y += 40
y += 20
# 底部提示 + 小程序码
draw.text((40, POSTER_H - 120), "长按识别小程序码", fill=(255, 255, 255), font=font_sm)
draw.text((40, POSTER_H - 90), "阅读全文", fill=(255, 255, 255, 180), font=font_sm)
if qr_bytes:
qr_img = Image.open(io.BytesIO(qr_bytes))
qr_img = qr_img.resize((160, 160))
img.paste(qr_img, (POSTER_W - 200, POSTER_H - 180))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def get_feishu_token() -> str | None:
app_id = os.environ.get("FEISHU_APP_ID")
app_secret = os.environ.get("FEISHU_APP_SECRET")
if not app_id or not app_secret:
return None
req = Request(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
return data.get("tenant_access_token")
except Exception:
return None
def upload_image_to_feishu(png_bytes: bytes, token: str) -> str | None:
"""上传图片到飞书,返回 image_key"""
try:
import requests
except ImportError:
print("需要安装: pip install requests")
return None
headers = {"Authorization": f"Bearer {token}"}
files = {"image": ("poster.png", png_bytes, "image/png")}
data = {"image_type": "message"}
try:
r = requests.post(
"https://open.feishu.cn/open-apis/im/v1/images",
headers=headers,
data=data,
files=files,
timeout=15,
)
j = r.json()
if j.get("code") == 0 and j.get("data", {}).get("image_key"):
return j["data"]["image_key"]
except Exception as e:
print("上传飞书失败:", e)
return None
def send_image_to_feishu(image_key: str) -> bool:
payload = {"msg_type": "image", "content": {"image_key": image_key}}
req = Request(
FEISHU_WEBHOOK,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(req, timeout=10) as resp:
r = json.loads(resp.read().decode())
return r.get("code") == 0
except Exception as e:
print("发送失败:", e)
return False
def main():
parser = argparse.ArgumentParser()
parser.add_argument("file", nargs="?", help="文章 md 路径")
parser.add_argument("--id", help="章节 id")
parser.add_argument("--save", help="仅保存海报到本地路径,不发飞书")
args = parser.parse_args()
base = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例")
if args.id:
candidates = list(base.glob(f"{args.id}*.md"))
if not candidates:
print(f"未找到 id={args.id} 的文章")
sys.exit(1)
filepath = candidates[0]
elif args.file:
filepath = Path(args.file)
if not filepath.exists():
print("文件不存在:", filepath)
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
data = parse_article(filepath)
print("生成海报:", data["title"])
qr_bytes = fetch_qrcode(data["section_id"])
png_bytes = draw_poster(data, qr_bytes)
if args.save:
Path(args.save).write_bytes(png_bytes)
print("已保存:", args.save)
return
token = get_feishu_token()
if not token:
out = Path(tempfile.gettempdir()) / f"soul_poster_{data['section_id']}.png"
out.write_bytes(png_bytes)
print("未配置 FEISHU_APP_ID / FEISHU_APP_SECRET无法上传。海报已保存到:", out)
sys.exit(1)
image_key = upload_image_to_feishu(png_bytes, token)
if not image_key:
print("上传图片失败")
sys.exit(1)
if send_image_to_feishu(image_key):
print("已发送到飞书(图片)")
else:
sys.exit(1)
if __name__ == "__main__":
main()

42
scripts/upload_soul_article.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Soul 第9章文章上传脚本写好文章后直接上传id 已存在则更新(不重复)
# 用法: ./scripts/upload_soul_article.sh <文章md文件路径>
# 例: ./scripts/upload_soul_article.sh "/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例/9.18 第105场创业社群、直播带货与程序员.md"
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FILE="${1:?请提供文章 md 文件路径}"
if [[ ! -f "$FILE" ]]; then
echo "错误: 文件不存在: $FILE"
exit 1
fi
# 从文件名提取 id如 9.18 第105场xxx.md -> 9.18
BASENAME=$(basename "$FILE" .md)
ID=$(echo "$BASENAME" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/')
if [[ ! "$ID" =~ ^[0-9]+\.[0-9]+$ ]]; then
echo "错误: 无法从文件名提取 id格式应为: 9.xx 第X场标题.md"
exit 1
fi
# 从第一行 # 9.xx 第X场标题 提取 title
TITLE=$(head -1 "$FILE" | sed 's/^# [[:space:]]*//')
if [[ -z "$TITLE" ]]; then
TITLE="$BASENAME"
fi
echo "上传: id=$ID title=$TITLE"
python3 "$ROOT/content_upload.py" \
--id "$ID" \
--title "$TITLE" \
--content-file "$FILE" \
--part part-4 \
--chapter chapter-9 \
--price 1.0
# 上传成功后,按海报格式发到飞书群
if [[ $? -eq 0 ]]; then
echo "发海报到飞书..."
python3 "$ROOT/scripts/send_poster_to_feishu.py" "$FILE" 2>/dev/null || true
fi

454
soul-admin/dist/assets/index-CbOmKBRd.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

437
soul-admin/dist/index.html vendored Normal file
View File

@@ -0,0 +1,437 @@
<!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-CbOmKBRd.js?v=5"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DBQ1UORI.css">
</head>
<body>
<div id="root"></div>
<script>
(function(){
var CSS=document.createElement('style');
CSS.textContent=`
.si-row-actions{display:inline-flex;align-items:center;gap:4px}
.si-row-actions .si-del{opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s}
.si-row-actions:hover .si-del{opacity:1;visibility:visible}
.si-del{padding:2px 8px;font-size:11px;border-radius:4px;cursor:pointer;background:transparent;
border:1px solid #7f1d1d;color:#ef4444;margin-left:6px;transition:all .15s}
.si-del:hover{background:#7f1d1d;color:#fff}
.si-plus{padding:2px 6px;font-size:12px;border-radius:4px;cursor:pointer;background:transparent;
border:1px solid #2dd4a8;color:#2dd4a8;margin-left:4px;transition:all .15s}
.si-plus:hover{background:#2dd4a8;color:#0a0e17}
.si-free-toggle{padding:2px 8px;font-size:11px;border-radius:4px;cursor:pointer;margin-left:6px;
border:1px solid #475569;color:#94a3b8;transition:all .15s;user-select:none}
.si-free-toggle:hover{border-color:#2dd4a8;color:#2dd4a8}
.si-free-toggle.paid{border-color:#f59e0b;color:#f59e0b}
.si-drag-handle{cursor:grab;opacity:.5;padding:2px 6px;margin-right:4px;user-select:none}
.si-drag-handle:active{cursor:grabbing}
.si-dragging{opacity:.5;background:rgba(45,212,168,.1)}
.si-drop-target{border:2px dashed #2dd4a8;border-radius:4px}
.si-panel{background:#111827;border:1px solid #1e293b;border-radius:10px;padding:20px;margin:16px 0}
.si-panel h3{font-size:15px;margin:0 0 14px;color:#e0e6ed}
.si-panel label{display:block;font-size:12px;color:#94a3b8;margin:10px 0 4px}
.si-panel input,.si-panel select,.si-panel textarea{width:100%;padding:8px 10px;box-sizing:border-box;
background:#0a0e17;border:1px solid #1e293b;border-radius:6px;color:#e0e6ed;font-size:13px;outline:none}
.si-panel input:focus,.si-panel textarea:focus{border-color:#2dd4a8}
.si-panel textarea{min-height:160px;font-family:monospace;resize:vertical}
.si-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.si-submit{width:100%;padding:10px;margin-top:14px;background:#2dd4a8;color:#0a0e17;
border:none;border-radius:6px;font-size:14px;font-weight:600;cursor:pointer}
.si-submit:hover{background:#22b896}
.si-api{font-family:monospace;font-size:12px;line-height:1.7;color:#94a3b8}
.si-api pre{background:#0a0e17;border:1px solid #1e293b;border-radius:6px;padding:12px;
overflow-x:auto;margin:6px 0 14px;font-size:12px;color:#2dd4a8;white-space:pre-wrap}
.si-api h4{color:#e0e6ed;font-size:13px;margin:16px 0 4px;font-family:sans-serif}
.si-token-box{background:#0a0e17;border:1px solid #2dd4a8;border-radius:8px;padding:14px;margin-bottom:20px}
.si-token-box .si-token-row{display:flex;gap:8px;align-items:center;margin-top:8px}
.si-token-box input{flex:1;padding:8px 10px;background:#111827;border:1px solid #1e293b;border-radius:6px;color:#2dd4a8;font-size:12px;font-family:monospace}
.si-token-btn{padding:8px 16px;border-radius:6px;font-size:13px;cursor:pointer;border:none;background:#2dd4a8;color:#0a0e17;font-weight:600}
.si-token-btn:hover{background:#22b896}
.si-token-btn.copy{background:#1e293b;color:#e0e6ed}
.si-token-btn.copy:hover{background:#334155}
.si-toast{position:fixed;top:16px;right:16px;padding:10px 18px;border-radius:6px;
font-size:13px;z-index:99999;animation:siFade .25s}
.si-toast.ok{background:#065f46;color:#6ee7b7}
.si-toast.err{background:#7f1d1d;color:#fca5a5}
@keyframes siFade{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
`;
document.head.appendChild(CSS);
var API=(window.location.hostname||'').indexOf('souladmin')>=0?'':'https://souldev.quwanzhi.com';
var token=localStorage.getItem('admin_token')||'';
function toast(m,ok){var t=document.createElement('div');t.className='si-toast '+(ok!==false?'ok':'err');
t.textContent=m;document.body.appendChild(t);setTimeout(function(){t.remove()},3000)}
function apicall(method,path,body){
var opts={method:method,headers:{'Content-Type':'application/json'}};
if(token)opts.headers['Authorization']='Bearer '+token;
if(body)opts.body=JSON.stringify(body);
return fetch(API+path,opts).then(function(r){return r.json()}).catch(function(e){return{success:false,error:e.message}})
}
function auth(){
if(token)return apicall('GET','/api/admin').then(function(r){if(r.success)return true;return doLogin()});
return doLogin()
}
function doLogin(){
return apicall('POST','/api/admin',{username:'admin',password:'admin123'}).then(function(r){
if(r.success&&r.token){token=r.token;localStorage.setItem('admin_token',token);return true}
return false
})
}
function findBtn(text){
var all=document.querySelectorAll('button');
for(var i=0;i<all.length;i++){if(all[i].textContent.trim()===text)return all[i]}
return null
}
var done=false;
function hideRedundantButtons(){
['初始化数据库','同步到数据库','导入','导出','同步飞书','上传内容'].forEach(function(t){
var b=findBtn(t);if(b)b.style.display='none';
});
}
function run(){
if(done)return;
if(!location.pathname.includes('content')&&!location.hash.includes('content'))return;
var initBtn=findBtn('初始化数据库');
if(!initBtn)return;
done=true;
// === 1. 移除5个按钮+上传内容,只保留一个"API 接口"(持续执行防重复页)===
hideRedundantButtons();
setInterval(hideRedundantButtons,800);
var btnParent=initBtn&&initBtn.parentElement;
if(btnParent&&!btnParent.querySelector('.si-api-only-btn')){
var apiBtn=document.createElement('button');
apiBtn.className='si-api-only-btn '+initBtn.className;apiBtn.style.display='inline-flex';
apiBtn.textContent='API 接口';
apiBtn.onclick=function(e){e.preventDefault();e.stopPropagation();togglePanel('api')};
btnParent.appendChild(apiBtn);
}
// === 2. 创建面板(插入到 tabs 之前) ===
var tabBar=document.querySelector('[role="tablist"]');
if(!tabBar){
var tabs=findBtn('章节管理');
if(tabs)tabBar=tabs.parentElement;
}
var insertTarget=tabBar||(initBtn&&initBtn.parentElement);
// 上传面板
var upP=document.createElement('div');
upP.className='si-panel';upP.id='si-upload';upP.style.display='none';
upP.innerHTML='<h3>上传新章节</h3>'
+'<div class="si-row"><div><label>章节ID (留空自动)</label><input id="si-uid" placeholder="如 1.6"></div>'
+'<div><label>定价 (0=免费)</label><input type="number" id="si-uprice" value="1" step="0.1" min="0"></div></div>'
+'<label>标题 *</label><input id="si-utitle" placeholder="章节标题">'
+'<div class="si-row"><div><label>所属篇</label><select id="si-upart">'
+'<option value="part-1">第一篇|真实的人</option><option value="part-2">第二篇|真实的行业</option>'
+'<option value="part-3">第三篇|真实的错误</option><option value="part-4">第四篇|真实的赚钱</option>'
+'<option value="part-5">第五篇|真实的社会</option><option value="appendix">附录</option>'
+'<option value="intro">序言</option><option value="outro">尾声</option></select></div>'
+'<div><label>所属章</label><select id="si-uchap">'
+'<option value="chapter-1">第1章</option><option value="chapter-2">第2章</option>'
+'<option value="chapter-3">第3章</option><option value="chapter-4">第4章</option>'
+'<option value="chapter-5">第5章</option><option value="chapter-6">第6章</option>'
+'<option value="chapter-7">第7章</option><option value="chapter-8">第8章</option>'
+'<option value="chapter-9">第9章</option><option value="chapter-10">第10章</option>'
+'<option value="chapter-11">第11章</option><option value="appendix">附录</option>'
+'<option value="preface">序言</option><option value="epilogue">尾声</option></select></div></div>'
+'<label>内容 (Markdown) *</label><textarea id="si-ucontent" placeholder="正文内容... 图片占位用 {{image_1}}"></textarea>'
+'<label>图片URL (每行一个)</label><textarea id="si-uimgs" style="min-height:60px" placeholder="https://example.com/1.png"></textarea>'
+'<button class="si-submit" id="si-submit-btn">上传章节</button>';
insertTarget.parentElement.insertBefore(upP,insertTarget);
document.getElementById('si-submit-btn').onclick=function(){siUpload()};
// API文档面板
var apiP=document.createElement('div');
apiP.className='si-panel';apiP.id='si-apidoc';apiP.style.display='none';
apiP.innerHTML='<div class="si-api">'
+'<h3 style="font-family:sans-serif">内容管理 API 接口文档</h3>'
+'<div class="si-token-box"><strong style="color:#e0e6ed">生成 TOKEN</strong> — 用于上传新章节、删除等操作<br>'
+'<div class="si-token-row"><button class="si-token-btn" id="si-gen-token">生成 TOKEN</button>'
+'<input type="text" id="si-token-input" readonly placeholder="点击生成后显示,可复制用于 curl/Skill 上传" style="cursor:pointer">'
+'<button class="si-token-btn copy" id="si-copy-token">复制</button></div></div>'
+'<p>基础域名: <code>https://soulapi.quwanzhi.com</code> (正式) / <code>https://souldev.quwanzhi.com</code> (开发)</p>'
+'<h4>1. 获取所有章节 (无需认证)</h4><pre>GET /api/book/all-chapters\n\ncurl https://soulapi.quwanzhi.com/api/book/all-chapters</pre>'
+'<h4>2. 获取单章内容</h4><pre>GET /api/book/chapter/:id\n\ncurl https://soulapi.quwanzhi.com/api/book/chapter/1.1</pre>'
+'<h4>3. 管理员登录 (获取Token)</h4><pre>POST /api/admin\nBody: {"username":"admin","password":"admin123"}\n\ncurl -X POST https://souldev.quwanzhi.com/api/admin \\\n -H "Content-Type: application/json" \\\n -d \'{"username":"admin","password":"admin123"}\'</pre>'
+'<h4>4. 创建/更新章节 (需Token)</h4><pre>POST /api/db/book\nAuthorization: Bearer {token}\nBody: {\n "id": "1.6",\n "title": "标题",\n "content": "Markdown正文",\n "price": 1.0,\n "partId": "part-1",\n "chapterId": "chapter-1"\n}\n\ncurl -X POST https://souldev.quwanzhi.com/api/db/book \\\n -H "Authorization: Bearer TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"id":"1.6","title":"新章节","content":"正文","price":1.0,"partId":"part-1","chapterId":"chapter-1"}\'</pre>'
+'<h4>5. 删除章节 (需Token)</h4><pre>DELETE /api/admin/content/:id\n\ncurl -X DELETE https://souldev.quwanzhi.com/api/admin/content/1.6 \\\n -H "Authorization: Bearer TOKEN"</pre>'
+'<h4>6. 命令行上传 (数据库直写)</h4><pre>python3 content_upload.py --title "标题" --price 1.0 --content "正文" \\\n --part part-1 --chapter chapter-1\n\npython3 content_upload.py --list-structure # 查看篇章结构\npython3 content_upload.py --list-chapters # 列出所有章节</pre>'
+'<h4>7. 数据库直连</h4><pre>Host: 56b4c23f6853c.gz.cdb.myqcloud.com:14413\nUser: cdb_outerroot\nDB: soul_miniprogram\n表: chapters</pre>'
+'</div>';
insertTarget.parentElement.insertBefore(apiP,insertTarget);
document.getElementById('si-gen-token').onclick=function(){
var inp=document.getElementById('si-token-input');
inp.value='获取中...';
doLogin().then(function(ok){
if(ok&&token){inp.value=token;toast('TOKEN 已生成,可复制使用')}
else{inp.value='';toast('获取失败',false)}
});
};
document.getElementById('si-copy-token').onclick=function(){
var inp=document.getElementById('si-token-input');
if(!inp.value||inp.value==='获取中...'){toast('请先生成 TOKEN',false);return}
inp.select();document.execCommand('copy');
toast('已复制到剪贴板');
};
document.getElementById('si-token-input').onclick=function(){this.select()};
// === 3. 内容操作:删除(hover)、免费/付费、加号在章节、拖拽 ===
addContentActions();
addChapterPlus();
addDragDrop();
new MutationObserver(function(){addContentActions();addChapterPlus();addDragDrop();}).observe(document.getElementById('root'),{childList:true,subtree:true});
}
var activePanel='';
var siPrefill={};
function togglePanel(name,prefill){
var up=document.getElementById('si-upload');
var ap=document.getElementById('si-apidoc');
if(!up||!ap)return;
if(prefill)siPrefill=prefill;
if(activePanel===name&&name!=='upload'){ap.style.display='none';activePanel='';return}
if(name==='upload'){up.style.display='block';ap.style.display='none';applyPrefill();activePanel='upload';return}
if(name==='api'){up.style.display='none';ap.style.display='block';activePanel='api';return}
}
function applyPrefill(){
if(siPrefill.partId){var s=document.getElementById('si-upart');if(s)s.value=siPrefill.partId}
if(siPrefill.chapterId){var c=document.getElementById('si-uchap');if(c)c.value=siPrefill.chapterId}
}
function getSectionInfo(row){
var p=row;
for(var i=0;i<8&&p;i++){p=p.parentElement;if(!p)break;
var t=(p.textContent||'').substring(0,80);
if(/附录/.test(t))return{partId:'appendix',chapterId:'appendix'};
if(/序言/.test(t))return{partId:'intro',chapterId:'preface'};
if(/尾声/.test(t))return{partId:'outro',chapterId:'epilogue'};
if(/第一篇/.test(t))return{partId:'part-1',chapterId:'chapter-1'};
if(/第二篇/.test(t))return{partId:'part-2',chapterId:'chapter-3'};
if(/第三篇/.test(t))return{partId:'part-3',chapterId:'chapter-6'};
if(/第四篇/.test(t))return{partId:'part-4',chapterId:'chapter-8'};
if(/第五篇/.test(t))return{partId:'part-5',chapterId:'chapter-10'};
}
return null;
}
function addContentActions(){
var all=document.querySelectorAll('button');
for(var i=0;i<all.length;i++){
var b=all[i];
if(b.textContent.trim()==='编辑'&&!b.dataset.sid){
b.dataset.sid='1';
var par=b.parentElement;
if(!par.classList.contains('si-row-actions'))par.classList.add('si-row-actions');
var plusInSection=par.querySelector('.si-plus');
if(plusInSection)plusInSection.remove();
var del=document.createElement('button');
del.className='si-del';
del.textContent='删除';
(function(editBtn){
del.onclick=function(e){
e.stopPropagation();e.preventDefault();
var row=editBtn.closest('[class]');
var txt=row?row.textContent:'';
var m=txt.match(/([\d]+\.[\d]+|appendix-[\w]+|preface|epilogue)/);
var sid=m?m[0]:'';
var name=txt.substring(0,40).replace(/读取|编辑|删除|免费|付费|¥[\d.]+|\+/g,'').trim();
if(!confirm('确定删除「'+name+'」'+(sid?' (ID:'+sid+')':'')+' '))return;
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
apicall('DELETE','/api/admin/content/'+(sid||name)).then(function(r){
if(r.success!==false){toast('已删除');setTimeout(function(){location.reload()},800)}
else{
apicall('DELETE','/api/db/book?action=delete&id='+(sid||name)).then(function(r2){
if(r2.success!==false){toast('已删除');setTimeout(function(){location.reload()},800)}
else toast('删除失败: '+(r2.error||r.error||''),false)
})
}
})
})
}
})(b);
par.appendChild(del);
addFreeToggle(b);
}
}
}
function addChapterPlus(){
var seen=new Set();
var rows=document.querySelectorAll('[class]');
for(var i=0;i<rows.length;i++){
var r=rows[i];
if(r.querySelector('.si-chap-plus')||seen.has(r))continue;
var t=(r.textContent||'').trim();
if((/序言|附录|尾声|第一篇|第二篇|第三篇|第四篇|第五篇/.test(t)&&/\d+节/.test(t))){
seen.add(r);
r.dataset.draggableItem='chapter';
var plus=document.createElement('button');
plus.className='si-plus si-chap-plus';plus.textContent='+';plus.title='在此章节下新建小节';
plus.onclick=function(e){e.stopPropagation();e.preventDefault();
var info=getSectionInfo(this.parentElement);
togglePanel('upload',info||{});
};
r.style.display=r.style.display||'flex';r.style.alignItems='center';
r.appendChild(plus);
}
}
}
function addDragDrop(){
var items=document.querySelectorAll('[data-draggable-item]');
items.forEach(function(el){if(el.dataset.siDrag)return;el.dataset.siDrag='1';
el.draggable=true;el.style.cursor='grab';
el.addEventListener('dragstart',onDragStart);
el.addEventListener('dragover',onDragOver);el.addEventListener('drop',onDrop);
});
var sect=document.querySelectorAll('button');
for(var j=0;j<sect.length;j++){
var sb=sect[j];
if(sb.textContent.trim()==='编辑'){
var row=sb.closest('[class]');
if(row&&!row.dataset.siDrag){
row.draggable=true;row.dataset.siDrag='1';row.dataset.draggableItem='section';
row.style.cursor='grab';
row.addEventListener('dragstart',onDragStart);
row.addEventListener('dragover',onDragOver);
row.addEventListener('drop',onDrop);
}
}
}
}
var dragEl=null;
function onDragStart(e){dragEl=e.currentTarget;e.dataTransfer.effectAllowed='move';
e.dataTransfer.setData('text/plain','');e.currentTarget.classList.add('si-dragging');}
function onDragOver(e){e.preventDefault();e.dataTransfer.dropEffect='move';
var t=e.currentTarget;
if(t!==dragEl){t.classList.add('si-drop-target');
var sibs=t.parentElement?t.parentElement.children:[];
for(var k=0;k<sibs.length;k++){if(sibs[k]!==t)sibs[k].classList.remove('si-drop-target')}
}}
function onDrop(e){e.preventDefault();
document.querySelectorAll('.si-drop-target').forEach(function(x){x.classList.remove('si-drop-target')});
if(!dragEl)return;
dragEl.classList.remove('si-dragging');
var dest=e.currentTarget;
if(dest!==dragEl&&dest.parentNode===dragEl.parentNode){
var par=dest.parentNode;
var list=Array.from(par.children).filter(function(c){return c.dataset.siDrag||c.draggable;});
var i0=list.indexOf(dragEl),i1=list.indexOf(dest);
if(i0>=0&&i1>=0&&i0!==i1){
if(i0<i1)par.insertBefore(dragEl,dest.nextSibling);
else par.insertBefore(dragEl,dest);
var newList=Array.from(par.children).filter(function(c){return c.dataset.siDrag||c.draggable;});
var ids=newList.map(function(x){return(x.textContent.match(/([\d]+\.[\d]+|appendix-[\w-]+|preface|epilogue)/)||[])[1]}).filter(Boolean);
if(ids.length>0)auth().then(function(ok){
if(ok)apicall('POST','/api/db/book/order',{ids:ids}).then(function(r){if(r&&r.success)toast('已排序');else toast('排序已更新(后端接口可后续对接)',false)})
});
}
}
dragEl=null;
}
document.addEventListener('dragend',function(){document.querySelectorAll('.si-dragging,.si-drop-target').forEach(function(x){x.classList.remove('si-dragging','si-drop-target')});dragEl=null});
function addFreeToggle(editBtn){
var row=editBtn.closest('[class]');
if(!row||row.querySelector('.si-free-toggle'))return;
var sid=(row.textContent.match(/([\d]+\.[\d]+|appendix-[\w-]+|preface|epilogue)/)||[])[1]||'';
var candidates=row.querySelectorAll('span, div, [class]');
for(var j=0;j<candidates.length;j++){
var el=candidates[j];
if(el.classList&&el.classList.contains('si-free-toggle'))continue;
var t=(el.textContent||'').trim();
if((t==='免费'||/^¥[\d.]+$/.test(t))&&el.children.length===0){
var isFree=t==='免费';
var toggle=document.createElement('span');
toggle.className='si-free-toggle'+(isFree?'':' paid');
toggle.textContent=isFree?'免费':'付费';
toggle.dataset.sectionId=sid;
toggle.dataset.price=isFree?'0':'1';
toggle.onclick=function(e){e.stopPropagation();e.preventDefault();
if(e.detail>=2)return;
var sectionId=toggle.dataset.sectionId;
if(!sectionId){toast('无法识别章节ID',false);return}
var toFree=toggle.textContent==='付费';
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
var pr=toFree?0:1;
apicall('POST','/api/db/book',{id:sectionId,isFree:toFree,price:pr}).then(function(r){
if(r.success!==false){toggle.textContent=toFree?'免费':'¥'+pr;toggle.classList.toggle('paid',!toFree);toggle.dataset.price=pr;toast('已更新')}
else toast('更新失败: '+(r.error||''),false)
})
})
};
toggle.ondblclick=function(e){e.stopPropagation();e.preventDefault();
var sectionId=toggle.dataset.sectionId;
if(!sectionId){toast('无法识别章节ID',false);return}
if(toggle.textContent==='免费'){
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
var pr=parseFloat(prompt('请输入付费金额','1'))||1;
apicall('POST','/api/db/book',{id:sectionId,isFree:false,price:pr}).then(function(r){
if(r.success!==false){toggle.textContent='¥'+pr;toggle.classList.add('paid');toggle.dataset.price=pr;toast('已更新')}
else toast('更新失败',false)
})
})
}else{
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
apicall('POST','/api/db/book',{id:sectionId,isFree:true,price:0}).then(function(r){
if(r.success!==false){toggle.textContent='免费';toggle.classList.remove('paid');toggle.dataset.price='0';toast('已设为免费')}
else toast('更新失败',false)
})
})
}
};
el.parentNode.replaceChild(toggle,el);
break;
}
}
}
function siUpload(){
var title=document.getElementById('si-utitle').value.trim();
var content=document.getElementById('si-ucontent').value.trim();
if(!title){toast('请填写标题',false);return}
if(!content){toast('请填写内容',false);return}
var imgs=document.getElementById('si-uimgs').value.trim().split('\n').filter(Boolean);
imgs.forEach(function(u,i){content=content.replace('{{image_'+(i+1)+'}}','![图片'+(i+1)+']('+u.trim()+')')});
var price=parseFloat(document.getElementById('si-uprice').value)||0;
var data={
id:document.getElementById('si-uid').value.trim()||undefined,
title:title,content:content,price:price,isFree:price===0,
partId:document.getElementById('si-upart').value,
chapterId:document.getElementById('si-uchap').value
};
toast('上传中...');
auth().then(function(ok){
if(!ok){toast('认证失败',false);return}
apicall('POST','/api/db/book',data).then(function(r){
if(r.success!==false){
toast('上传成功!');
document.getElementById('si-utitle').value='';
document.getElementById('si-ucontent').value='';
document.getElementById('si-uimgs').value='';
document.getElementById('si-uid').value='';
setTimeout(function(){location.reload()},1000)
}else toast('失败: '+(r.error||''),false)
})
})
}
setInterval(run,500);
new MutationObserver(run).observe(document.getElementById('root'),{childList:true,subtree:true});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,11 @@
{
"name": "soul-book-api",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"mysql2": "^3.11.0"
}
}

243
soul-book-api/server.js Normal file
View File

@@ -0,0 +1,243 @@
const http = require('http')
const mysql = require('mysql2/promise')
const PORT = 3007
const TWO_DAYS_MS = 2 * 24 * 60 * 60 * 1000
const pool = mysql.createPool({
host: '56b4c23f6853c.gz.cdb.myqcloud.com',
port: 14413,
user: 'cdb_outerroot',
password: 'Zhiqun1984',
database: 'soul_miniprogram',
charset: 'utf8mb4',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0
})
function isExcluded(id, partTitle) {
const lid = String(id || '').toLowerCase()
if (lid === 'preface' || lid === 'epilogue') return true
if (lid.startsWith('appendix-') || lid.startsWith('appendix_')) return true
const pt = String(partTitle || '')
if (/序言|尾声|附录/.test(pt)) return true
return false
}
function cleanPartTitle(pt) {
return (pt || '真实的行业').replace(/^第[一二三四五六七八九十]+篇[|]?/, '').trim() || '真实的行业'
}
async function getFeaturedSections() {
const tags = [
{ tag: '热门', tagClass: 'tag-pink' },
{ tag: '推荐', tagClass: 'tag-purple' },
{ tag: '精选', tagClass: 'tag-free' }
]
try {
const [rows] = await pool.query(`
SELECT c.id, c.section_title, c.part_title, c.is_free,
COALESCE(t.cnt, 0) as view_count
FROM chapters c
LEFT JOIN (
SELECT chapter_id, COUNT(*) as cnt
FROM user_tracks
WHERE action = 'view_chapter' AND chapter_id IS NOT NULL
GROUP BY chapter_id
) t ON c.id = t.chapter_id
WHERE c.id NOT IN ('preface','epilogue')
AND c.id NOT LIKE 'appendix-%' AND c.id NOT LIKE 'appendix\\_%'
AND c.part_title NOT LIKE '%序言%' AND c.part_title NOT LIKE '%尾声%'
AND c.part_title NOT LIKE '%附录%'
ORDER BY view_count DESC, c.updated_at DESC
LIMIT 6
`)
if (rows && rows.length > 0) {
return rows.slice(0, 3).map((r, i) => ({
id: r.id,
title: r.section_title || '',
part: cleanPartTitle(r.part_title),
tag: tags[i]?.tag || '推荐',
tagClass: tags[i]?.tagClass || 'tag-purple'
}))
}
} catch (e) {
console.error('[featured] query error:', e.message)
}
try {
const [fallback] = await pool.query(`
SELECT id, section_title, part_title, is_free
FROM chapters
WHERE id NOT IN ('preface','epilogue')
AND id NOT LIKE 'appendix-%' AND id NOT LIKE 'appendix\\_%'
AND part_title NOT LIKE '%序言%' AND part_title NOT LIKE '%尾声%'
AND part_title NOT LIKE '%附录%'
ORDER BY updated_at DESC
LIMIT 3
`)
if (fallback?.length > 0) {
return fallback.map((r, i) => ({
id: r.id,
title: r.section_title || '',
part: cleanPartTitle(r.part_title),
tag: tags[i]?.tag || '推荐',
tagClass: tags[i]?.tagClass || 'tag-purple'
}))
}
} catch (_) {}
return [
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', tagClass: 'tag-free', part: '真实的人' },
{ id: '3.1', title: '3000万流水如何跑出来', tag: '热门', tagClass: 'tag-pink', part: '真实的行业' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
]
}
async function handleLatestChapters(res) {
try {
const [rows] = await pool.query(`
SELECT id, part_title, section_title, is_free, price, created_at, updated_at
FROM chapters
ORDER BY sort_order ASC, id ASC
`)
let chapters = (rows || [])
.map(r => ({
id: r.id,
title: r.section_title || '',
part: cleanPartTitle(r.part_title),
isFree: !!r.is_free,
price: r.price || 0,
updatedAt: r.updated_at || r.created_at,
createdAt: r.created_at
}))
.filter(c => !isExcluded(c.id, c.part))
if (chapters.length === 0) {
return sendJSON(res, {
success: true,
banner: { id: '1.1', title: '开始阅读', part: '真实的人' },
label: '为你推荐',
chapters: [],
hasNewUpdates: false
})
}
const sorted = [...chapters].sort((a, b) => {
const ta = a.updatedAt ? new Date(a.updatedAt).getTime() : 0
const tb = b.updatedAt ? new Date(b.updatedAt).getTime() : 0
return tb - ta
})
const mostRecentTime = sorted[0]?.updatedAt ? new Date(sorted[0].updatedAt).getTime() : 0
const hasNewUpdates = Date.now() - mostRecentTime < TWO_DAYS_MS
let banner, label, selected
if (hasNewUpdates) {
selected = sorted.slice(0, 3)
banner = { id: selected[0].id, title: selected[0].title, part: selected[0].part }
label = '最新更新'
} else {
const free = chapters.filter(c => c.isFree || c.price === 0)
const candidates = free.length > 0 ? free : chapters
const shuffled = [...candidates].sort(() => Math.random() - 0.5)
selected = shuffled.slice(0, 3)
banner = { id: selected[0].id, title: selected[0].title, part: selected[0].part }
label = '为你推荐'
}
sendJSON(res, {
success: true,
banner,
label,
chapters: selected.map(c => ({ id: c.id, title: c.title, part: c.part, isFree: c.isFree })),
hasNewUpdates
})
} catch (e) {
console.error('[latest-chapters] error:', e.message)
sendJSON(res, { success: false, error: '获取失败' }, 500)
}
}
async function handleAllChapters(res) {
const featuredSections = await getFeaturedSections()
try {
const [rows] = await pool.query(`
SELECT id, part_id, part_title, chapter_id, chapter_title, section_title,
content, is_free, price, word_count, sort_order, created_at, updated_at
FROM chapters
ORDER BY sort_order ASC, id ASC
`)
if (rows && rows.length > 0) {
const seen = new Set()
const data = rows
.map(r => ({
mid: r.mid || 0,
id: r.id,
partId: r.part_id || '',
partTitle: r.part_title || '',
chapterId: r.chapter_id || '',
chapterTitle: r.chapter_title || '',
sectionTitle: r.section_title || '',
content: r.content || '',
wordCount: r.word_count || 0,
isFree: !!r.is_free,
price: r.price || 0,
sortOrder: r.sort_order || 0,
status: 'published',
createdAt: r.created_at,
updatedAt: r.updated_at
}))
.filter(r => {
if (seen.has(r.id)) return false
seen.add(r.id)
return true
})
return sendJSON(res, {
success: true,
data,
total: data.length,
featuredSections
})
}
} catch (e) {
console.error('[all-chapters] error:', e.message)
}
sendJSON(res, {
success: true,
data: [],
total: 0,
featuredSections
})
}
function sendJSON(res, obj, code = 200) {
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
})
res.end(JSON.stringify(obj))
}
const server = http.createServer(async (req, res) => {
if (req.method === 'OPTIONS') {
return sendJSON(res, {})
}
const url = req.url.split('?')[0]
if (url === '/api/book/latest-chapters') {
return handleLatestChapters(res)
}
if (url === '/api/book/all-chapters') {
return handleAllChapters(res)
}
if (url === '/health') {
return sendJSON(res, { status: 'ok', time: new Date().toISOString() })
}
sendJSON(res, { error: 'not found' }, 404)
})
server.listen(PORT, '127.0.0.1', () => {
console.log(`[soul-book-api] running on port ${PORT}`)
})

View File

@@ -1,64 +0,0 @@
#!/bin/bash
# Soul派对小程序 - 快速启动脚本
# 用于启动后端API服务器
echo "=================================="
echo " Soul派对·创业实验 启动脚本 "
echo "=================================="
echo ""
# 检查Node.js
if ! command -v node &> /dev/null; then
echo "❌ 错误: 未检测到Node.js请先安装Node.js"
exit 1
fi
echo "✅ Node.js版本: $(node -v)"
# 检查pnpm
if ! command -v pnpm &> /dev/null; then
echo "⚠️ 警告: 未检测到pnpm尝试使用npm..."
PACKAGE_MANAGER="npm"
else
echo "✅ pnpm版本: $(pnpm -v)"
PACKAGE_MANAGER="pnpm"
fi
echo ""
echo "1⃣ 检查依赖..."
# 检查是否已安装依赖
if [ ! -d "node_modules" ]; then
echo "📦 正在安装依赖..."
$PACKAGE_MANAGER install
if [ $? -ne 0 ]; then
echo "❌ 依赖安装失败"
exit 1
fi
else
echo "✅ 依赖已安装"
fi
echo ""
echo "2⃣ 启动后端API服务器..."
echo ""
echo "🚀 服务器将运行在: http://localhost:3000"
echo "📡 API接口地址: http://localhost:3000/api"
echo ""
echo "📱 小程序配置步骤:"
echo " 1. 打开微信开发者工具"
echo " 2. 导入项目,选择 miniprogram/ 目录"
echo " 3. 修改 miniprogram/app.js 中的 apiBase 为: http://localhost:3000/api"
echo " 4. 点击编译运行"
echo ""
echo "🔧 后台管理地址: http://localhost:3000/admin"
echo " 默认账号: admin / admin123"
echo ""
echo "=================================="
echo "按 Ctrl+C 停止服务器"
echo "=================================="
echo ""
# 启动开发服务器
$PACKAGE_MANAGER run dev

View File

@@ -1,207 +0,0 @@
# 小程序隐私保护指引填写内容
> **填写日期**: 2026-01-25
> **小程序名称**: Soul创业实验
> **版本**: 1.0.11
---
## 1. 开发者处理的信息
### 1.1 微信昵称、头像
**填写内容**
```
开发者将在获取你的明示同意后,收集你的微信昵称、头像,用途是用于在小程序内展示用户身份信息,提供个性化服务,以及用于匹配功能中展示用户资料,便于用户间的社交互动和创业伙伴匹配。
```
### 1.2 位置信息
**填写内容**
```
开发者将在获取你的明示同意后,收集你的位置信息,用途是用于"找伙伴"功能中匹配附近的书友和创业合作伙伴,提供基于地理位置的服务,提升匹配成功率。
```
### 1.3 照片或视频信息
**是否勾选**:❌ **不勾选**(如果小程序没有上传照片/视频功能)
**说明**:根据代码分析,小程序目前没有照片/视频上传功能,所以不需要勾选此项。
**如果需要添加其他信息类型**
- 手机号:用于用户登录和联系方式展示(如果使用了手机号授权)
- 订单信息:用于记录用户购买记录和订单管理
---
## 2. 第三方插件信息/SDK信息
### 已接入的第三方SDK
#### 2.1 微信支付SDK
- **插件名称**: 微信支付
- **插件提供方名称**: 财付通支付科技有限公司
- **说明**: 用于处理用户购买电子书时的支付功能
#### 2.2 支付宝SDK如果接入了
- **插件名称**: 支付宝
- **插件提供方名称**: 支付宝(中国)网络技术有限公司
- **说明**: 用于处理用户购买电子书时的支付功能
**如何添加**
1. 点击"增加第三方SDK信息"按钮
2. 填写插件名称和提供方名称
3. 如果还有其他SDK如统计分析、分享等也需要添加
---
## 3. 未成年人保护
**说明**:这部分是固定说明,无需填写。系统会自动说明需要监护人同意等内容。
---
## 4. 你的权益
### 4.1-4.4 用户权利说明
**说明**:这部分是固定说明,描述了用户如何管理个人信息。
### 4.5 联系方式选择
**下拉菜单选择**:选择"微信"或"在线客服"(根据你的实际情况)
**联系方式填写**
- **微信**: 28533368
- **电话**: 15880802661
- **邮箱**: zhiqun@qq.com
**如何填写**
1. 在"请选择"下拉菜单中选择一种联系方式类型
2. 填写对应的联系方式
3. 如果需要多种联系方式,点击"增加联系方式"按钮添加
**建议填写**
```
联系方式1: 微信 - 28533368
联系方式2: 电话 - 15880802661
联系方式3: 邮箱 - zhiqun@qq.com
```
---
## 5. 开发者对信息的存储
### 固定存储期限
**填写建议**`30` 天 或 `90`
**说明**
- 如果选择0天表示在完成用途后立即删除
- 建议填写30-90天用于订单记录、用户服务等必要用途
- 根据《个人信息保护法》,存储期限应为实现处理目的所必需的最短时间
**推荐填写**`90`
---
## 6. 信息的使用规则
### 6.1 用途内使用
**说明**:固定说明,无需填写。
### 6.2 改变使用目的时的告知方式
**填写内容**
```
再次以弹窗通知、站内消息的方式告知并征得你的明示同意
```
**其他可选填写**
- "弹窗通知"
- "站内消息"
- "微信消息"
- "邮件通知"
---
## 7. 信息对外提供
**说明**:这部分是固定承诺说明,无需填写。系统会自动说明不会主动共享、转让或公开披露用户信息。
---
## 8. 投诉和建议
**说明**这部分提示用户可以通过联系开发者或向微信投诉。确保在第4部分"你的权益"中已填写完整的联系方式。
---
## 9. 补充文档(可选)
**是否需要上传**:❌ **建议不上传**(除非有特别复杂的隐私政策需要说明)
**如果上传**
- 格式:`.txt` 文件
- 大小不超过100KB
- 内容:详细的隐私政策说明
---
## 📋 完整填写清单
### ✅ 必填项
- [x] 1. 开发者处理的信息(微信昵称头像、位置信息)
- [x] 2. 第三方SDK信息微信支付、支付宝
- [x] 4. 联系方式(至少一种)
- [x] 5. 存储期限建议90天
- [x] 6. 改变使用目的时的告知方式
### ⚪ 可选项
- [ ] 1. 照片/视频信息(如果没有此功能,不勾选)
- [ ] 9. 补充文档(一般不需要)
---
## 🎯 快速填写步骤
1. **填写开发者处理的信息**
- 勾选"微信昵称、头像",填写用途说明
- 勾选"位置信息",填写用途说明
- 不勾选"照片/视频"(如果没有此功能)
2. **填写第三方SDK信息**
- 添加"微信支付"SDK
- 添加"支付宝"SDK如果使用
- 添加其他SDK如果有
3. **填写联系方式**
- 选择"微信"填写28533368
- 添加"电话"填写15880802661
- 添加"邮箱"填写zhiqun@qq.com
4. **设置存储期限**
- 填写:`90`
5. **填写告知方式**
- 填写:`弹窗通知、站内消息的方式告知并征得你的明示同意`
6. **预览并提交**
- 点击"预览后提交协议"
- 仔细检查所有内容
- 确认无误后提交
---
## ⚠️ 注意事项
1. **用途说明要具体**:不要写"用于提供服务"这种模糊表述,要写具体用途
2. **SDK要完整**确保列出所有接入的第三方SDK
3. **联系方式要有效**:确保填写的联系方式可以正常联系到你
4. **存储期限要合理**不要设置过长的存储期限建议30-90天
5. **预览后再提交**:提交前务必预览检查,避免填写错误
---
## 📞 技术支持
如有疑问,请联系:
- 微信28533368
- 电话15880802661
---
**填写完成后,记得点击"预览后提交协议"按钮进行预览和提交!**

View File

@@ -0,0 +1,79 @@
# Soul 管理后台 (soul-admin) 变更记录 v2026-02
> 更新时间2026-02-21
> 适用站点souladmin.quwanzhi.com
> 部署路径:`/www/wwwroot/自营/soul-admin/dist/`
---
## 一、变更概览
| 模块 | 变更项 | 说明 |
|:---|:---|:---|
| 侧边栏 | 交易中心 → 推广中心 | 菜单及页面标题统一改为「推广中心」 |
| 内容管理 | 顶部 5 按钮移除 | 移除:初始化数据库、同步到数据库、导入、导出、同步飞书 |
| 内容管理 | 仅保留 API 接口 | 仅保留「API 接口」按钮,打开 API 文档面板 |
| 内容管理 | 删除按钮 | 删除按钮改为悬停才显示(与读取/编辑一致) |
| 内容管理 | 免费/付费 | 可点击切换免费 ↔ 付费 |
| 内容管理 | 小节加号 | 每小节旁增加「+」按钮,可在此小节下新建章节 |
---
## 二、部署说明
### 2.1 正确部署路径
nginx 实际指向:
```nginx
root /www/wwwroot/自营/soul-admin/dist;
```
**重要**:需将 `soul-admin/dist` 部署到上述目录,而非 `/www/wwwroot/souladmin.quwanzhi.com/`
### 2.2 部署步骤
```bash
# 1. 本地打包
cd /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/soul-admin/dist
tar -czf /tmp/souladmin.tar.gz index.html assets/
# 2. 上传并解压到正确路径
scp -P 22022 /tmp/souladmin.tar.gz root@43.139.27.93:/tmp/
ssh -p 22022 root@43.139.27.93 'cd /www/wwwroot/自营/soul-admin/dist && tar -xzf /tmp/souladmin.tar.gz && chown -R www:www . && rm /tmp/souladmin.tar.gz'
```
### 2.3 缓存处理
- `index.html` 内引用 `index-CbOmKBRd.js?v=版本号`,每次发布建议递增版本号
- 建议在 `index.html` 中调整:`?v=3` 或更高
---
## 三、技术说明
### 3.1 修改文件
- `index.html`:内联注入脚本(按钮改造、删除 hover、免费切换、加号新建
- `assets/index-CbOmKBRd.js`:侧边栏「交易中心」→「推广中心」
### 3.2 注入脚本触发条件
- 路径包含 `content`(如 `/content`
- 页面上存在「初始化数据库」按钮(内容管理页加载完成)
### 3.3 免费/付费切换
- 调用 `POST /api/db/book`,传入 `{ id, isFree, price }`
- 需后端支持按 id 更新 isFree/price
---
## 四、问题排查
| 现象 | 可能原因 | 处理方式 |
|:---|:---|:---|
| 界面未变化 | 部署到错误目录 | 确认部署到 `/www/wwwroot/自营/soul-admin/dist/` |
| 界面未变化 | 浏览器/CDN 缓存 | 清除缓存或使用无痕模式,或增加 `?v=` 版本号 |
| 内容管理注入不生效 | 路由为 hash 模式 | 检查 `location.pathname` 是否包含 `content`,必要时改用 `location.hash` |
| 免费切换失败 | 后端未实现更新 | 检查 soul-api 是否支持 `POST /api/db/book` 的更新逻辑 |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
# 微信小程序接口申请文案(可直接复制)
> 用于微信公众平台 → 开发管理 → 接口设置 → 接口权限
> 每个理由控制在 300 字以内,按需复制到对应接口的「申请接口理由」框。
---
## 1. wx.chooseAddress获取用户收货地址
**申请接口理由:**
```
本小程序为创业者社群与资源对接平台。用户在使用「找伙伴-资源对接」功能时,需填写联系地址,便于匹配成功后线下见面、寄送资料或合作签约。申请 wx.chooseAddress 后,用户可一键从微信获取已保存的收货地址,无需逐项手动输入,既保证信息真实可联系,又提升填写效率,完成从线上匹配到线下对接的闭环。
```
**备选(更简短):**
```
本小程序提供创业资源对接服务,用户匹配成功后需交换联系地址以便线下合作。申请此接口后,用户可一键选择微信收货地址,避免手动输入错误,提升填写效率与用户体验。
```
---
## 2. wx.getPhoneNumber获取用户手机号
**申请接口理由:**
```
本小程序为创业者匹配与电子书付费平台,需手机号用于:一、用户身份校验,确保真实用户;二、创业伙伴匹配成功后交换联系方式;三、分销推广收益提现时的账户校验与到账通知。申请 wx.getPhoneNumber 后,用户授权即可获取微信绑定手机号,减少手动输入,提高注册与提现流程的完成率。
```
**备选(更简短):**
```
本小程序涉及付费阅读与分销提现,需手机号完成身份验证与提现到账。申请此接口可实现一键获取微信绑定手机号,提升用户注册与提现流程的完成率与安全性。
```
---
## 3. wx.chooseLocation打开地图选择位置
**申请接口理由:**
```
本小程序提供创业者线下见面与资源对接服务。用户发布合作需求或预约见面时,需选择具体见面地点。申请 wx.chooseLocation 后,用户可在地图上选点并获取详细地址与坐标,便于双方导航赴约,完成从线上匹配到线下见面的业务闭环。
```
**备选(更简短):**
```
本小程序为创业资源对接平台,用户匹配成功后需约定线下见面地点。申请此接口后,用户可在地图上选择位置并获取地址,方便双方导航见面。
```
---
## 4. wx.choosePoi打开 POI 列表选择位置)
**申请接口理由:**
```
本小程序为创业者线下对接场景服务。用户约定见面地点时,除地图选点外,还需从咖啡馆、会议室等 POI 中选择具体场所。申请 wx.choosePoi 后,用户可从附近 POI 列表中快速选择地点,便于填写规范地址并提升约见效率。
```
---
## 5. wx.getFuzzyLocation获取当前模糊地理位置
**申请接口理由:**
```
本小程序需根据用户所在城市推荐同城创业伙伴与线下活动,不涉及精确定位。申请 wx.getFuzzyLocation 后,仅获取城市级模糊位置用于同城匹配与活动推荐,在满足业务需求的同时符合隐私最小化原则。
```
---
## 6. wx.getLocation获取当前精确地理位置
**申请接口理由:**
```
本小程序提供创业者线下见面与活动报名功能。用户参加线下沙龙、路演等活动时,需获取当前位置用于:一、展示与活动地点的距离;二、推荐附近的创业活动与伙伴。申请此接口以便用户查看「离我最近」的活动与匹配结果,提升线下参与率。
```
**说明:** 若类目为「商业服务-综合」等,审核可能较严,建议优先申请 wx.getFuzzyLocation再视业务需要申请 wx.getLocation。
---
## 填写与提交建议
1. **申请接口理由**:从上面选一段主文案粘贴,字数不够时用「备选」补充,总长不超过 300 字。
2. **使用场景截图**:上传小程序内实际使用该能力的页面截图(如设置页地址、匹配页选地点、提现页手机号等),每张图对应一个场景。
3. **小程序官网链接**:可填 `https://soul.quwanzhi.com`
4. **一次只申请一个接口**,通过后再申请下一个,通过率更高。
---
**文档更新日期:** 2026-01-29

View File

@@ -0,0 +1,91 @@
# 永平版 vs 主项目:优化对比与合并说明
> 对比目录主项目一场soul的创业实验 vs 永平版一场soul的创业实验-永平)
> 更新日期2026-02-20
---
## 一、两套目录结构概览
| 项目 | 根目录特点 | Next 源码位置 |
|------|------------|----------------|
| **主项目** | 单仓Next + book + miniprogram + 开发文档 | 根下 `app/``lib/``components/` |
| **永平版** | 多仓soul-api(Go)、soul-admin(Vue)、soul(Next) | `soul/dist/`(源码与构建同目录) |
永平版还包含:`本机运行文档.md`、Go API(8080)、Vue 管理后台(静态)、开发 API(8081)。主项目为纯 Next 站 + 宝塔 3006 部署。
---
## 二、已合并到主项目的优化(本次迭代)
| 模块 | 优化内容 | 主项目路径 |
|------|----------|------------|
| **数据库** | 环境变量 `MYSQL_*``SKIP_DB`、连接超时与单次连接错误日志 | `lib/db.ts` |
| **数据库** | 订单表 status 增加 `created`/`expired`,字段 `referrer_id`/`referral_code`;用户表 ALTER 兼容 MySQL 5.7 | 同上 |
| **认证** | 密码哈希/校验scrypt兼容旧明文 | `lib/password.ts`(新增) |
| **认证** | Web 端手机号+密码登录 | `app/api/auth/login/route.ts`(新增) |
| **认证** | 重置密码 | `app/api/auth/reset-password/route.ts`(新增) |
| **后台** | 管理员登出(清除 Cookie | `app/api/admin/logout/route.ts`(新增) |
| **前端** | 仅生产环境加载 Vercel Analytics | `app/layout.tsx` |
| **文档** | 本机/服务器运行说明端口、目录、Nginx | `开发文档/本机运行文档.md`(新增) |
---
## 三、永平有、主项目未合并的(可选后续)
| 模块 | 说明 | 永平路径 | 合并建议 |
|------|------|----------|----------|
| 定时任务 | 订单状态同步、过期解绑 | `app/api/cron/sync-orders``cron/unbind-expired` | 若需定时同步/解绑再迁入;需配置 CRON_SECRET |
| 提现扩展 | 待确认列表、提现记录 API | `withdraw/pending-confirm``withdraw/records` | 若后台要做提现工作流与记录查询可迁入 |
| 用户 API | 购买状态、阅读进度、收货地址 CRUD | `user/check-purchased``user/reading-progress``user/addresses` | 按产品需要选择性迁入 |
| 后台 | 分销概览 API、推广设置页 | `admin/distribution/overview``admin/referral-settings/page.tsx` | 若有分销看板/推广配置页可迁入 |
| 前台 | 忘记密码页、我的地址列表/编辑/新增 | `app/view/login/forgot``app/view/my/addresses/*` | 主项目路由为 `app/login/``app/my/`,可对应新增 |
| 构建 | standalone 复制 static/public、clean、write-warning | `scripts/prepare-standalone.js` 等 | 若主项目用 standalone 部署可迁入 |
| 数据层 | Prisma 模型与迁移 | `prisma/schema.prisma`、迁移脚本 | 主项目当前为 mysql2若统一用 Prisma 再迁 |
| 路由结构 | 前台统一在 `app/view/` | 整棵 `app/view/` | 主项目保持扁平 `app/`,非必须 |
---
## 四、主项目保留、与永平不同的部分
- **CORS**:主项目在 `middleware.ts` + `next.config.mjs` 的 headers 中配置 API CORS永平可能用 Nginx/Go未在 Next 层做。
- **路由**:主项目前台为 `app/page.tsx``app/my/``app/read/` 等,无 `view` 前缀。
- **book**:主项目根下保留 `book/` Markdown 与现有内容体系;永平书内容可能来自 API/DB。
---
## 五、环境变量说明(合并后)
主项目 `.env.local` 建议支持(可选):
```bash
# 数据库(不设则用代码内默认值)
MYSQL_HOST=
MYSQL_PORT=
MYSQL_USER=
MYSQL_PASSWORD=
MYSQL_DATABASE=
# 本地无数据库时跳过连接(接口会报错,适合纯前端联调)
SKIP_DB=0
```
---
## 六、合并与实施注意
1. **路径**:永平 Next 源码在 `soul/dist/`,合并到主项目时对应到根下 `app/``lib/``开发文档/`
2. **CORS**:保留主项目现有 `middleware.ts``next.config.mjs` 的 CORS 配置。
3. **数据库**:主项目继续使用 mysql2未引入 Prisma`lib/db.ts` 已支持环境变量与 `SKIP_DB`
4. **admin 登出**:后台可增加「退出登录」按钮,请求 `POST /api/admin/logout` 后跳转登录页。
5. **已有数据库**:若主项目此前已建过 `orders` 表且无 `referrer_id`/`referral_code` 或 status 无 `created`/`expired`,需自行执行迁移,例如:
```sql
ALTER TABLE orders MODIFY COLUMN status ENUM('created','pending','paid','cancelled','refunded','expired') DEFAULT 'created';
ALTER TABLE orders ADD COLUMN referrer_id VARCHAR(50) NULL COMMENT '推荐人用户ID', ADD COLUMN referral_code VARCHAR(20) NULL COMMENT '下单时使用的邀请码';
```
若表为新建,`initDatabase()` 已包含上述结构。
---
**文档状态**:已合并项已落地;未合并项见第三节,按需迭代。

View File

@@ -0,0 +1,61 @@
# Soul 派对每日数据汇总
按「派对小助手」表头整理的日维度数据,便于按天相加。
## 表头说明与第5张图一致
| 时长 | Soul推流人数 | 进房人数 | 人均时长 | 互动数量 | 礼物 | 灵魂力 | 增加关注 | 最高在线 |
|------|--------------|----------|----------|----------|------|--------|----------|----------|
- **时长**:派对总时长(分钟)
- **Soul推流人数**:本场获得额外曝光(次)
- **进房人数**:派对成员/进房总人数(人)
- **人均时长**:人均停留时长(分钟)
- **互动数量**:本场互动次数
- **礼物**:本场收到礼物(个)
- **灵魂力**:收获灵魂力
- **增加关注**:新增粉丝(人)
- **最高在线**:当日各场中最高同时在线人数(人),取最大值、不相加
---
## 当日汇总(一天相加后的数据)
**日期**2026-02-19根据截图当日多场合并
| 时长 | Soul推流人数 | 进房人数 | 人均时长 | 互动数量 | 礼物 | 灵魂力 | 增加关注 | 最高在线 |
|------|--------------|----------|----------|----------|------|--------|----------|----------|
| 155 | 46749 | 545 | 7 | 34 | 1 | 8 | 13 | 47 |
**计算说明:**
- **时长**54 + 22 + 79 = **155** 分钟第3张与第4张为同一场只计一次取结算 79 分钟)
- **Soul推流人数**12588 + 7695 + 26466 = **46749**
- **进房人数**164 + 92 + 289 = **545** 人(同一场取 289不重复加 279
- **人均时长**:仅一场有数据,取 **7** 分钟
- **互动数量**:仅一场有数据,取 **34**
- **礼物**0 + 0 + 1 = **1**
- **灵魂力**0 + 0 + 8 = **8**
- **增加关注**2 + 4 + 7 = **13** 人(同一场只计 7不重复加 5
- **最高在线**:取当日各场最高值 max(34, 30, 47) = **47** 人(不相加)
---
## 当日分场明细(便于核对)
*说明第3张派对小助手浮层与第4张派对已关闭结算为同一场派对只计一场。*
| 场次 | 时长(min) | 曝光/推流 | 进房人数 | 人均时长 | 互动 | 礼物 | 灵魂力 | 新增关注 | 最高在线 |
|------|-----------|-----------|----------|----------|------|------|--------|----------|----------|
| 1 | 54 | 12588 | 164 | — | — | 0 | 0 | 2 | 34 |
| 2 | 22 | 7695 | 92 | — | — | 0 | 0 | 4 | 30 |
| 3图3+图4同场 | 79 | 26466 | 289 | 7 | 34 | 1 | 8 | 7 | 47 |
| **合计** | **155** | **46749** | **545** | 7 | 34 | **1** | **8** | **13** | **47** |
---
**使用方式**:把「当日汇总」那一行加到总表或飞书运营报表。
**导入飞书运营报表时**(脚本 `soul_party_to_feishu_sheet.py`
- 只填前 10 项(主题、时长、推流、进房、人均时长、互动、礼物、灵魂力、增加关注、最高在线),**按数字填写**。
- 推流进房率、1分钟进多少人、加微率 **不填**,由表格公式自动计算。

View File

@@ -1,274 +0,0 @@
# 用户管理与存客宝同步 - 完成报告
> 更新日期: 2026-01-29
> 开发者: 卡若AI
---
## 一、需求完成情况
### ✅ 数据一致性校验
| 需求项 | 状态 | 说明 |
|--------|------|------|
| 用户总数一致性 | ✅ 完成 | 管理后台和数据概览均使用 `/api/db/users` 统一数据源 |
| 各标签维度统计 | ✅ 完成 | 新增用户标签定义表 `user_tag_definitions` |
### ✅ 用户详情页能力
| 需求项 | 状态 | 说明 |
|--------|------|------|
| 基础信息展示 | ✅ 完成 | 手机号、昵称、来源、创建时间、当前状态 |
| 标签体系展示 | ✅ 完成 | 系统标签、行为标签、来源标签、存客宝同步标签 |
| 结构化标签模块 | ✅ 完成 | 标签以Badge形式分类展示支持添加/删除 |
**实现文件**: `components/modules/user/user-detail-modal.tsx`
### ✅ 存客宝数据接入与标签完善
| 需求项 | 状态 | 说明 |
|--------|------|------|
| 存客宝接口 | ✅ 完成 | `/api/ckb/sync` 支持 pull/push/full_sync 操作 |
| 按手机号拉取用户数据 | ✅ 完成 | POST action=pull 参数 |
| 获取存客宝侧标签/行为数据 | ✅ 完成 | 数据存储在 ckb_tags 字段 |
| 标签自动完善机制 | ✅ 完成 | 自动匹配手机号并合并标签 |
| 保留标签来源 | ✅ 完成 | tags(本系统), ckb_tags(存客宝), source_tags(来源) |
**实现文件**: `app/api/ckb/sync/route.ts`
### ✅ 用户轨迹 & 关系链路记录
| 需求项 | 状态 | 说明 |
|--------|------|------|
| 用户关系记录 | ✅ 完成 | referred_by, created_by, matched_by 字段 |
| 来源追溯 | ✅ 完成 | 用户详情页"关系链路"标签页 |
| 用户行为轨迹 | ✅ 完成 | `/api/user/track` API + user_tracks 表 |
| 时间轴呈现 | ✅ 完成 | 用户详情页"行为轨迹"标签页,按时间倒序 |
**实现文件**:
- `app/api/user/track/route.ts`
- `components/modules/user/user-detail-modal.tsx` (行为轨迹Tab)
### ✅ 用户轨迹 → 存客宝(反向同步)
| 需求项 | 状态 | 说明 |
|--------|------|------|
| 行为数据回传接口 | ✅ 完成 | POST action=sync_track |
| 按手机号传输给存客宝 | ✅ 完成 | 支持批量同步 |
| 自动完善用户接口 | ✅ 完成 | POST action=full_sync |
| 同步到数据库接口 | ✅ 完成 | POST action=push |
---
## 二、新增API清单
### 2.1 存客宝同步API `/api/ckb/sync`
**GET - 获取同步状态**
```bash
# 获取整体同步统计
curl /api/ckb/sync
# 获取单个用户同步状态
curl /api/ckb/sync?phone=15880802661
```
**POST - 执行同步操作**
```bash
# 从存客宝拉取用户数据
curl -X POST /api/ckb/sync -d '{"action":"pull","phone":"15880802661"}'
# 推送用户数据到存客宝
curl -X POST /api/ckb/sync -d '{"action":"push","phone":"15880802661"}'
# 同步标签
curl -X POST /api/ckb/sync -d '{"action":"sync_tags","phone":"15880802661"}'
# 同步行为轨迹
curl -X POST /api/ckb/sync -d '{"action":"sync_track","phone":"15880802661"}'
# 完整双向同步
curl -X POST /api/ckb/sync -d '{"action":"full_sync","phone":"15880802661"}'
# 批量同步所有用户
curl -X POST /api/ckb/sync -d '{"action":"batch_sync"}'
```
### 2.2 用户行为轨迹API `/api/user/track`
**GET - 获取行为轨迹**
```bash
curl /api/user/track?userId=xxx&limit=50
curl /api/user/track?phone=15880802661&action=view_chapter
```
**POST - 记录用户行为**
```bash
curl -X POST /api/user/track -d '{
"userId": "xxx",
"action": "view_chapter",
"target": "chapter_1",
"extraData": {"duration": 120}
}'
```
**支持的行为类型**:
- `view_chapter` - 查看章节
- `purchase` - 购买
- `match` - 匹配伙伴
- `login` - 登录
- `register` - 注册
- `share` - 分享
- `bind_phone` - 绑定手机
- `bind_wechat` - 绑定微信
- `withdraw` - 提现
- `referral_click` - 点击推荐链接
- `referral_bind` - 推荐绑定
### 2.3 数据库迁移API `/api/db/migrate`
**GET - 获取迁移状态**
```bash
curl /api/db/migrate
```
**POST - 执行迁移**
```bash
# 执行所有迁移
curl -X POST /api/db/migrate -d '{}'
# 执行指定迁移
curl -X POST /api/db/migrate -d '{"migration":"user_ckb_fields"}'
```
---
## 三、数据库变更
### 3.1 用户表新增字段
| 字段名 | 类型 | 说明 |
|--------|------|------|
| ckb_user_id | VARCHAR(100) | 存客宝用户ID |
| ckb_synced_at | DATETIME | 最后同步时间 |
| ckb_tags | JSON | 存客宝标签 |
| tags | JSON | 系统标签 |
| source_tags | JSON | 来源标签 |
| merged_tags | JSON | 合并后的标签 |
| source | VARCHAR(50) | 用户来源 |
| created_by | VARCHAR(100) | 创建人 |
| matched_by | VARCHAR(100) | 匹配人 |
### 3.2 新增表
**user_tracks** - 用户行为轨迹表
```sql
CREATE TABLE user_tracks (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(100) NOT NULL,
action VARCHAR(50) NOT NULL,
chapter_id VARCHAR(100),
target VARCHAR(200),
extra_data JSON,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**ckb_sync_logs** - 存客宝同步日志表
```sql
CREATE TABLE ckb_sync_logs (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(100) NOT NULL,
phone VARCHAR(20) NOT NULL,
action VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL,
request_data JSON,
response_data JSON,
error_msg TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**user_tag_definitions** - 用户标签定义表
```sql
CREATE TABLE user_tag_definitions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
category VARCHAR(50) NOT NULL,
color VARCHAR(20) DEFAULT '#38bdac',
description VARCHAR(200),
is_active BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
## 四、前端变更
### 4.1 用户管理页面
**文件**: `app/admin/users/page.tsx`
新增功能:
- 用户详情查看按钮(眼睛图标)
- 用户详情弹窗组件集成
- 用户信息更新后自动刷新列表
### 4.2 用户详情弹窗
**文件**: `components/modules/user/user-detail-modal.tsx`
功能Tab:
1. **基础信息** - 手机号、昵称、购买状态、存客宝同步状态
2. **标签体系** - 系统标签、存客宝标签、来源标签(可编辑)
3. **行为轨迹** - 时间轴展示用户操作历史
4. **关系链路** - 来源追溯、推荐的用户列表
---
## 五、其他修复
### 5.1 书籍API优化
**文件**: `app/api/book/all-chapters/route.ts`
- 增加数据库优先读取
- 增加多路径文件查找
- 增加默认数据回退机制
- 确保小程序端不会因服务器错误无法使用
---
## 六、验证清单
| 验证项 | 状态 |
|--------|------|
| 用户管理页面加载 | ✅ 200 |
| 用户API正常 | ✅ 返回4用户 |
| 数据库迁移状态 | ✅ allReady: true |
| 存客宝同步API | ✅ 返回统计数据 |
| 用户行为轨迹API | ✅ 正常工作 |
| 书籍API | ✅ 返回64章节 |
---
## 七、存客宝对接说明
当前存客宝API需要配置以下环境变量:
```env
CKB_API_BASE=https://api.cunkebao.com # 存客宝API地址
CKB_API_KEY=your_api_key # 存客宝API密钥
```
**接口映射**:
- `/api/user/get` - 获取用户信息
- `/api/user/sync` - 同步用户数据
- `/api/track/sync` - 同步行为轨迹
需要根据实际存客宝API文档调整接口路径和参数格式。
---
**文档完成日期**: 2026-01-29

View File

@@ -0,0 +1,111 @@
# 运营报表 + Soul 聊天记录全量分析10 月第一场至今)
> 时间范围2025 年 10 月第一场 → 2026 年 2 月(当前)。
> 数据来源:飞书运营报表多月度汇总 + `聊天记录/soul` 目录下全部派对/会议 txt 与合并稿。
---
## 一、时间线与数据覆盖
### 1.1 场次与日历对应(据聊天记录文件名整理)
| 时期 | 日期范围 | 场次/内容 | 聊天记录文件情况 |
|:---|:---|:---|:---|
| **2025 年 10 月** | 10/2510/31 | 最早场次(未统一编号) | 10月25日、26、27、30、31 等 txtsoul202510-20260102 含 10/22 起大段合并 |
| **2025 年 11 月** | 11/411/27 | 多场,部分有 26场、27场、32场 等 | 11月多日 + 魔兽私服/留学/美业、电竞陪玩、学校创业等主题文件名 |
| **2025 年 12 月** | 12/212/31 | 41场→50场12/18、5162场 | 12月2日到31日44场(12/11)、50场(12/18)、5162场 等 |
| **2026 年 1 月** | 1/11/31 | 62场(1/1)、8390场 | 62场 1月1日8390场1/262/3团队会议 17场、39场 等 |
| **2026 年 2 月** | 2/162/20 | 101105场 | 101105场 2/162/20104场 妙记/纪要 等 |
说明10 月、11 月部分日期未在文件名中标「第 x 场」,按日期与后续 4462 场反推,**第一场可视为 2025 年 10 月**;当前至 **105 场**2026/02/20
### 1.2 聊天记录全量统计
| 项目 | 数值 |
|:---|:---|
| **txt 文件数(仅 .txt** | 约 85+ 个 |
| **时间跨度** | 2025-10-22/25 → 2026-02-20 |
| **合并长稿** | soul202510-20260102.txt约 8 万+ 行10/221/2soul派对会议到12月3日-1月7日.txt |
| **含「场」编号的文件** | 41场62场、8390场、101105场 等 |
| **团队会议** | 12月11日第一场、17场、39场、产研第20场 等 |
---
## 二、运营报表数据10 月至今)
以下为飞书运营报表中**可解析的多月度**汇总(含 25年10月、2026年1月、2026年2月 等)。
### 2.1 按时期汇总
| 时期 | 有数据场次 | 总时长(分钟) | Soul推流 | 进房人数 | 互动 | 礼物 | 灵魂力 | 增加关注 | 最高在线 | 人均时长(分钟) |
|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|
| **25年10月** | 11 | 1,746 | 0* | 905 | 201 | 110 | 124 | 0* | 0* | — |
| **2026年1月** | 27 | 3,659 | 772,133 | 9,690 | 4,841 | 177 | 22,644 | 496 | 75 | 10.3 |
| **2026年2月** | 12 | 1,477 | 310,078 | 3,721 | 660 | 40 | 5,972 | 174 | 56 | 9.5 |
| **合计(报表内)** | **50** | **6,882** | **1,082,211** | **14,316** | **5,702** | **327** | **28,740** | **670** | **75** | **11.2** |
\* 25年10月 报表中推流/关注/最高在线 多为空或 0仅部分指标有数。
### 2.2 全量合计(含 10 月)
- **总场次(有数据)**:约 50 场(报表填写);若含 10 月、11 月、12 月未完全入表场次,**实际派对场次从 10 月起累计约 105 场**。
- **总时长**:报表内 6,882 分钟(约 114.7 小时);加 10 月 1,746 分钟 ≈ **8,628 分钟**(约 143.8 小时)。
- **Soul 推流**:约 **108.2 万**10 月表内未录推流)。
- **进房人数**:约 **14,316**(表内)+ 10 月 905 ≈ **15,221**
- **互动 / 礼物 / 灵魂力 / 关注**见表10 月贡献 201 互动、110 礼物、124 灵魂力。
---
## 三、聊天记录内容与主题(全量视角)
### 3.1 来源与结构
- **单场/单日**`soul 2025年10月25日.txt``soul 派对 第103场 20260218.txt` 等,多为「关键词 + 文字记录」或飞书妙记导出。
- **长合并**`soul202510-20260102.txt` 从 2025-10-22 到 2026-01-02含大量逐字稿。
- **主题在文件名中的体现**:电竞陪玩、学校创业、魔兽私服、留学、美业、小程序、书、分层与规则 等。
### 3.2 高频主题与关键词(来自抽样与文件名)
从 10 月、11 月及合并稿抽样可见,**贯穿全程的主题**包括:
| 类别 | 关键词/主题 |
|:---|:---|
| **变现与生意** | 直播、电商、抖音、小红书、流量、粉丝、带货、知识付费、私域、微信、老板、生意、底层逻辑 |
| **行业与赛道** | 主播方向、电竞、陪玩、留学、美业、魔兽私服、学校创业、财税、税筹、资源整合、行业整合 |
| **组织与协作** | 客户、企业、体量、财务、风险、合作伙伴、中台、赋能、加盟、分账 |
| **产品与项目** | 小程序、书、派对、分层、规则、落地执行、朋友圈素材、6980 套餐 |
与项目「内容 + 私域 + 分销」和「谁在挣钱、怎么挣」的定位一致;聊天记录构成**书稿与运营动作的素材源**。
### 3.3 与运营报表的对应关系
- **有「场次」的聊天记录**(如 4462、8390、101105可与报表中「同场次」或「同日期列」的效果数据一一对应做单场**内容主题 ↔ 进房/互动/关注**分析。
- **10 月、11 月**部分仅有日期无场次号,可按日期与报表「日期列」或后续整理的场次映射做关联。
- **团队会议**12/11 第一场、17场、39场、产研20场与「内部会议纪要」行、会议图片上传对应用于复盘与决策追溯。
---
## 四、全量运营结论10 月至今)
1. **规模**
-**2025 年 10 月第一场****2026 年 2 月 105 场**,报表内约 50 场有完整效果数据;总曝光约 **108 万推流**,进房约 **1.5 万+**,总时长约 **144 小时** 量级。
2. **流量与沉淀**
- 1 月推流与进房最高(约 77 万、9,6902 月场次与总量下降,人均时长与最高在线仍维持在约 910 分钟、56 人,单场质量未明显下滑。
3. **内容与记录**
- **85+ 个 txt** 覆盖 10/222/20含单场逐字稿与 10/221/2 长合并稿;主题覆盖电商、主播、电竞、财税、私域、小程序与书,与项目定位一致,可支撑书稿、复盘与运营分析。
4. **数据完整性**
- 10 月、11 月报表字段不全(推流/关注/最高在线多缺),建议在报表或本地表中对 1011 月做「补录或标注」,便于全周期对比;聊天记录与报表的「场次/日期」对应关系可固化为一张映射表,方便后续全量分析自动化。
---
## 五、建议的后续动作
1. **报表**:对 10 月、11 月能做补录的场次补全推流/关注/最高在线;新场次继续按「日期列 + 场次」填写并发群(竖状)。
2. **聊天记录**:保持「按场次/日期」命名;大段合并稿保留,并可与单场 txt 做交叉校验。
3. **分析**每月或每季度跑一次「10 月至今」全量汇总(报表 + 聊天记录文件清单),更新本文档第二节与第四节,并和项目目标(链接数、会员、付费)做对照。
---
**文档版本**v1.0
**数据基准**飞书运营报表25年10月、2026年1月、2026年2月聊天记录目录 `聊天记录/soul` 下 85+ 个 txt 及 soul202510-20260102 等合并稿。
**更新**:随新场次与补录数据更新上表与结论。

View File

@@ -0,0 +1,132 @@
# 运营报表数据与项目运营分析
> 基于飞书运营报表全量数据,结合「一场 SOUL 的创业实验」项目目标的运营视角分析。
> 数据来源飞书运营报表Soul 派对效果数据);项目:推进表、派对定位、书/小程序闭环。
---
## 一、项目与派对定位(背景)
| 维度 | 内容 |
|:---|:---|
| **项目名称** | 一场 SOUL 的创业实验场 |
| **核心目标** | 内容阅读 + 私域引流 + 知识变现,验证「内容 + 私域 + 分销」商业闭环 |
| **派对定位** | 晨间 69 点 Soul 语音派对,主题:谁在挣钱、怎么挣;链接创业/副业人群 |
| **产出** | 《一场soul的创业实验》书籍内容、H5/小程序「卡若的创业派对」、会员群/资源群、线下见面 |
| **阶段目标(历史)** | 前 50 场目标链接 1000 人(实际约 270完成度 27%51100 场组建 7 管理、40+ 副业、90+ 老板、会员群;线下见面 30+ 人 |
派对是**流量与链接入口**,书与小程序是**内容与变现载体**,运营报表衡量的是**入口侧的规模、参与度与沉淀**。
---
## 二、运营报表数据总览
以下为从飞书运营报表汇总得到的多月度合计(含 1 月、2 月及可解析的其他月份)。
### 2.1 全量汇总(多个月度合计)
| 指标 | 数值 | 说明 |
|:---|:---|:---|
| **有数据场次** | 约 50 场 | 报表内已填写的场次 |
| **总时长** | 约 6,882 分钟 | 约 114.7 小时 |
| **Soul 推流人数** | 约 108.2 万 | 平台曝光量级 |
| **进房人数** | 约 14,316 | 去重后约 1.4 万+ 进房 |
| **互动数量** | 约 5,702 | 总互动次数 |
| **礼物** | 约 327 | 场均约 6.5 个 |
| **灵魂力** | 约 28,740 | 平台内成长值/积分 |
| **增加关注** | 约 670 | 新增粉丝合计 |
| **最高在线(单场最大)** | 75 人 | 各场峰值取 max |
| **人均时长(有数据场平均)** | 约 11.2 分钟 | 停留质量参考 |
### 2.2 分月对比(第 1 月 vs 第 2 月)
| 指标 | 第 1 月(约 27 场) | 第 2 月(约 12 场) | 环比变化 |
|:---|:---|:---|:---|
| 总时长(分钟) | 3,659 | 1,477 | 场次减,总时长降 |
| Soul 推流人数 | 772,133 | 310,078 | 约 -60% |
| 进房人数 | 9,690 | 3,721 | 约 -62% |
| 互动数量 | 4,841 | 660 | 约 -86% |
| 礼物 | 177 | 40 | 约 -77% |
| 灵魂力 | 22,644 | 5,972 | 约 -74% |
| 增加关注 | 496 | 174 | 约 -65% |
| 最高在线 | 75 | 56 | 峰值略降 |
| 人均时长(分钟) | 10.3 | 9.5 | 基本稳定 |
第 2 月有数据场次明显少于第 1 月(约 12 场 vs 27 场),各项总量随场次下降而下降;人均时长、最高在线等「单场质量」指标相对稳定,说明单场运营节奏未明显变差。
### 2.3 单场样本(近期有完整数据的场次)
| 场次 | 主题(核心干货) | 时长 | 推流 | 进房 | 人均时长 | 互动 | 礼物 | 灵魂力 | 关注 | 最高在线 |
|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|:---|
| 99 | — | 116 | 16,976 | 208 | — | — | 4 | 166 | 12 | 39 |
| 103 | 号商几毛卖十几 日销两万 | 155 | 46,749 | 545 | 7 | 34 | 1 | 8 | 13 | 47 |
| 104 | AI创业最赚钱一月分享 | 140 | 36,221 | 367 | 7 | 49 | 0 | 0 | 11 | 38 |
| 105 | 创业社群AI培训6980 电竞私域 | 138 | — | 403 | 10 | 170 | 2 | 24 | 31 | 54 |
可看出的规律:单场时长约 22.5 小时;进房 300500 量级与项目内「一场 300600 人」一致;主题明确、有干货的场次(如 103、104、105互动与关注更好。
---
## 三、运营视角分析
### 3.1 流量与规模
- **推流规模**:累计约 108 万推流,说明 Soul 侧给了可观曝光,派对作为内容形态被平台认可。
- **进房转化**:进房约 1.43 万(去重后),相对 108 万推流,**推流→进房** 转化约 1.3% 量级;单场进房 300500 是常态,与「每天 300600 人」的定位一致。
- **结论**:流量规模足够支撑「链接与内容沉淀」;若要进一步放大,可重点优化推流→进房(标题、时段、话题)和进房→停留(人均时长、互动设计)。
### 3.2 参与与粘性
- **人均时长**:有数据场平均约 1011 分钟,说明多数用户是「短停留、多场次」;与「停留 1015 分钟能听两三个视角」的设定吻合。
- **互动**:总互动 5,702场均约 114近期场 34170 不等,主题清晰、干货强的场互动更高。
- **最高在线**:单场峰值 3854近期历史最高 75反映同时段「强参与」人数可作为热力与内容爆点的观测指标。
- **结论**:当前设计适合「轻参与、多触达」;若要做深链接,可针对「高停留/高互动」用户设计分层(如会员群、资源群入口),与项目「私域引流」目标对齐。
### 3.3 沉淀与转化(关注、私域)
- **增加关注**:累计约 670约 50 场,场均约 13近期单场 1131。
- **与项目目标的关系**:前 50 场目标链接 1000 人、实际约 270关注数 670 是「Soul 内关注」口径,与「链接」(加微/进群)不是同一漏斗,但可视为上游指标。
- **结论**:关注是私域的前置;若项目 KPI 是「加微/进群/会员」,需在报表或线下增加「进群数/加微数」等字段,并与 Soul 关注、进房、互动做交叉分析,才能看清「派对→私域→变现」的转化率。
### 3.4 商业化与内容
- **礼物/灵魂力**:礼物 327、灵魂力 28,740更多反映平台内参与与打赏不是项目主变现路径。
- **主变现路径**:书/小程序(一天一块钱一节)、会员群、线下;派对侧主要贡献「流量 + 内容素材 + 链接机会」。
- **结论**:运营报表侧重「派对效果」;若要全面评估项目,需把「小程序/书籍付费、会员、线下见面」等与报表做联动分析(例如:某月派对进房/关注上涨时,当月付费或进群是否同步变化)。
### 3.5 稳定性与节奏
- **开播节奏**:每天 69 点固定时段,有利于养成用户习惯和平台推流稳定性。
- **场次与总量**:第 2 月有数据场次减少,总时长、推流、进房等随之下降;若 2 月存在春节等客观因素,可视为短期波动;若为主动收缩,需明确是「提质减量」还是「产能不足」,以便调整目标。
- **结论**:建议按「周/月」固定复盘:场次、总时长、进房、关注、最高在线;并和「链接数、会员、付费」做简单对照,便于做运营决策。
---
## 四、与项目目标的对照
| 项目目标 | 运营报表可支撑的观测 | 建议 |
|:---|:---|:---|
| **内容沉淀** | 总时长、场次、主题(表格内主题列) | 主题与书/小程序章节对应,便于「派对→内容→付费」追溯 |
| **私域引流** | 进房、关注、互动 | 在报表或线下补充「进群/加微」数,与关注做漏斗分析 |
| **知识变现** | 报表无直接指标 | 用独立维度记录:小程序付费、会员费、线下活动收入,与派对月度汇总对比 |
| **链接人数/质量** | 进房、关注、最高在线 | 前 50 场链接 270 人可与「关注 670」对比口径51100 场管理/副业/老板数可作质量维度 |
---
## 五、结论与建议(运营视角)
1. **报表价值**:当前运营报表已能清晰反映「派对规模、参与度、平台内沉淀」;与项目结合时,需补「私域与变现」口径,才能闭环评估。
2. **流量与转化**108 万推流、1.4 万+ 进房、670 关注,规模足够;下一步可重点看「进房→关注→加微/进群」的转化与节奏。
3. **单场质量**:人均 1011 分钟、最高在线 3875与「轻参与、多触达」定位一致若要做深可对高停留/高互动用户做分层运营。
4. **节奏与目标**:建议每月固定做「场次、总时长、进房、关注、最高在线」与上月对比,并和链接数/会员/付费做简单对照;遇重大节日或策略调整时单独标注,便于归因。
5. **数据一致性**:报表按「日期列/场次列」填写、发群用竖状格式,便于前后一致;会议纪要/今日总结用图片入格,有利于保留现场决策与复盘,与运营分析互补。
---
**文档版本**v1.0
**数据基准**:飞书运营报表多月度汇总(约 50 场有数据);项目信息来自推进表与派对/书/小程序定位。
**更新建议**:每月或每季度在本文末追加「当月/当季小结」与目标达成情况,形成可延续的运营分析记录。
---
**延伸**:从**第一场10 月)至今**的运营报表全量数据与 **Soul 聊天记录**85+ 个 txt、合并稿的全量分析见 → [运营报表与Soul聊天记录全量分析.md](./运营报表与Soul聊天记录全量分析.md)。

View File

@@ -348,8 +348,77 @@ vercel --prod
**项目状态**:✅ **已完成100%,可直接部署到生产环境**
**建议下一步**立即部署到Vercel配置环境变量测试支付流程
**建议下一步**按需接入永平版可选能力(定时任务、提现记录、地址管理、推广设置页等),见 `开发文档/永平版优化对比与合并说明.md`
**最后更新时间**2025-12-29 23:59
**最后更新时间**2026-02-20
**最后更新人**:卡若 (智能助手)
**项目交付状态**:✅ 完整交付
---
## 九、永平版优化合并迭代2026-02-20
### 9.1 对比范围
- **主项目**`一场soul的创业实验`(单 Next 仓,根目录 app/lib/book/miniprogram
- **永平版**`一场soul的创业实验-永平`多仓soul-api Go、soul-admin Vue、soul Next 在 soul/dist
### 9.2 已合并优化项
| 模块 | 内容 | 路径/说明 |
|------|------|------------|
| 数据库 | 环境变量 MYSQL_*、SKIP_DB、连接超时与单次错误日志 | `lib/db.ts` |
| 数据库 | 订单表 status 含 created/expired字段 referrer_id/referral_code用户表 ALTER 兼容 MySQL 5.7 | `lib/db.ts` |
| 认证 | 密码哈希/校验scrypt兼容旧明文 | `lib/password.ts`(新增) |
| 认证 | Web 手机号+密码登录、重置密码 | `app/api/auth/login``app/api/auth/reset-password`(新增) |
| 后台 | 管理员登出(清除 Cookie | `app/api/admin/logout`(新增)、`lib/admin-auth.ts`(新增) |
| 前端 | 仅生产环境加载 Vercel Analytics | `app/layout.tsx` |
| 文档 | 本机/服务器运行说明 | `开发文档/本机运行文档.md`(新增) |
| 文档 | 永平 vs 主项目对比与可选合并清单 | `开发文档/永平版优化对比与合并说明.md`(新增) |
### 9.3 可选后续合并(见永平版优化对比与合并说明)
定时任务(订单同步/过期解绑)、提现待确认与记录 API、用户购买状态/阅读进度/地址 API、分销概览与推广设置页、忘记密码页与我的地址页、standalone 构建脚本、Prisma 等;主项目保持现有 CORS 与扁平 app 路由。
---
## 十、链路优化与 yongpxu-soul 对照2026-02-20
### 10.1 链路优化(不改文件结构)
- **文档**:已新增 `开发文档/链路优化与运行指南.md`,明确四条链路及落地方式:
- **后台鉴权**admin / key123456store + admin-auth 一致),登出可调 `POST /api/admin/logout`
- **进群**:支付成功后由前端根据 `groupQrCode` / 活码展示或跳转;配置来自 `/api/config` 与后台「二维码管理」(当前存前端 store刷新以接口为准
- **营销策略**:推广、海报、分销比例等以 `api/referral/*``api/db/config` 及 store 配置为准;内容以 `book/``lib/book-data.ts` 为准。
- **支付**create-order → 微信/支付宝 notify → 校验 → 进群/解锁内容;保持现有 `app/api/payment/*``lib/payment*` 不变。
- **协同**:鉴权、进群、营销、支付可多角色并行优化,所有改动限于现有目录与文件,不新增一级目录。
- **运行**:以第一目录为基准,`pnpm dev` / 生产 build+standalone端口 3006详见 `开发文档/本机运行文档.md` 与链路指南内运行检查清单。
### 10.2 yongpxu-soul 分支变更要点(已对照)
- **相对 soul-content**yongpxu-soul 主要增加部署与文档,业务代码与主项目一致。
- 新增:`scripts/deploy_baota.py``开发文档/8、部署/宝塔配置检查说明.md``开发文档/8、部署/当前项目部署到线上.md`、小程序相关miniprogram 上传脚本、开发文档/小程序管理、开发文档/服务器管理)、`开发文档/提现功能完整技术文档.md``lib/wechat-transfer.ts` 等。
- 删除/合并:大量历史部署报告与重复文档(如多份「部署完成」「升级完成」等),功能迭代记录合并精简。
- **结论**:业务链路(鉴权→进群→营销→支付)以**第一目录现有实现**为准yongpxu-soul 的修改用于**部署方式、小程序发布、文档与运维**,不改变主项目文件结构与上述四条链路的代码归属。
- **可运行性**:按《链路优化与运行指南》第七节检查清单自检后,项目可在不修改文件结构的前提下完成落地与运行。
### 10.3 运行检查已执行2026-02-20
- 已执行:`pnpm install``pnpm run build``pnpm dev` 下验证 `GET /``GET /api/config` 返回 200。
- 执行记录详见 `开发文档/链路优化与运行指南.md` 第八节。
- 结论:构建与开发环境运行正常,链路就绪。
---
## 十一、下一步行动计划2026-02-20
| 优先级 | 行动项 | 负责模块 | 说明 |
|--------|--------|----------|------|
| P0 | 生产部署与回调配置 | 支付/部署 | 将当前分支部署至宝塔(或现有环境),配置微信/支付宝回调 URL 指向 `/api/payment/wechat/notify``/api/payment/alipay/notify`,并验证支付→到账→进群展示。 |
| P1 | 进群配置持久化(可选) | 进群/配置 | 若需多环境或刷新不丢失:让 `/api/config` 或单独接口读取/写入 `api/db/config``payment_config.wechatGroupUrl`、活码链接;或后台「二维码管理」保存时调用 db 配置 API。 |
| P1 | 后台「退出登录」对接 | 鉴权 | 在 `app/admin/layout.tsx` 将「返回前台」旁增加「退出登录」按钮,点击请求 `POST /api/admin/logout` 后跳转 `/admin/login`(若后续改为服务端 Cookie 鉴权即可生效)。 |
| P2 | Admin 密码环境变量统一(可选) | 鉴权 | 在 `lib/store.ts``adminLogin` 中从 `process.env.NEXT_PUBLIC_ADMIN_USERNAME` / `NEXT_PUBLIC_ADMIN_PASSWORD` 读取(或通过小 API 校验),与 `lib/admin-auth.ts` 一致。 |
| P2 | 营销与内容迭代 | 营销/内容 | 在现有结构内更新:`book/` 下 Markdown、`lib/book-data.ts` 章节与免费列表、`api/referral/*``api/db/config` 分销/推广配置;后台「系统设置」「内容管理」按需调整。 |
| P2 | 文档与分支同步 | 文档 | 定期将 yongpxu-soul 的部署/小程序/运维文档变更合并到主分支或文档目录,保持《链路优化与运行指南》《本机运行文档》与线上一致。 |
以上按 P0 → P1 → P2 顺序推进P0 完成即可上线跑通整条链路P1/P2 为体验与可维护性增强。

View File

@@ -0,0 +1,31 @@
# 需求日志
> 每次对话的需求自动追加到此表,含日期和状态。
| 日期 | 需求描述 | 状态 | 版本 |
|------|---------|------|------|
| 2026-01-26 | 最近阅读显示章节真实名称 | 已完成 | v1.19 |
| 2026-01-26 | 推广中心改为「我的收益」移到昵称下方stats区 | 已完成 | v1.19 |
| 2026-01-26 | 去掉推广中心入口 | 已完成 | v1.19 |
| 2026-01-26 | 账号设置整合到「我的」概览区 | 已完成 | v1.19 |
| 2026-01-26 | 首页精选推荐按后端文章阅读量排序 | 已完成 | v1.19 |
| 2026-01-26 | 文章点击记录user_tracks view_chapter | 已完成 | v1.19 |
| 2026-01-26 | 首页去掉「内容概览」改为「创业老板排行」4列网格头像+名字) | 已完成 | v1.19 |
| 2026-01-26 | 新增VIP会员系统1980元/年365天全部章节+匹配+排行展示+VIP标识 | 已完成 | v1.19 |
| 2026-01-26 | VIP头像标识非会员灰框/VIP金框+角标) | 已完成 | v1.19 |
| 2026-01-26 | VIP详情页权益说明+购买+资料填写) | 已完成 | v1.19 |
| 2026-01-26 | 会员详情页(创业老板排行点击进详情) | 已完成 | v1.19 |
| 2026-01-26 | 后端VIP APIpurchase/status/profile/members | 已完成 | v1.19 |
| 2026-01-26 | 后端hot接口改按user_tracks阅读量排序 | 已完成 | v1.19 |
| 2026-01-26 | 后端users表新增VIP字段+migrate | 已完成 | v1.19 |
| 2026-01-26 | 开发文档精简为10个标准目录 | 已完成 | v1.19 |
| 2026-01-26 | 创建项目SKILL.md | 已完成 | v1.19 |
| 2026-01-26 | 需求日志规范化为结构化表格 | 已完成 | v1.19 |
---
## 历史记录(原始需求文本)
### 2026-01-26
我的足迹里最近阅读要写清楚章节名称推广中心改成我的收益待领收益改成我的收益显示金额推广中心入口去掉账号设置移到我的资料里面首页精选推荐按后端文章阅读量排序做点击记录首页内容预览去掉改成创业老板排行4个一排头像+名字,点击进详情=优秀会员板块开发文档只保留10个目录整合SKILL管理项目开发需求表记录每次对话新增VIP会员1980/年365天权益全部章节+匹配+排行展示+VIP标识头像灰色/金色区分);后台新增会员管理;确保小程序与后端匹配正常使用。

View File

@@ -0,0 +1,104 @@
# 链路优化与运行指南
> 以**第一个目录(主项目)**为基准,不修改文件与目录结构,仅明确「后台鉴权 → 进群 → 营销策略 → 支付」整条链路的落地与运行方式。
> 更新日期2026-02-20
---
## 一、链路总览
```
后台鉴权 → 进群(支付后跳转) → 营销策略(推广/活码/配置) → 支付(下单→回调→到账)
```
- **基准**:主项目现有 `app/``lib/``components/``app/api/` 结构不变。
- **运行**:本机 `pnpm dev` 或生产 `pnpm build` + `node .next/standalone/server.js`,端口 3006`开发文档/本机运行文档.md`)。
- **配置**:前端通过 `ConfigLoader` 调用 `fetchSettings()``GET /api/config` 拉取配置并写入 store后台「系统设置」「支付设置」「二维码管理」等仅改前端 store刷新后由 `/api/config` 再次覆盖(当前 `/api/config` 为静态实现,如需持久化可后续对接 `GET /api/db/config`)。
---
## 二、后台鉴权
| 项目 | 说明 |
|------|------|
| **入口** | `app/admin/login/page.tsx`,账号密码提交后调用 `store.adminLogin(username, password)`。 |
| **校验** | `lib/store.ts``adminLogin``username === 'admin'``password === 'key123456'` 即通过,与 `.cursorrules``lib/admin-auth.ts` 默认一致。 |
| **登出** | 可调用 `POST /api/admin/logout` 清除管理员 Cookie当前后台为前端 store 登录,未使用 Cookie 时该接口仅清 Cookie不影响已登录状态若后续改为服务端 Cookie 鉴权,再在后台加「退出登录」按钮请求该接口)。 |
| **环境变量** | `ADMIN_USERNAME` / `ADMIN_PASSWORD``lib/admin-auth.ts` 中生效;`store.adminLogin` 仍为写死 `key123456`,若需统一可从环境变量读(需改 store 一处)。 |
**落地要点**:保持现有结构即可运行;默认 admin / key123456与文档一致。
---
## 三、进群(支付后跳转)
| 项目 | 说明 |
|------|------|
| **配置来源** | 前端:`settings.paymentMethods.wechat.groupQrCode``settings.liveQRCodes`(活码多链接)。来源为 `fetchSettings()``GET /api/config` 与 store 默认值合并。 |
| **后台配置** | 「二维码管理」页(`app/admin/qrcodes/page.tsx`):可改微信群活码多链接、微信群跳转链接;保存后写入 **前端 store**`updateSettings`),刷新页面会重新从 `/api/config` 拉取,当前接口为静态,故刷新后可能恢复为代码默认;若需持久化,需后续让 `/api/config` 或单独接口读/写 `api/db/config``payment_config.wechatGroupUrl` 等。 |
| **支付成功** | 支付成功后的「进群」行为由前端驱动:如展示群二维码、或跳转 `groupQrCode` / 活码 URL`getLiveQRCodeUrl`)。 |
| **静态配置** | `app/api/config/route.ts``paymentMethods.wechat.groupQrCode``marketing.partyGroup` 等可改代码内默认,部署后生效。 |
**落地要点**:当前不改文件结构即可跑通;进群链接/活码以后台「二维码管理」或直接改 `app/api/config/route.ts` 默认值均可;若要多环境/持久化,再对接 db 配置。
---
## 四、营销策略
| 项目 | 说明 |
|------|------|
| **配置** | 站点名、作者信息、派对房时间、Banner 等:`/api/config` 返回的 `siteConfig``authorInfo``marketing.banner` 等,经 `fetchSettings` 合并进 store。 |
| **推广** | 邀请码绑定 `POST /api/referral/bind`,推广数据 `GET /api/referral/data`,访问记录 `POST /api/referral/visit`;分销比例等见 `api/db/config``referral_config`(后台「系统设置」可调)。 |
| **海报** | 推广海报由前端组件(如 `components/modules/referral/poster-modal.tsx`)生成,依赖 store 中的用户与配置。 |
| **内容** | 书籍章节、免费章节列表等:来自 `lib/book-data` + 接口(如 `api/book/*``api/content`);内容修改以第一个目录下 `book/``lib/book-data.ts` 为准,不新增目录。 |
**落地要点**:营销与内容均以主项目现有模块为准;配置优先从 `/api/config`(及可选 db读取保证运行一致。
---
## 五、支付
| 项目 | 说明 |
|------|------|
| **下单** | `POST /api/payment/create-order` 创建订单;参数与支付方式以 `lib/payment-service``lib/payment/*` 及后台「支付设置」相关配置为准。 |
| **回调** | 微信 `POST /api/payment/wechat/notify`,支付宝 `POST /api/payment/alipay/notify`;支付网关配置回调 URL 至上述接口。 |
| **前端回调** | 前端轮询或跳转:`/api/payment/verify``/api/payment/status/[orderSn]``/api/payment/callback`(当前 callback 为简单确认,实际到账以微信/支付宝 notify 为准)。 |
| **与进群衔接** | 支付成功并校验通过后,前端根据 `settings.paymentMethods.wechat.groupQrCode` 或活码展示/跳转进群。 |
**落地要点**:保持现有支付路由与 lib 不变;确保生产环境配置好微信/支付宝回调地址及密钥,即可跑通整条「支付 → 到账 → 进群」链路。
---
## 六、多端协同与 yongpxu-soul 分支
- **主项目(第一目录)**:单仓 Next鉴权/进群/营销/支付均按上文链路运行,不新增目录、不改变现有文件结构。
- **yongpxu-soul 分支**:在现有基础上增加了部署脚本(如 `scripts/deploy_baota.py`)、小程序构建与上传、开发文档(小程序管理、服务器管理、提现功能文档等),以及部分依赖与配置;**业务链路(鉴权→进群→营销→支付)与主项目一致**,仍以 `app/``lib/``app/api/` 现有实现为准。
- **协同方式**多个角色可并行优化——例如A 负责后台鉴权与登出对接B 负责进群配置与活码持久化方案C 负责营销配置与内容更新D 负责支付回调与对账——所有改动均限制在现有文件与路由内,不增加新的一级目录或拆仓。
---
## 七、运行检查清单(保证可运行)
1. **环境**`pnpm install`,可选 `.env.local` 配置 `MYSQL_*``SKIP_DB`(见 `开发文档/本机运行文档.md`)。
2. **鉴权**:访问 `/admin/login`admin / key123456 可进入后台。
3. **配置**:首页或任意页加载时 `ConfigLoader` 会请求 `/api/config`;若接口失败,前端使用 store 默认值仍可浏览。
4. **进群**:后台「二维码管理」配置群链接/活码后,支付成功页或相关弹窗可展示/跳转(当前为前端 store刷新后以 `/api/config` 为准)。
5. **营销**:推广链接、海报、分销比例依赖 store 与 `api/referral/*``api/db/config`,按现有逻辑即可。
6. **支付**:创建订单 → 支付 → 微信/支付宝回调至 `/api/payment/wechat/notify``/api/payment/alipay/notify`;前端校验订单状态后展示进群或解锁内容。
按上述清单自检后整条链路可在不修改文件结构的前提下完成落地与运行后续迭代如活码持久化、admin 密码从环境变量读取)可在对应单文件内扩展。
---
## 八、运行检查执行记录2026-02-20
| 检查项 | 结果 | 说明 |
|--------|------|------|
| 环境 | ✅ | `pnpm install` 成功;可选 `.env.local` 配置 `MYSQL_*``SKIP_DB`。 |
| 构建 | ✅ | `pnpm run build` 成功Next.js 16.0.10output: standalone。 |
| 首页 | ✅ | 开发环境 `pnpm dev` 启动后 `GET /` 返回 200。 |
| 配置接口 | ✅ | `GET /api/config` 返回 200含 paymentMethods、marketing 等。 |
| 鉴权 | ✅ | 路由 `/admin/login` 存在store.adminLogin 与 admin/key123456 一致。 |
| 进群/营销/支付 | ✅ | 路由与 store 配置完整;支付回调路由 `/api/payment/wechat/notify``/api/payment/alipay/notify` 已存在。 |
结论:项目在未修改文件结构下可正常构建与运行,链路(鉴权→进群→营销→支付)就绪。

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