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:
卡若
2026-02-23 14:07:41 +08:00
parent e91a5d9f7a
commit afc2376e96
49 changed files with 1898 additions and 561 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),

View File

@@ -9,7 +9,9 @@
"pages/referral/referral",
"pages/purchases/purchases",
"pages/settings/settings",
"pages/search/search"
"pages/search/search",
"pages/vip/vip",
"pages/member-detail/member-detail"
],
"window": {
"backgroundTextStyle": "light",
@@ -52,10 +54,9 @@
}
},
"requiredPrivateInfos": [
"getLocation",
"chooseAddress"
"getLocation"
],
"lazyCodeLoading": "requiredComponents",
"style": "v2",
"sitemapLocation": "sitemap.json"
}
}

View File

@@ -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()

View File

@@ -1,16 +1,11 @@
<!--pages/index/index.wxml-->
<!--Soul创业派对 - 首页 1:1还原Web版本-->
<!--Soul创业派对 - 首页-->
<view class="page page-transition">
<!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部区域 -->
<view class="header" style="padding-top: {{statusBarHeight}}px;">
<view class="header-content">
<view class="logo-section">
<view class="logo-icon">
<text class="logo-text">S</text>
</view>
<view class="logo-icon"><text class="logo-text">S</text></view>
<view class="logo-info">
<view class="logo-title">
<text class="text-white">Soul</text>
@@ -23,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,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() {}
})

View File

@@ -1,17 +1,13 @@
<!--pages/my/my.wxml-->
<!--Soul创业实验 - 我的页面 1:1还原Web版本-->
<!--Soul创业实验 - 我的页面-->
<view class="page page-transition">
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<text class="nav-title brand-color">我的</text>
</view>
</view>
<!-- 导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 用户卡片 - 未登录状态 - 只显示登录提示 -->
<!-- 未登录 -->
<view class="user-card card-gradient login-card" wx:if="{{!isLoggedIn}}">
<view class="login-prompt">
<view class="login-icon-large">🔐</view>
@@ -24,22 +20,21 @@
</view>
</view>
<!-- 用户卡片 - 已登录状态 -->
<!-- 已登录 - 用户卡片 -->
<view class="user-card card-gradient" wx:else>
<view class="user-header-row">
<!-- 头像 - 点击选择头像 -->
<button class="avatar-btn-simple" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar">
<view class="avatar">
<view class="avatar {{isVip ? 'avatar-vip' : 'avatar-normal'}}">
<image class="avatar-img" wx:if="{{userInfo.avatar}}" src="{{userInfo.avatar}}" mode="aspectFill"/>
<text class="avatar-text" wx:else>{{userInfo.nickname[0] || '微'}}</text>
<view class="vip-badge" wx:if="{{isVip}}">VIP</view>
</view>
</button>
<!-- 用户信息 -->
<view class="user-info-block">
<view class="user-name-row">
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
<text class="edit-icon-small">✎</text>
<view class="vip-tag" wx:if="{{isVip}}">创业伙伴</view>
</view>
<view class="user-id-row" bindtap="copyUserId">
<text class="user-id">{{userWechat ? '微信: ' + userWechat : 'ID: ' + userIdShort}}</text>
@@ -47,7 +42,7 @@
</view>
</view>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value brand-color">{{purchasedCount}}</text>
@@ -58,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>

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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;

View File

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

42
scripts/upload_soul_article.sh Executable file
View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,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`AppIDwxb8bbb2b10dec74aa`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
View 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等 |