Compare commits
11 Commits
09fb67d2af
...
yongpxu-so
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b038a042c2 | ||
|
|
afc2376e96 | ||
|
|
e91a5d9f7a | ||
|
|
7551840c86 | ||
|
|
f6846b5941 | ||
|
|
685b476721 | ||
|
|
74b1c3396d | ||
|
|
6e276fca61 | ||
|
|
76d90a0397 | ||
|
|
934f7c7988 | ||
|
|
0e4baa4b7f |
@@ -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秒生效
|
||||
|
||||
41
app/admin/error.tsx
Normal file
41
app/admin/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
9
app/api/admin/logout/route.ts
Normal file
9
app/api/admin/logout/route.ts
Normal 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
|
||||
}
|
||||
72
app/api/auth/login/route.ts
Normal file
72
app/api/auth/login/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
54
app/api/auth/reset-password/route.ts
Normal file
54
app/api/auth/reset-password/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}个月前`
|
||||
}
|
||||
|
||||
97
app/api/content/upload/route.ts
Normal file
97
app/api/content/upload/route.ts
Normal 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') ? `` : 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
67
app/api/vip/members/route.ts
Normal file
67
app/api/vip/members/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
77
app/api/vip/profile/route.ts
Normal file
77
app/api/vip/profile/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
57
app/api/vip/purchase/route.ts
Normal file
57
app/api/vip/purchase/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
73
app/api/vip/status/route.ts
Normal file
73
app/api/vip/status/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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上认识,在现实中落地。
|
||||
|
||||
这就是资源整合最简单的样子。
|
||||
519
content-manager.html
Normal file
519
content-manager.html
Normal 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="# 标题 正文内容... 图片用 {{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 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}}}`, `})`);
|
||||
});
|
||||
|
||||
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
275
content_upload.py
Normal 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"")
|
||||
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()
|
||||
92
lib/admin-auth.ts
Normal file
92
lib/admin-auth.ts
Normal 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
|
||||
}
|
||||
@@ -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
182
lib/db.ts
@@ -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.7:IF 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
56
lib/password.ts
Normal 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
|
||||
}
|
||||
27
middleware.ts
Normal file
27
middleware.ts
Normal 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*',
|
||||
}
|
||||
@@ -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",
|
||||
@@ -52,10 +54,9 @@
|
||||
}
|
||||
},
|
||||
"requiredPrivateInfos": [
|
||||
"getLocation",
|
||||
"chooseAddress"
|
||||
"getLocation"
|
||||
],
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
37
miniprogram/pages/member-detail/member-detail.js
Normal file
37
miniprogram/pages/member-detail/member-detail.js
Normal 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() }
|
||||
})
|
||||
1
miniprogram/pages/member-detail/member-detail.json
Normal file
1
miniprogram/pages/member-detail/member-detail.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "usingComponents": {}, "navigationStyle": "custom" }
|
||||
38
miniprogram/pages/member-detail/member-detail.wxml
Normal file
38
miniprogram/pages/member-detail/member-detail.wxml
Normal 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>
|
||||
24
miniprogram/pages/member-detail/member-detail.wxss
Normal file
24
miniprogram/pages/member-detail/member-detail.wxss
Normal 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; }
|
||||
@@ -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() {}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
107
miniprogram/pages/vip/vip.js
Normal file
107
miniprogram/pages/vip/vip.js
Normal 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() }
|
||||
})
|
||||
4
miniprogram/pages/vip/vip.json
Normal file
4
miniprogram/pages/vip/vip.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
60
miniprogram/pages/vip/vip.wxml
Normal file
60
miniprogram/pages/vip/vip.wxml
Normal 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>
|
||||
38
miniprogram/pages/vip/vip.wxss
Normal file
38
miniprogram/pages/vip/vip.wxss
Normal 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; }
|
||||
@@ -20,8 +20,10 @@ CONFIG = {
|
||||
'desc': 'Soul创业派对 - 首次发布',
|
||||
}
|
||||
|
||||
# 微信开发者工具CLI可能的路径
|
||||
# 微信开发者工具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",
|
||||
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
4
scripts/.env.feishu.example
Normal file
4
scripts/.env.feishu.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# 飞书应用凭证,用于上传海报图片到飞书群
|
||||
# 复制为 .env.feishu 并填写(需与 webhook 同租户的应用)
|
||||
FEISHU_APP_ID=your_app_id
|
||||
FEISHU_APP_SECRET=your_app_secret
|
||||
48
scripts/fix_souladmin_login.sh
Executable file
48
scripts/fix_souladmin_login.sh
Executable 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 尝试登录"
|
||||
271
scripts/send_poster_to_feishu.py
Normal file
271
scripts/send_poster_to_feishu.py
Normal 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
42
scripts/upload_soul_article.sh
Executable 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
454
soul-admin/dist/assets/index-CbOmKBRd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-DBQ1UORI.css
vendored
Normal file
1
soul-admin/dist/assets/index-DBQ1UORI.css
vendored
Normal file
File diff suppressed because one or more lines are too long
437
soul-admin/dist/index.html
vendored
Normal file
437
soul-admin/dist/index.html
vendored
Normal 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)+'}}','+')')});
|
||||
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>
|
||||
11
soul-book-api/package.json
Normal file
11
soul-book-api/package.json
Normal 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
243
soul-book-api/server.js
Normal 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}`)
|
||||
})
|
||||
79
开发文档/10、项目管理/soul-admin变更记录_v2026-02.md
Normal file
79
开发文档/10、项目管理/soul-admin变更记录_v2026-02.md
Normal 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` 的更新逻辑 |
|
||||
91
开发文档/10、项目管理/永平版优化对比与合并说明.md
Normal file
91
开发文档/10、项目管理/永平版优化对比与合并说明.md
Normal 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()` 已包含上述结构。
|
||||
|
||||
---
|
||||
|
||||
**文档状态**:已合并项已落地;未合并项见第三节,按需迭代。
|
||||
61
开发文档/10、项目管理/派对每日数据汇总.md
Normal file
61
开发文档/10、项目管理/派对每日数据汇总.md
Normal 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分钟进多少人、加微率 **不填**,由表格公式自动计算。
|
||||
111
开发文档/10、项目管理/运营报表与Soul聊天记录全量分析.md
Normal file
111
开发文档/10、项目管理/运营报表与Soul聊天记录全量分析.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 运营报表 + Soul 聊天记录全量分析(10 月第一场至今)
|
||||
|
||||
> 时间范围:2025 年 10 月第一场 → 2026 年 2 月(当前)。
|
||||
> 数据来源:飞书运营报表多月度汇总 + `聊天记录/soul` 目录下全部派对/会议 txt 与合并稿。
|
||||
|
||||
---
|
||||
|
||||
## 一、时间线与数据覆盖
|
||||
|
||||
### 1.1 场次与日历对应(据聊天记录文件名整理)
|
||||
|
||||
| 时期 | 日期范围 | 场次/内容 | 聊天记录文件情况 |
|
||||
|:---|:---|:---|:---|
|
||||
| **2025 年 10 月** | 10/25–10/31 | 最早场次(未统一编号) | 10月25日、26、27、30、31 等 txt;soul202510-20260102 含 10/22 起大段合并 |
|
||||
| **2025 年 11 月** | 11/4–11/27 | 多场,部分有 26场、27场、32场 等 | 11月多日 + 魔兽私服/留学/美业、电竞陪玩、学校创业等主题文件名 |
|
||||
| **2025 年 12 月** | 12/2–12/31 | 41场→50场(12/18)、51–62场 | 12月2日到31日;44场(12/11)、50场(12/18)、51–62场 等 |
|
||||
| **2026 年 1 月** | 1/1–1/31 | 62场(1/1)、83–90场 | 62场 1月1日;83–90场(1/26–2/3);团队会议 17场、39场 等 |
|
||||
| **2026 年 2 月** | 2/16–2/20 | 101–105场 | 101–105场 2/16–2/20;104场 妙记/纪要 等 |
|
||||
|
||||
说明:10 月、11 月部分日期未在文件名中标「第 x 场」,按日期与后续 44–62 场反推,**第一场可视为 2025 年 10 月**;当前至 **105 场**(2026/02/20)。
|
||||
|
||||
### 1.2 聊天记录全量统计
|
||||
|
||||
| 项目 | 数值 |
|
||||
|:---|:---|
|
||||
| **txt 文件数(仅 .txt)** | 约 85+ 个 |
|
||||
| **时间跨度** | 2025-10-22/25 → 2026-02-20 |
|
||||
| **合并长稿** | soul202510-20260102.txt(约 8 万+ 行,10/22–1/2);soul派对会议到12月3日-1月7日.txt |
|
||||
| **含「场」编号的文件** | 41场–62场、83–90场、101–105场 等 |
|
||||
| **团队会议** | 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 与运营报表的对应关系
|
||||
|
||||
- **有「场次」的聊天记录**(如 44–62、83–90、101–105)可与报表中「同场次」或「同日期列」的效果数据一一对应,做单场**内容主题 ↔ 进房/互动/关注**分析。
|
||||
- **10 月、11 月**部分仅有日期无场次号,可按日期与报表「日期列」或后续整理的场次映射做关联。
|
||||
- **团队会议**(12/11 第一场、17场、39场、产研20场)与「内部会议纪要」行、会议图片上传对应,用于复盘与决策追溯。
|
||||
|
||||
---
|
||||
|
||||
## 四、全量运营结论(10 月至今)
|
||||
|
||||
1. **规模**
|
||||
- 从 **2025 年 10 月第一场** 到 **2026 年 2 月 105 场**,报表内约 50 场有完整效果数据;总曝光约 **108 万推流**,进房约 **1.5 万+**,总时长约 **144 小时** 量级。
|
||||
2. **流量与沉淀**
|
||||
- 1 月推流与进房最高(约 77 万、9,690);2 月场次与总量下降,人均时长与最高在线仍维持在约 9–10 分钟、56 人,单场质量未明显下滑。
|
||||
3. **内容与记录**
|
||||
- **85+ 个 txt** 覆盖 10/22–2/20,含单场逐字稿与 10/22–1/2 长合并稿;主题覆盖电商、主播、电竞、财税、私域、小程序与书,与项目定位一致,可支撑书稿、复盘与运营分析。
|
||||
4. **数据完整性**
|
||||
- 10 月、11 月报表字段不全(推流/关注/最高在线多缺),建议在报表或本地表中对 10–11 月做「补录或标注」,便于全周期对比;聊天记录与报表的「场次/日期」对应关系可固化为一张映射表,方便后续全量分析自动化。
|
||||
|
||||
---
|
||||
|
||||
## 五、建议的后续动作
|
||||
|
||||
1. **报表**:对 10 月、11 月能做补录的场次补全推流/关注/最高在线;新场次继续按「日期列 + 场次」填写并发群(竖状)。
|
||||
2. **聊天记录**:保持「按场次/日期」命名;大段合并稿保留,并可与单场 txt 做交叉校验。
|
||||
3. **分析**:每月或每季度跑一次「10 月至今」全量汇总(报表 + 聊天记录文件清单),更新本文档第二节与第四节,并和项目目标(链接数、会员、付费)做对照。
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**数据基准**:飞书运营报表(25年10月、2026年1月、2026年2月);聊天记录目录 `聊天记录/soul` 下 85+ 个 txt 及 soul202510-20260102 等合并稿。
|
||||
**更新**:随新场次与补录数据更新上表与结论。
|
||||
132
开发文档/10、项目管理/运营报表与项目运营分析.md
Normal file
132
开发文档/10、项目管理/运营报表与项目运营分析.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 运营报表数据与项目运营分析
|
||||
|
||||
> 基于飞书运营报表全量数据,结合「一场 SOUL 的创业实验」项目目标的运营视角分析。
|
||||
> 数据来源:飞书运营报表(Soul 派对效果数据);项目:推进表、派对定位、书/小程序闭环。
|
||||
|
||||
---
|
||||
|
||||
## 一、项目与派对定位(背景)
|
||||
|
||||
| 维度 | 内容 |
|
||||
|:---|:---|
|
||||
| **项目名称** | 一场 SOUL 的创业实验场 |
|
||||
| **核心目标** | 内容阅读 + 私域引流 + 知识变现,验证「内容 + 私域 + 分销」商业闭环 |
|
||||
| **派对定位** | 晨间 6–9 点 Soul 语音派对,主题:谁在挣钱、怎么挣;链接创业/副业人群 |
|
||||
| **产出** | 《一场soul的创业实验》书籍内容、H5/小程序「卡若的创业派对」、会员群/资源群、线下见面 |
|
||||
| **阶段目标(历史)** | 前 50 场目标链接 1000 人(实际约 270,完成度 27%);51–100 场组建 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 |
|
||||
|
||||
可看出的规律:单场时长约 2–2.5 小时;进房 300–500 量级与项目内「一场 300–600 人」一致;主题明确、有干货的场次(如 103、104、105)互动与关注更好。
|
||||
|
||||
---
|
||||
|
||||
## 三、运营视角分析
|
||||
|
||||
### 3.1 流量与规模
|
||||
|
||||
- **推流规模**:累计约 108 万推流,说明 Soul 侧给了可观曝光,派对作为内容形态被平台认可。
|
||||
- **进房转化**:进房约 1.43 万(去重后),相对 108 万推流,**推流→进房** 转化约 1.3% 量级;单场进房 300–500 是常态,与「每天 300–600 人」的定位一致。
|
||||
- **结论**:流量规模足够支撑「链接与内容沉淀」;若要进一步放大,可重点优化推流→进房(标题、时段、话题)和进房→停留(人均时长、互动设计)。
|
||||
|
||||
### 3.2 参与与粘性
|
||||
|
||||
- **人均时长**:有数据场平均约 10–11 分钟,说明多数用户是「短停留、多场次」;与「停留 10–15 分钟能听两三个视角」的设定吻合。
|
||||
- **互动**:总互动 5,702,场均约 114;近期场 34–170 不等,主题清晰、干货强的场互动更高。
|
||||
- **最高在线**:单场峰值 38–54(近期),历史最高 75;反映同时段「强参与」人数,可作为热力与内容爆点的观测指标。
|
||||
- **结论**:当前设计适合「轻参与、多触达」;若要做深链接,可针对「高停留/高互动」用户设计分层(如会员群、资源群入口),与项目「私域引流」目标对齐。
|
||||
|
||||
### 3.3 沉淀与转化(关注、私域)
|
||||
|
||||
- **增加关注**:累计约 670,约 50 场,场均约 13;近期单场 11–31。
|
||||
- **与项目目标的关系**:前 50 场目标链接 1000 人、实际约 270;关注数 670 是「Soul 内关注」口径,与「链接」(加微/进群)不是同一漏斗,但可视为上游指标。
|
||||
- **结论**:关注是私域的前置;若项目 KPI 是「加微/进群/会员」,需在报表或线下增加「进群数/加微数」等字段,并与 Soul 关注、进房、互动做交叉分析,才能看清「派对→私域→变现」的转化率。
|
||||
|
||||
### 3.4 商业化与内容
|
||||
|
||||
- **礼物/灵魂力**:礼物 327、灵魂力 28,740,更多反映平台内参与与打赏,不是项目主变现路径。
|
||||
- **主变现路径**:书/小程序(一天一块钱一节)、会员群、线下;派对侧主要贡献「流量 + 内容素材 + 链接机会」。
|
||||
- **结论**:运营报表侧重「派对效果」;若要全面评估项目,需把「小程序/书籍付费、会员、线下见面」等与报表做联动分析(例如:某月派对进房/关注上涨时,当月付费或进群是否同步变化)。
|
||||
|
||||
### 3.5 稳定性与节奏
|
||||
|
||||
- **开播节奏**:每天 6–9 点固定时段,有利于养成用户习惯和平台推流稳定性。
|
||||
- **场次与总量**:第 2 月有数据场次减少,总时长、推流、进房等随之下降;若 2 月存在春节等客观因素,可视为短期波动;若为主动收缩,需明确是「提质减量」还是「产能不足」,以便调整目标。
|
||||
- **结论**:建议按「周/月」固定复盘:场次、总时长、进房、关注、最高在线;并和「链接数、会员、付费」做简单对照,便于做运营决策。
|
||||
|
||||
---
|
||||
|
||||
## 四、与项目目标的对照
|
||||
|
||||
| 项目目标 | 运营报表可支撑的观测 | 建议 |
|
||||
|:---|:---|:---|
|
||||
| **内容沉淀** | 总时长、场次、主题(表格内主题列) | 主题与书/小程序章节对应,便于「派对→内容→付费」追溯 |
|
||||
| **私域引流** | 进房、关注、互动 | 在报表或线下补充「进群/加微」数,与关注做漏斗分析 |
|
||||
| **知识变现** | 报表无直接指标 | 用独立维度记录:小程序付费、会员费、线下活动收入,与派对月度汇总对比 |
|
||||
| **链接人数/质量** | 进房、关注、最高在线 | 前 50 场链接 270 人可与「关注 670」对比口径;51–100 场管理/副业/老板数可作质量维度 |
|
||||
|
||||
---
|
||||
|
||||
## 五、结论与建议(运营视角)
|
||||
|
||||
1. **报表价值**:当前运营报表已能清晰反映「派对规模、参与度、平台内沉淀」;与项目结合时,需补「私域与变现」口径,才能闭环评估。
|
||||
2. **流量与转化**:108 万推流、1.4 万+ 进房、670 关注,规模足够;下一步可重点看「进房→关注→加微/进群」的转化与节奏。
|
||||
3. **单场质量**:人均 10–11 分钟、最高在线 38–75,与「轻参与、多触达」定位一致;若要做深,可对高停留/高互动用户做分层运营。
|
||||
4. **节奏与目标**:建议每月固定做「场次、总时长、进房、关注、最高在线」与上月对比,并和链接数/会员/付费做简单对照;遇重大节日或策略调整时单独标注,便于归因。
|
||||
5. **数据一致性**:报表按「日期列/场次列」填写、发群用竖状格式,便于前后一致;会议纪要/今日总结用图片入格,有利于保留现场决策与复盘,与运营分析互补。
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0
|
||||
**数据基准**:飞书运营报表多月度汇总(约 50 场有数据);项目信息来自推进表与派对/书/小程序定位。
|
||||
**更新建议**:每月或每季度在本文末追加「当月/当季小结」与目标达成情况,形成可延续的运营分析记录。
|
||||
|
||||
---
|
||||
|
||||
**延伸**:从**第一场(10 月)至今**的运营报表全量数据与 **Soul 聊天记录**(85+ 个 txt、合并稿)的全量分析见 → [运营报表与Soul聊天记录全量分析.md](./运营报表与Soul聊天记录全量分析.md)。
|
||||
@@ -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 / key123456(store + 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 为体验与可维护性增强。
|
||||
|
||||
31
开发文档/1、需求/需求日志.md
Normal file
31
开发文档/1、需求/需求日志.md
Normal 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 API(purchase/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标识,头像灰色/金色区分);后台新增会员管理;确保小程序与后端匹配正常使用。
|
||||
104
开发文档/2、架构/链路优化与运行指南.md
Normal file
104
开发文档/2、架构/链路优化与运行指南.md
Normal 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.10,output: 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` 已存在。 |
|
||||
|
||||
结论:项目在未修改文件结构下可正常构建与运行,链路(鉴权→进群→营销→支付)就绪。
|
||||
93
开发文档/6、后端/内容创建问题修复说明.md
Normal file
93
开发文档/6、后端/内容创建问题修复说明.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# 内容创建问题修复说明
|
||||
|
||||
> 问题:souladmin 添加内容后显示「创建成功」,但目录和数据库未增加,前端也未显示。
|
||||
|
||||
## 根因分析
|
||||
|
||||
1. **两套后台数据源不一致**
|
||||
- souladmin.quwanzhi.com 调用 soulapi.quwanzhi.com(Go API)
|
||||
- soul.quwanzhi.com/admin 使用 Next.js API,list 此前仅从 bookData(静态)读取
|
||||
- 新建章节写入数据库,但 list 不查库,导致新建内容不显示
|
||||
|
||||
2. **PUT 创建未完整支持 partId/chapterId**
|
||||
- 新建章节时 partId、chapterId、partTitle、chapterTitle 未正确写入数据库
|
||||
|
||||
## 已做修复
|
||||
|
||||
### 1. 修改 `/api/db/book` list 接口
|
||||
- **原逻辑**:仅从 bookData 读取
|
||||
- **现逻辑**:优先从数据库 chapters 表读取,再与 bookData 合并
|
||||
- **效果**:新建章节会立即出现在列表中
|
||||
|
||||
### 2. 修改 PUT 接口支持新建章节
|
||||
- 支持 body 传入 `partId`、`chapterId`、`partTitle`、`chapterTitle`、`isFree`
|
||||
- 新建章节能正确写入数据库
|
||||
|
||||
### 3. 在 book-data 中新增 9.15
|
||||
- 章节 ID: 9.15
|
||||
- 标题: 第102场|今年第一个红包你发给谁
|
||||
- 文件: book/第四篇|真实的赚钱/第9章|我在Soul上亲访的赚钱案例/9.15 第102场|今年第一个红包你发给谁.md
|
||||
|
||||
### 4. soul-admin 改用 soul.quwanzhi.com 作为 API
|
||||
- 修改 soul-admin 的 API 基址:soulapi → soul.quwanzhi.com
|
||||
- 在 Next.js 中为 souladmin.quwanzhi.com 配置 CORS
|
||||
|
||||
## 部署步骤
|
||||
|
||||
### 步骤 1:部署 soul 主站(小型宝塔)
|
||||
|
||||
```bash
|
||||
cd /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验
|
||||
# 按 .cursorrules 中的流程执行
|
||||
pnpm build
|
||||
# 然后执行部署脚本
|
||||
```
|
||||
|
||||
### 步骤 2:同步 9.15 到数据库
|
||||
|
||||
部署后访问 soul.quwanzhi.com/admin,在内容管理页面点击「同步到数据库」,将包含 9.15 的 bookData 同步进库。
|
||||
|
||||
### 步骤 3:部署修改后的 soul-admin(KR 宝塔)
|
||||
|
||||
```bash
|
||||
# 将 一场soul的创业实验-永平 中的 soul-admin/dist 上传到 KR 宝塔
|
||||
cd /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平
|
||||
tar -czf soul-admin-dist.tar.gz soul-admin/dist
|
||||
sshpass -p 'Zhiqun1984' scp -P 22022 soul-admin-dist.tar.gz root@43.139.27.93:/tmp/
|
||||
sshpass -p 'Zhiqun1984' ssh -p 22022 root@43.139.27.93 "
|
||||
cd /www/wwwroot/自营/soul-admin
|
||||
rm -rf dist.bak
|
||||
mv dist dist.bak 2>/dev/null || true
|
||||
tar -xzf /tmp/soul-admin-dist.tar.gz -C .
|
||||
rm /tmp/soul-admin-dist.tar.gz
|
||||
"
|
||||
```
|
||||
|
||||
### 步骤 4:校验
|
||||
|
||||
1. 打开 souladmin.quwanzhi.com/content
|
||||
2. 新建章节,确认创建后列表中立即出现
|
||||
3. 刷新 soul.quwanzhi.com 主站,确认新章节可读
|
||||
|
||||
## 注意事项
|
||||
|
||||
- souladmin 现改为调用 soul.quwanzhi.com,不再调用 soulapi(Go),需确保 soul 主站可用
|
||||
- 若仍需使用 Go API,需在 soul-api 源码中修复 list/create 逻辑
|
||||
|
||||
---
|
||||
|
||||
## 内容上传 API(供科室/Skill 调用)
|
||||
|
||||
- **地址**:`POST /api/content/upload`
|
||||
- **Content-Type**:`application/json`
|
||||
- **Body 字段**:
|
||||
- `title`(必填):节标题
|
||||
- `price`:定价,默认 1
|
||||
- `content`:正文(Markdown 或 HTML)
|
||||
- `format`:`markdown` | `html`,默认 `markdown`
|
||||
- `images`:图片 URL 数组;正文中可用 `{{image_0}}`、`{{image_1}}` 占位,会替换为对应图片的 Markdown 图链
|
||||
- `partId`、`partTitle`、`chapterId`、`chapterTitle`:归属篇/章,可选
|
||||
- `isFree`:是否免费,默认 false
|
||||
- `sectionId`:指定节 ID,不传则自动生成(如 `upload.标题slug.时间戳`)
|
||||
- **返回**:`{ success, id, message, title, price, isFree, wordCount }`
|
||||
- 写入数据库 `chapters` 表,list/目录会从库中读取并去重显示。
|
||||
75
开发文档/8、部署/小程序上传复盘_2026-02-23.md
Normal file
75
开发文档/8、部署/小程序上传复盘_2026-02-23.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# 小程序同步与上传复盘(2026-02-23)
|
||||
|
||||
## 目标 & 结果
|
||||
|
||||
- **目标**:将 GitHub 仓库 `fnvtk/Mycontent` 分支 `yongpxu-soul` 下 **miniprogram** 最新版同步到本地,并上传到微信公众平台(腾讯侧小程序后台)。
|
||||
- **结果**:本地已与 GitHub 最新版一致;小程序已成功上传至微信,版本号 **1.17**,描述为「从GitHub(yongpxu-soul)同步最新版」。
|
||||
|
||||
---
|
||||
|
||||
## 过程
|
||||
|
||||
1. **从 GitHub 拉取 miniprogram**
|
||||
- 克隆仓库:`git clone --depth 1 --branch yongpxu-soul https://github.com/fnvtk/Mycontent.git`(临时目录)。
|
||||
- 使用 `rsync -av --delete` 将 `Mycontent/miniprogram/` 覆盖到本地目录:
|
||||
`一场soul的创业实验/miniprogram/`。
|
||||
- 同步后删除临时克隆目录。
|
||||
|
||||
2. **本地上传能力整理**
|
||||
- 为 `miniprogram/上传小程序.py` 增加 **Mac 微信开发者工具 CLI** 路径:
|
||||
`/Applications/wechatwebdevtools.app/Contents/MacOS/cli`(及用户目录下的备用路径),便于在 Mac 上自动找到 CLI。
|
||||
- 执行 `python3 上传小程序.py` 时,因 **未配置 private.key**(密钥未入库、本机未放置),脚本在「检查上传密钥」步骤退出,未执行实际上传。
|
||||
|
||||
3. **改用微信开发者工具 CLI 直接上传**
|
||||
- 使用本机已安装的微信开发者工具 CLI,不依赖 private.key,执行:
|
||||
`cli upload --project <miniprogram 绝对路径> --version 1.17 --desc "从GitHub(yongpxu-soul)同步最新版"`。
|
||||
- CLI 自动完成连接/启动服务、拉取 AppID 权限、打包上传;上传成功,包体积约 259.9 KB。
|
||||
|
||||
---
|
||||
|
||||
## 本次更新内容(相对你之前本地的版本)
|
||||
|
||||
- **来源**:GitHub `fnvtk/Mycontent` 分支 `yongpxu-soul` 的 `miniprogram` 目录(与当前本地已一致)。
|
||||
- **主要结构**(与 README 一致):
|
||||
- **入口与配置**:`app.js`、`app.json`、`app.wxss`,`project.config.json`(AppID:wxb8bbb2b10dec74aa)、`sitemap.json`。
|
||||
- **页面**:首页、目录、找伙伴、我的、阅读、关于作者、推广中心、订单、设置、搜索(见 `app.json` pages)。
|
||||
- **能力**:自定义 TabBar、阅读/付费墙、分享海报、推广佣金、支付等;后端基地址 `https://soul.quwanzhi.com`。
|
||||
- **脚本与文档**:`上传小程序.py`、`upload.js`、`小程序快速配置指南.md`、`小程序部署说明.md`、`自动部署.sh`、`编译小程序.bat/.ps1` 等。
|
||||
- **脚本层面**:仅在 `上传小程序.py` 中新增 Mac 版微信开发者工具 CLI 路径,便于后续在 Mac 上一键上传(仍可选配 private.key 使用 Node/miniprogram-ci 方式)。
|
||||
|
||||
---
|
||||
|
||||
## 反思
|
||||
|
||||
- **private.key**:正确做法是不把密钥提交到 Git;本机若要用 `上传小程序.py` 或 `upload.js`(miniprogram-ci)上传,需在 [微信公众平台 → 开发管理 → 开发设置 → 小程序代码上传密钥] 下载密钥,重命名为 `private.key` 并放到 `miniprogram/` 目录。
|
||||
- **Mac 上传方式**:在未配置 private.key 的情况下,本机通过 **微信开发者工具 CLI** 直接上传可行(CLI 会启动或连接本地 IDE 服务完成上传),适合当前「同步 GitHub 后快速上传」的流程。
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
- 本地 **miniprogram** 已与 GitHub `yongpxu-soul` 最新版一致。
|
||||
- 小程序已上传至微信公众平台,**版本 1.17**;上传方式为本次使用的微信开发者工具 CLI(未使用 private.key)。
|
||||
- 后续如需继续用「脚本/CI」上传,可在 `miniprogram/` 下配置 `private.key` 后使用 `上传小程序.py` 或 `upload.js`;若仅本机上传,可继续使用 CLI 命令。
|
||||
|
||||
---
|
||||
|
||||
## 执行(后续建议)
|
||||
|
||||
1. **微信公众平台**
|
||||
- 登录 [mp.weixin.qq.com](https://mp.weixin.qq.com/) → 版本管理。
|
||||
- 确认开发版 **1.17** 已出现;如需给体验人员使用,可设为「选为体验版」;准备发正式版则「提交审核」。
|
||||
|
||||
2. **下次从 GitHub 同步后再上传**
|
||||
- 同步代码(同上 rsync 或你已有的脚本)。
|
||||
- 上传命令示例(在终端执行):
|
||||
```bash
|
||||
/Applications/wechatwebdevtools.app/Contents/MacOS/cli upload \
|
||||
--project "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram" \
|
||||
--version "1.18" \
|
||||
--desc "本次更新说明"
|
||||
```
|
||||
将 `1.18` 和 `本次更新说明` 按实际版本与描述修改即可。
|
||||
|
||||
3. **可选**
|
||||
- 若希望用 `上传小程序.py` 在 Mac 上一键上传,可将从公众平台下载的代码上传密钥重命名为 `private.key` 放入 `miniprogram/`,再运行 `python3 上传小程序.py`。
|
||||
98
开发文档/8、部署/本机运行文档.md
Normal file
98
开发文档/8、部署/本机运行文档.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Soul 主站 · 本机运行文档
|
||||
|
||||
> 主项目(一场soul的创业实验)本机与服务器运行说明。永平版多服务架构见「永平版优化对比与合并说明」中的本机运行文档参考。
|
||||
|
||||
---
|
||||
|
||||
## 一、主项目运行架构(单 Next 站)
|
||||
|
||||
### 1.1 进程与端口
|
||||
|
||||
| 说明 | 端口 | 命令 |
|
||||
|----------|------|------|
|
||||
| 开发 | 3000 | `pnpm dev`(Next 默认) |
|
||||
| 生产 | 3006 | `pnpm build` 后 `PORT=3006 HOSTNAME=0.0.0.0 node .next/standalone/server.js` |
|
||||
|
||||
### 1.2 目录与部署
|
||||
|
||||
- **本地开发**:根目录即 Next 源码,`app/`、`lib/`、`components/`、`book/`、`miniprogram/` 同层。
|
||||
- **生产部署**:小型宝塔 42.194.232.22,项目路径 `/www/wwwroot/soul`,PM2 进程名 `soul`,端口 3006。
|
||||
|
||||
---
|
||||
|
||||
## 二、本机运行步骤
|
||||
|
||||
### 2.1 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2.2 开发模式
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- 默认端口 3000,可在 `package.json` 或环境变量中指定 `PORT=3006`。
|
||||
- 访问:http://localhost:3000(或 http://localhost:3006)
|
||||
|
||||
### 2.3 生产模式(本地模拟)
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
PORT=3006 HOSTNAME=0.0.0.0 node .next/standalone/server.js
|
||||
```
|
||||
|
||||
- 需先完成 `pnpm build`,standalone 输出在 `.next/standalone/`。
|
||||
- 环境变量:`.env.local` 中配置 `MYSQL_*`(可选)、`SKIP_DB`(本地无 DB 时可设 `SKIP_DB=1`,部分接口会报错,适合纯前端联调)。
|
||||
|
||||
### 2.4 数据库
|
||||
|
||||
- 默认使用腾讯云 MySQL(见 `lib/db.ts` 默认值)。
|
||||
- 本地无数据库时:设置 `SKIP_DB=1`,接口中依赖 DB 的会抛错,可配合 Mock 或仅跑静态页。
|
||||
- 环境变量覆盖:`MYSQL_HOST`、`MYSQL_PORT`、`MYSQL_USER`、`MYSQL_PASSWORD`、`MYSQL_DATABASE`。
|
||||
|
||||
---
|
||||
|
||||
## 三、关键配置
|
||||
|
||||
### 3.1 环境变量(.env.local)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE | 数据库连接,不设则用代码默认值 |
|
||||
| SKIP_DB | 设为 1 或 true 时跳过 DB 连接,适合无 DB 环境 |
|
||||
| ADMIN_USERNAME / ADMIN_PASSWORD | 后台管理员账号密码(默认 admin / key123456) |
|
||||
| ADMIN_SESSION_SECRET | 管理员 Cookie 签名密钥(生产建议修改) |
|
||||
|
||||
### 3.2 管理后台
|
||||
|
||||
- 登录:http://localhost:3000/admin/login(开发)或 /admin/login(生产)
|
||||
- 默认账号:admin / key123456(与 .cursorrules 一致,可通过环境变量覆盖)
|
||||
- 登出 API:`POST /api/admin/logout`(清除管理员 Cookie,可与「退出登录」按钮对接)
|
||||
|
||||
---
|
||||
|
||||
## 四、与永平版差异
|
||||
|
||||
- **永平版**:多服务(Go API 8080、Vue 管理后台 5174、Next 主站 3006),见永平根目录 `本机运行文档.md`。
|
||||
- **本主项目**:单 Next 应用,无独立 Go/Vue,管理后台为 Next 内 `/admin`,API 为 Next 内 `/api/*`。
|
||||
- CORS:主项目在 `middleware.ts` 与 `next.config.mjs` 中配置;永平可能由 Nginx/Go 处理。
|
||||
|
||||
---
|
||||
|
||||
## 五、常见问题
|
||||
|
||||
1. **端口被占用**
|
||||
修改启动命令:`PORT=3007 pnpm dev` 或 `PORT=3007 node .next/standalone/server.js`
|
||||
|
||||
2. **数据库连接失败**
|
||||
检查 `.env.local` 中 `MYSQL_*` 及本机网络是否能访问腾讯云 MySQL;或设 `SKIP_DB=1` 做无 DB 联调。
|
||||
|
||||
3. **API 跨域**
|
||||
主项目已通过 `middleware.ts` 为 `/api/:path*` 设置 CORS,允许来源见 `ALLOWED_ORIGINS`。
|
||||
|
||||
---
|
||||
|
||||
**文档状态**:适用于主项目单站部署与本机开发;多服务架构以永平版文档为准。
|
||||
84
开发文档/SKILL.md
Normal file
84
开发文档/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Soul创业派对 - 项目开发SKILL
|
||||
|
||||
## 项目概述
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| 项目名 | Soul创业派对(一场Soul的创业实验) |
|
||||
| 小程序AppID | wxb8bbb2b10dec74aa |
|
||||
| 后端地址 | https://soul.quwanzhi.com |
|
||||
| 技术栈(前端) | 微信小程序原生 + 自定义TabBar |
|
||||
| 技术栈(后端) | Next.js App Router + MySQL |
|
||||
| 数据库 | 腾讯云MySQL |
|
||||
| 仓库 | github.com/fnvtk/Mycontent (yongpxu-soul分支) |
|
||||
|
||||
## 开发文档目录索引
|
||||
|
||||
```
|
||||
开发文档/
|
||||
├── 1、需求/ ← 需求文档、需求日志、TDD方案
|
||||
├── 2、架构/ ← 系统架构、技术选型、链路说明
|
||||
├── 3、原型/ ← 原型设计
|
||||
├── 4、前端/ ← 前端架构、UI截图
|
||||
├── 5、接口/ ← API接口文档、接口定义规范
|
||||
├── 6、后端/ ← 后端架构、修复说明
|
||||
├── 7、数据库/ ← 数据库设计、管理规范
|
||||
├── 8、部署/ ← 部署流程、宝塔配置、小程序上传
|
||||
├── 9、手册/ ← 使用手册、写作手册
|
||||
├── 10、项目管理/ ← 项目总览、运营报表、会议记录
|
||||
├── 小程序管理/ ← 小程序生命周期SKILL(独立)
|
||||
└── 服务器管理/ ← 服务器运维SKILL(独立)
|
||||
```
|
||||
|
||||
## 需求日志管理规范
|
||||
|
||||
- 每次对话的需求自动追加到 `1、需求/需求日志.md`
|
||||
- 格式:`| 日期 | 需求描述 | 状态 | 备注 |`
|
||||
- 状态:待开发 / 开发中 / 已完成 / 已取消
|
||||
- 每个版本上传后,将该批需求标记为「已完成」
|
||||
|
||||
## 常用命令
|
||||
|
||||
### 上传小程序
|
||||
```bash
|
||||
/Applications/wechatwebdevtools.app/Contents/MacOS/cli upload \
|
||||
--project "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram" \
|
||||
--version "版本号" --desc "版本说明"
|
||||
```
|
||||
|
||||
### 从GitHub同步miniprogram
|
||||
```bash
|
||||
cd /tmp && rm -rf Mycontent_soul_tmp
|
||||
git clone --depth 1 --branch yongpxu-soul https://github.com/fnvtk/Mycontent.git Mycontent_soul_tmp
|
||||
rsync -av --delete Mycontent_soul_tmp/miniprogram/ "/Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验/miniprogram/"
|
||||
rm -rf Mycontent_soul_tmp
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
```bash
|
||||
curl -X POST https://soul.quwanzhi.com/api/db/migrate -H 'Content-Type: application/json' -d '{}'
|
||||
```
|
||||
|
||||
## 核心页面结构
|
||||
|
||||
| 页面 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| 首页 | pages/index/index | 精选推荐(阅读量)、创业老板排行 |
|
||||
| 目录 | pages/chapters/chapters | 章节列表 |
|
||||
| 找伙伴 | pages/match/match | 匹配动画 |
|
||||
| 我的 | pages/my/my | 用户信息、收益、VIP、账号设置 |
|
||||
| 阅读 | pages/read/read | 章节内容、付费墙 |
|
||||
| VIP | pages/vip/vip | VIP权益、购买、资料填写 |
|
||||
| 会员详情 | pages/member-detail/member-detail | 创业老板排行点击详情 |
|
||||
|
||||
## 后端API模块
|
||||
|
||||
| 模块 | 路径前缀 | 说明 |
|
||||
|------|---------|------|
|
||||
| VIP会员 | /api/vip/ | purchase、status、profile、members |
|
||||
| 书籍 | /api/book/ | chapters、hot、latest-chapters、search |
|
||||
| 用户 | /api/user/ | profile、update、track |
|
||||
| 支付 | /api/miniprogram/pay | 微信小程序支付 |
|
||||
| 推广 | /api/referral/ | bind、data、visit |
|
||||
| 提现 | /api/withdraw | 提现到微信零钱 |
|
||||
| 管理后台 | /api/admin/ | content、chapters、payment等 |
|
||||
52
部署到GitHub与宝塔.sh
Executable file
52
部署到GitHub与宝塔.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# 1) 以本地为准推送到 GitHub yongpxu-soul
|
||||
# 2) 打包 → SCP 上传 → SSH 解压并 pnpm install + build
|
||||
# 3) 使用宝塔 API 重启 Node 项目(不用 pm2 命令)
|
||||
# 在「一场soul的创业实验」目录下执行
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "===== 1. 推送到 GitHub(以本地为准)====="
|
||||
git push origin yongpxu-soul --force-with-lease
|
||||
|
||||
echo "===== 2. 打包 ====="
|
||||
tar --exclude='node_modules' --exclude='.next' --exclude='.git' -czf /tmp/soul_update.tar.gz .
|
||||
|
||||
echo "===== 3. 上传到宝塔服务器 ====="
|
||||
sshpass -p 'Zhiqun1984' scp /tmp/soul_update.tar.gz root@42.194.232.22:/tmp/
|
||||
|
||||
echo "===== 4. SSH:解压、安装、构建(不执行 pm2)====="
|
||||
sshpass -p 'Zhiqun1984' ssh root@42.194.232.22 "
|
||||
cd /www/wwwroot/soul
|
||||
rm -rf app components lib public styles *.json *.js *.ts *.mjs *.md .next
|
||||
tar -xzf /tmp/soul_update.tar.gz
|
||||
rm /tmp/soul_update.tar.gz
|
||||
export PATH=/www/server/nodejs/v22.14.0/bin:\$PATH
|
||||
pnpm install
|
||||
pnpm run build
|
||||
"
|
||||
|
||||
echo "===== 5. 宝塔 API 重启 Node 项目 soul ====="
|
||||
BT_HOST="42.194.232.22"
|
||||
BT_PORT="9988"
|
||||
BT_KEY="hsAWqFSi0GOCrunhmYdkxy92tBXfqYjd"
|
||||
REQUEST_TIME=$(date +%s)
|
||||
# request_token = md5( request_time + md5(api_key) ),兼容 macOS/Linux
|
||||
md5hex() { printf '%s' "$1" | openssl md5 2>/dev/null | awk '{print $NF}' || true; }
|
||||
MD5_KEY=$(md5hex "$BT_KEY")
|
||||
SIGN_STR="${REQUEST_TIME}${MD5_KEY}"
|
||||
REQUEST_TOKEN=$(md5hex "$SIGN_STR")
|
||||
|
||||
RESP=$(curl -s -k -X POST "https://${BT_HOST}:${BT_PORT}/project/nodejs/restart_project" \
|
||||
-d "request_time=${REQUEST_TIME}" \
|
||||
-d "request_token=${REQUEST_TOKEN}" \
|
||||
-d "project_name=soul" 2>/dev/null || true)
|
||||
|
||||
if echo "$RESP" | grep -q '"status":true\|"status": true'; then
|
||||
echo "宝塔 API 重启成功: $RESP"
|
||||
else
|
||||
echo "宝塔 API 返回(若失败请到面板手动重启): $RESP"
|
||||
fi
|
||||
|
||||
echo "===== 部署完成 ====="
|
||||
52
部署到Kr宝塔.sh
Executable file
52
部署到Kr宝塔.sh
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# 部署到 Kr宝塔 (43.139.27.93):打包 → SCP(端口22022) → SSH 解压构建 → 宝塔 API 重启
|
||||
# 不用 pm2 命令,用宝塔 API 操作。在「一场soul的创业实验」目录下执行。
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
SSH_PORT="22022"
|
||||
BT_HOST="43.139.27.93"
|
||||
BT_PORT="9988"
|
||||
BT_KEY="qcWubCdlfFjS2b2DMT1lzPFaDfmv1cBT"
|
||||
PROJECT_PATH="/www/wwwroot/soul"
|
||||
PROJECT_NAME="soul"
|
||||
|
||||
echo "===== 1. 打包 ====="
|
||||
tar --exclude='node_modules' --exclude='.next' --exclude='.git' -czf /tmp/soul_update.tar.gz .
|
||||
|
||||
echo "===== 2. 上传到 Kr宝塔 (${BT_HOST}:${SSH_PORT}) ====="
|
||||
sshpass -p 'Zhiqun1984' scp -P "$SSH_PORT" /tmp/soul_update.tar.gz root@${BT_HOST}:/tmp/
|
||||
|
||||
echo "===== 3. SSH:解压、安装、构建(不执行 pm2)====="
|
||||
sshpass -p 'Zhiqun1984' ssh -p "$SSH_PORT" root@${BT_HOST} "
|
||||
mkdir -p ${PROJECT_PATH}
|
||||
cd ${PROJECT_PATH}
|
||||
rm -rf app components lib public styles *.json *.js *.ts *.mjs *.md .next
|
||||
tar -xzf /tmp/soul_update.tar.gz
|
||||
rm /tmp/soul_update.tar.gz
|
||||
export PATH=/www/server/nodejs/v22.14.0/bin:\$PATH
|
||||
[ -x \"\$(command -v pnpm)\" ] || npm i -g pnpm
|
||||
pnpm install
|
||||
pnpm run build
|
||||
"
|
||||
|
||||
echo "===== 4. 宝塔 API 重启 Node 项目 ${PROJECT_NAME} ====="
|
||||
REQUEST_TIME=$(date +%s)
|
||||
md5hex() { printf '%s' "$1" | openssl md5 2>/dev/null | awk '{print $NF}' || true; }
|
||||
MD5_KEY=$(md5hex "$BT_KEY")
|
||||
SIGN_STR="${REQUEST_TIME}${MD5_KEY}"
|
||||
REQUEST_TOKEN=$(md5hex "$SIGN_STR")
|
||||
|
||||
RESP=$(curl -s -k -X POST "https://${BT_HOST}:${BT_PORT}/project/nodejs/restart_project" \
|
||||
-d "request_time=${REQUEST_TIME}" \
|
||||
-d "request_token=${REQUEST_TOKEN}" \
|
||||
-d "project_name=${PROJECT_NAME}" 2>/dev/null || true)
|
||||
|
||||
if echo "$RESP" | grep -q '"status":true\|"status": true'; then
|
||||
echo "宝塔 API 重启成功: $RESP"
|
||||
else
|
||||
echo "宝塔 API 返回(若失败请到面板 网站→Node项目→${PROJECT_NAME}→重启): $RESP"
|
||||
fi
|
||||
|
||||
echo "===== 部署到 Kr宝塔 完成 ====="
|
||||
Reference in New Issue
Block a user