v1.19 全面改版:VIP会员系统、我的收益、创业老板排行、阅读量排序
- 后端: users表新增VIP字段, 4个VIP API (purchase/status/profile/members) - 后端: hot接口改按user_tracks阅读量排序 - 后端: orders表支持vip产品类型, migrate新增vip_fields迁移 - 小程序「我的」: 推广中心改为我的收益, 头像VIP标识, VIP入口卡片 - 小程序「我的」: 最近阅读显示真实章节名称 - 小程序首页: 去掉内容概览, 新增创业老板排行(4列网格) - 小程序首页: 精选推荐从hot接口获取, goToRead增加track记录 - 新增页面: VIP详情页, 会员详情页 - 开发文档精简为10个标准目录, 创建SKILL.md, 需求日志规范化 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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秒生效
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,13 @@ export async function initDatabase() {
|
||||
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('用户表初始化完成')
|
||||
|
||||
@@ -153,7 +160,7 @@ export async function initDatabase() {
|
||||
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),
|
||||
|
||||
@@ -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,84 +1,57 @@
|
||||
/**
|
||||
* 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: '未来职业与商业生态' }
|
||||
],
|
||||
|
||||
// 加载状态
|
||||
|
||||
// 创业老板排行
|
||||
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()
|
||||
await this.loadLatestSection()
|
||||
await Promise.all([
|
||||
this.loadBookData(),
|
||||
this.loadLatestSection(),
|
||||
this.loadHotSections(),
|
||||
this.loadVipMembers()
|
||||
])
|
||||
} catch (e) {
|
||||
console.error('初始化失败:', e)
|
||||
this.computeLatestSectionFallback()
|
||||
@@ -87,121 +60,107 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 从后端获取最新章节(2日内有新章取最新3章,否则随机免费章)
|
||||
// 从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)
|
||||
}
|
||||
},
|
||||
|
||||
// 加载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 && res.success && res.banner) {
|
||||
this.setData({
|
||||
latestSection: res.banner,
|
||||
latestLabel: res.label || '最新更新'
|
||||
})
|
||||
if (res?.success && res.banner) {
|
||||
this.setData({ latestSection: res.banner, latestLabel: res.label || '最新更新' })
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('latest-chapters API 失败,使用兜底逻辑:', e.message)
|
||||
console.warn('latest-chapters API 失败:', e.message)
|
||||
}
|
||||
this.computeLatestSectionFallback()
|
||||
},
|
||||
|
||||
// 兜底:API 失败时从 bookData 计算(随机选免费章节)
|
||||
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,
|
||||
id: s.id, title: s.title || s.sectionTitle,
|
||||
part: s.part || s.sectionTitle || '真实的行业',
|
||||
isFree: s.isFree,
|
||||
price: s.price
|
||||
isFree: s.isFree, price: s.price
|
||||
}))
|
||||
} else if (bookData && typeof bookData === 'object') {
|
||||
const parts = bookData.parts || (Array.isArray(bookData) ? bookData : [])
|
||||
if (Array.isArray(parts)) {
|
||||
parts.forEach(p => {
|
||||
(p.chapters || p.sections || []).forEach(c => {
|
||||
(c.sections || [c]).forEach(s => {
|
||||
sections.push({
|
||||
id: s.id,
|
||||
title: s.title || s.section_title,
|
||||
part: p.title || p.part_title || c.title || '',
|
||||
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 === 0) {
|
||||
if (!candidates.length) {
|
||||
this.setData({ latestSection: { id: '1.1', title: '开始阅读', part: '真实的人' }, latestLabel: '为你推荐' })
|
||||
return
|
||||
}
|
||||
const idx = Math.floor(Math.random() * candidates.length)
|
||||
const selected = { id: candidates[idx].id, title: candidates[idx].title, part: candidates[idx].part || '真实的行业' }
|
||||
this.setData({ latestSection: selected, latestLabel: '为你推荐' })
|
||||
this.setData({ latestSection: candidates[idx], latestLabel: '为你推荐' })
|
||||
},
|
||||
|
||||
// 加载书籍数据(含精选推荐,按后端点击量排序)
|
||||
async loadBookData() {
|
||||
try {
|
||||
const res = await app.request('/api/book/all-chapters')
|
||||
if (res && res.data) {
|
||||
const setData = {
|
||||
if (res?.data) {
|
||||
this.setData({
|
||||
bookData: res.data,
|
||||
totalSections: res.totalSections || res.data?.length || 62
|
||||
}
|
||||
if (res.featuredSections && res.featuredSections.length) {
|
||||
setData.featuredSections = res.featuredSections
|
||||
}
|
||||
this.setData(setData)
|
||||
})
|
||||
}
|
||||
} 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,20 +18,14 @@
|
||||
<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">{{latestLabel}}</view>
|
||||
@@ -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>
|
||||
<!-- 创业老板排行(替代内容概览) -->
|
||||
<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,45 +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' }
|
||||
],
|
||||
|
||||
// 登录弹窗
|
||||
|
||||
showLoginModal: false,
|
||||
isLoggingIn: false
|
||||
},
|
||||
@@ -49,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,
|
||||
@@ -90,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: '修改昵称',
|
||||
@@ -167,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) {
|
||||
@@ -240,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) {
|
||||
@@ -268,39 +234,26 @@ 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'
|
||||
}
|
||||
|
||||
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] })
|
||||
},
|
||||
|
||||
// 跳转VIP页面
|
||||
goToVip() {
|
||||
if (!this.data.isLoggedIn) { this.showLogin(); return }
|
||||
wx.navigateTo({ url: '/pages/vip/vip' })
|
||||
},
|
||||
|
||||
// 绑定微信号
|
||||
bindWechat() {
|
||||
wx.showModal({
|
||||
title: '绑定微信号',
|
||||
editable: true,
|
||||
placeholderText: '请输入微信号',
|
||||
title: '绑定微信号', editable: true, placeholderText: '请输入微信号',
|
||||
success: async (res) => {
|
||||
if (res.confirm && res.content) {
|
||||
const wechat = res.content.trim()
|
||||
@@ -313,24 +266,18 @@ Page({
|
||||
app.globalData.userInfo = userInfo
|
||||
wx.setStorageSync('userInfo', userInfo)
|
||||
await app.request('/api/user/update', {
|
||||
method: 'POST',
|
||||
data: { userId: userInfo.id, wechat }
|
||||
method: 'POST', data: { userId: userInfo.id, wechat }
|
||||
})
|
||||
wx.showToast({ title: '绑定成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
console.log('绑定微信号失败', e)
|
||||
wx.showToast({ title: '已保存到本地', icon: 'success' })
|
||||
}
|
||||
} catch (e) { wx.showToast({ title: '已保存到本地', icon: 'success' }) }
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 清除缓存
|
||||
clearCache() {
|
||||
wx.showModal({
|
||||
title: '清除缓存',
|
||||
content: '确定要清除本地缓存吗?不会影响账号数据',
|
||||
title: '清除缓存', content: '确定要清除本地缓存吗?不会影响账号数据',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const userInfo = wx.getStorageSync('userInfo')
|
||||
@@ -344,41 +291,14 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 跳转到阅读页
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
|
||||
},
|
||||
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' }) },
|
||||
|
||||
// 跳转到目录
|
||||
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()
|
||||
@@ -389,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,62 +53,64 @@
|
||||
<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="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>
|
||||
@@ -135,12 +132,8 @@
|
||||
|
||||
<!-- 足迹内容 -->
|
||||
<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>
|
||||
@@ -160,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>
|
||||
@@ -188,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>
|
||||
@@ -202,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>
|
||||
|
||||
@@ -1042,3 +1042,94 @@
|
||||
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",
|
||||
|
||||
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
|
||||
@@ -21,6 +21,14 @@ 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;
|
||||
|
||||
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
|
||||
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}`)
|
||||
})
|
||||
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标识,头像灰色/金色区分);后台新增会员管理;确保小程序与后端匹配正常使用。
|
||||
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`。
|
||||
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等 |
|
||||
Reference in New Issue
Block a user