feat: 完善后台管理+搜索功能+分销系统

主要更新:
- 后台菜单精简(9项→6项)
- 新增搜索功能(敏感信息过滤)
- 分销绑定和提现系统完善
- 数据库初始化API(自动修复表结构)
- 用户管理:显示绑定关系详情
- 小程序:上下章导航优化、匹配页面重构
- 修复hydration和数据类型问题
This commit is contained in:
卡若
2026-01-25 19:37:59 +08:00
parent 65d2831a45
commit 4dd2f9f4a7
49 changed files with 5921 additions and 636 deletions

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useRef } from "react" import { useState, useRef, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -26,6 +26,10 @@ import {
Upload, Upload,
Eye, Eye,
Database, Database,
Plus,
Image as ImageIcon,
Trash2,
Search,
} from "lucide-react" } from "lucide-react"
interface EditingSection { interface EditingSection {
@@ -34,6 +38,9 @@ interface EditingSection {
price: number price: number
content?: string content?: string
filePath?: string filePath?: string
isNew?: boolean
partId?: string
chapterId?: string
} }
export default function ContentPage() { export default function ContentPage() {
@@ -46,9 +53,26 @@ export default function ContentPage() {
const [feishuDocUrl, setFeishuDocUrl] = useState("") const [feishuDocUrl, setFeishuDocUrl] = useState("")
const [showFeishuModal, setShowFeishuModal] = useState(false) const [showFeishuModal, setShowFeishuModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false) const [showImportModal, setShowImportModal] = useState(false)
const [showNewSectionModal, setShowNewSectionModal] = useState(false)
const [importData, setImportData] = useState("") const [importData, setImportData] = useState("")
const [isLoadingContent, setIsLoadingContent] = useState(false) const [isLoadingContent, setIsLoadingContent] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [searchResults, setSearchResults] = useState<any[]>([])
const [isSearching, setIsSearching] = useState(false)
const [uploadingImage, setUploadingImage] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const imageInputRef = useRef<HTMLInputElement>(null)
// 新建章节表单
const [newSection, setNewSection] = useState({
id: "",
title: "",
price: 1,
partId: "part-1",
chapterId: "chapter-1",
content: "",
})
const togglePart = (partId: string) => { const togglePart = (partId: string) => {
setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId])) setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId]))
@@ -69,8 +93,8 @@ export default function ContentPage() {
if (data.success) { if (data.success) {
setEditingSection({ setEditingSection({
id: section.id, id: section.id,
title: section.title, title: data.section.title || section.title,
price: section.price, price: data.section.price || section.price,
content: data.section.content || "", content: data.section.content || "",
filePath: section.filePath, filePath: section.filePath,
}) })
@@ -103,6 +127,7 @@ export default function ContentPage() {
const handleSaveSection = async () => { const handleSaveSection = async () => {
if (!editingSection) return if (!editingSection) return
setIsSaving(true)
try { try {
const res = await fetch('/api/db/book', { const res = await fetch('/api/db/book', {
method: 'PUT', method: 'PUT',
@@ -126,6 +151,110 @@ export default function ContentPage() {
} catch (error) { } catch (error) {
console.error("Save section error:", error) console.error("Save section error:", error)
alert("保存失败") alert("保存失败")
} finally {
setIsSaving(false)
}
}
// 创建新章节
const handleCreateSection = async () => {
if (!newSection.id || !newSection.title) {
alert("请填写章节ID和标题")
return
}
setIsSaving(true)
try {
const res = await fetch('/api/db/book', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: newSection.id,
title: newSection.title,
price: newSection.price,
content: newSection.content,
partId: newSection.partId,
chapterId: newSection.chapterId,
saveToFile: false, // 新建章节暂不保存到文件系统
})
})
const data = await res.json()
if (data.success) {
alert(`章节创建成功: ${newSection.title}`)
setShowNewSectionModal(false)
setNewSection({ id: "", title: "", price: 1, partId: "part-1", chapterId: "chapter-1", content: "" })
} else {
alert("创建失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Create section error:", error)
alert("创建失败")
} finally {
setIsSaving(false)
}
}
// 上传图片
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingImage(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'book-images')
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
})
const data = await res.json()
if (data.success) {
// 插入图片Markdown到内容
const imageMarkdown = `![${file.name}](${data.data.url})`
if (editingSection) {
setEditingSection({
...editingSection,
content: (editingSection.content || '') + '\n\n' + imageMarkdown
})
}
alert(`图片上传成功: ${data.data.url}`)
} else {
alert("上传失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Image upload error:", error)
alert("上传失败")
} finally {
setUploadingImage(false)
if (imageInputRef.current) {
imageInputRef.current.value = ''
}
}
}
// 搜索内容
const handleSearch = async () => {
if (!searchQuery.trim()) return
setIsSearching(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
const data = await res.json()
if (data.success) {
setSearchResults(data.data.results || [])
} else {
alert("搜索失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Search error:", error)
alert("搜索失败")
} finally {
setIsSearching(false)
} }
} }
@@ -297,11 +426,15 @@ export default function ContentPage() {
setIsInitializing(true) setIsInitializing(true)
try { try {
const res = await fetch('/api/db/init', { method: 'POST' }) const res = await fetch('/api/db/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adminToken: 'init_db_2025' })
})
const data = await res.json() const data = await res.json()
if (data.success) { if (data.success) {
alert(data.message) alert(data.data?.message || '初始化成功')
} else { } else {
alert("初始化失败: " + (data.error || "未知错误")) alert("初始化失败: " + (data.error || "未知错误"))
} }
@@ -506,6 +639,116 @@ export default function ContentPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 新建章节弹窗 */}
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Plus className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">ID *</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如: 9.15"
value={newSection.id}
onChange={(e) => setNewSection({ ...newSection, id: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> ()</Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={newSection.price}
onChange={(e) => setNewSection({ ...newSection, price: Number(e.target.value) })}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> *</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="输入章节标题"
value={newSection.title}
onChange={(e) => setNewSection({ ...newSection, title: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select value={newSection.partId} onValueChange={(v) => setNewSection({ ...newSection, partId: v })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
{bookData.map((part) => (
<SelectItem key={part.id} value={part.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
{part.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Select value={newSection.chapterId} onValueChange={(v) => setNewSection({ ...newSection, chapterId: v })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
{bookData.find(p => p.id === newSection.partId)?.chapters.map((ch) => (
<SelectItem key={ch.id} value={ch.id} className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
{ch.title}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> (Markdown格式)</Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[300px] font-mono text-sm placeholder:text-gray-500"
placeholder="输入章节内容..."
value={newSection.content}
onChange={(e) => setNewSection({ ...newSection, content: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewSectionModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleCreateSection}
disabled={isSaving || !newSection.id || !newSection.title}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 章节编辑弹窗 */} {/* 章节编辑弹窗 */}
<Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}> <Dialog open={!!editingSection} onOpenChange={() => setEditingSection(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-y-auto"> <DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -527,11 +770,12 @@ export default function ContentPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-gray-300"></Label> <Label className="text-gray-300"> ()</Label>
<Input <Input
className="bg-[#0a1628] border-gray-700 text-white text-sm" type="number"
value={editingSection.filePath || ""} className="bg-[#0a1628] border-gray-700 text-white"
disabled value={editingSection.price}
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
/> />
</div> </div>
</div> </div>
@@ -543,17 +787,43 @@ export default function ContentPage() {
onChange={(e) => setEditingSection({ ...editingSection, title: e.target.value })} onChange={(e) => setEditingSection({ ...editingSection, title: e.target.value })}
/> />
</div> </div>
{editingSection.filePath && (
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-gray-400 text-sm"
value={editingSection.filePath}
disabled
/>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-gray-300"> ()</Label> <div className="flex items-center justify-between">
<Input <Label className="text-gray-300"> (Markdown格式)</Label>
type="number" <div className="flex gap-2">
className="bg-[#0a1628] border-gray-700 text-white w-32" <input
value={editingSection.price} ref={imageInputRef}
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })} type="file"
/> accept="image/*"
</div> onChange={handleImageUpload}
<div className="space-y-2"> className="hidden"
<Label className="text-gray-300"> (Markdown格式)</Label> />
<Button
variant="outline"
size="sm"
onClick={() => imageInputRef.current?.click()}
disabled={uploadingImage}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
{uploadingImage ? (
<RefreshCw className="w-4 h-4 mr-1 animate-spin" />
) : (
<ImageIcon className="w-4 h-4 mr-1" />
)}
</Button>
</div>
</div>
{isLoadingContent ? ( {isLoadingContent ? (
<div className="bg-[#0a1628] border border-gray-700 rounded-md min-h-[400px] flex items-center justify-center"> <div className="bg-[#0a1628] border border-gray-700 rounded-md min-h-[400px] flex items-center justify-center">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /> <RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
@@ -579,9 +849,22 @@ export default function ContentPage() {
<X className="w-4 h-4 mr-2" /> <X className="w-4 h-4 mr-2" />
</Button> </Button>
<Button onClick={handleSaveSection} className="bg-[#38bdac] hover:bg-[#2da396] text-white"> <Button
<Save className="w-4 h-4 mr-2" /> onClick={handleSaveSection}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -596,6 +879,13 @@ export default function ContentPage() {
<BookOpen className="w-4 h-4 mr-2" /> <BookOpen className="w-4 h-4 mr-2" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="search"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
>
<Search className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger <TabsTrigger
value="hooks" value="hooks"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
@@ -606,6 +896,15 @@ export default function ContentPage() {
</TabsList> </TabsList>
<TabsContent value="chapters" className="space-y-4"> <TabsContent value="chapters" className="space-y-4">
{/* 新建章节按钮 */}
<Button
onClick={() => setShowNewSectionModal(true)}
className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
{bookData.map((part, partIndex) => ( {bookData.map((part, partIndex) => (
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden"> <Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
<CardHeader <CardHeader
@@ -679,6 +978,66 @@ export default function ContentPage() {
))} ))}
</TabsContent> </TabsContent>
<TabsContent value="search" className="space-y-4">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 flex-1"
placeholder="搜索标题或内容..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button
onClick={handleSearch}
disabled={isSearching || !searchQuery.trim()}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSearching ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
</Button>
</div>
{/* 搜索结果 */}
{searchResults.length > 0 && (
<div className="space-y-2 mt-4">
<p className="text-gray-400 text-sm"> {searchResults.length} </p>
{searchResults.map((result: any) => (
<div
key={result.id}
className="p-3 rounded-lg bg-[#162840] hover:bg-[#1a3050] cursor-pointer transition-colors"
onClick={() => handleReadSection({ id: result.id, title: result.title, price: result.price || 1, filePath: '' })}
>
<div className="flex items-center justify-between">
<div>
<span className="text-[#38bdac] font-mono text-xs mr-2">{result.id}</span>
<span className="text-white">{result.title}</span>
</div>
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">
{result.matchType === 'title' ? '标题匹配' : '内容匹配'}
</Badge>
</div>
{result.snippet && (
<p className="text-gray-500 text-xs mt-2 line-clamp-2">{result.snippet}</p>
)}
<p className="text-gray-600 text-xs mt-1">
{result.partTitle} · {result.chapterTitle}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="hooks" className="space-y-4"> <TabsContent value="hooks" className="space-y-4">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl"> <Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader> <CardHeader>

View File

@@ -1,44 +1,48 @@
"use client" "use client"
import type React from "react" import type React from "react"
import { useState, useEffect } from "react"
import Link from "next/link" import Link from "next/link"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { LayoutDashboard, FileText, Users, CreditCard, QrCode, Settings, LogOut, Wallet, Globe, Share2 } from "lucide-react" import { LayoutDashboard, FileText, Users, CreditCard, Settings, LogOut, Wallet, Globe, BookOpen } from "lucide-react"
import { useStore } from "@/lib/store"
import { useRouter } from "next/navigation"
import { useEffect } from "react"
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const [mounted, setMounted] = useState(false)
const { user, isLoggedIn } = useStore()
useEffect(() => { useEffect(() => {
if (!isLoggedIn) { setMounted(true)
// router.push("/my") }, [])
}
}, [isLoggedIn, router])
// 简化菜单:按功能归类,保留核心功能
const menuItems = [ const menuItems = [
{ icon: LayoutDashboard, label: "数据概览", href: "/admin" }, { icon: LayoutDashboard, label: "数据概览", href: "/admin" },
{ icon: Globe, label: "网站配置", href: "/admin/site" }, { icon: BookOpen, label: "内容管理", href: "/admin/content" },
{ icon: FileText, label: "内容管理", href: "/admin/content" },
{ icon: Users, label: "用户管理", href: "/admin/users" }, { icon: Users, label: "用户管理", href: "/admin/users" },
{ icon: Share2, label: "分管理", href: "/admin/distribution" }, { icon: Wallet, label: "分管理", href: "/admin/withdrawals" },
{ icon: CreditCard, label: "支付置", href: "/admin/payment" }, { icon: CreditCard, label: "支付置", href: "/admin/payment" },
{ icon: Wallet, label: "提现管理", href: "/admin/withdrawals" },
{ icon: QrCode, label: "二维码", href: "/admin/qrcodes" },
{ icon: Settings, label: "系统设置", href: "/admin/settings" }, { icon: Settings, label: "系统设置", href: "/admin/settings" },
] ]
// 避免hydration错误等待客户端mount
if (!mounted) {
return (
<div className="flex min-h-screen bg-[#0a1628]">
<div className="w-64 bg-[#0f2137] border-r border-gray-700/50" />
<div className="flex-1 flex items-center justify-center">
<div className="text-[#38bdac]">...</div>
</div>
</div>
)
}
return ( return (
<div className="flex min-h-screen bg-[#0a1628]"> <div className="flex min-h-screen bg-[#0a1628]">
{/* Sidebar - 深色侧边栏 */} {/* Sidebar - 深色侧边栏 */}
<div className="w-64 bg-[#0f2137] flex flex-col border-r border-gray-700/50 shadow-xl"> <div className="w-64 bg-[#0f2137] flex flex-col border-r border-gray-700/50 shadow-xl">
<div className="p-6 border-b border-gray-700/50"> <div className="p-6 border-b border-gray-700/50">
<h1 className="text-xl font-bold text-[#38bdac]"></h1> <h1 className="text-xl font-bold text-[#38bdac]"></h1>
<p className="text-xs text-gray-400 mt-1">Soul创业实验场</p> <p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
</div> </div>
<nav className="flex-1 p-4 space-y-1"> <nav className="flex-1 p-4 space-y-1">

517
app/admin/match/page.tsx Normal file
View File

@@ -0,0 +1,517 @@
"use client"
import { useState, useEffect, Suspense } from "react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Settings, Save, RefreshCw, Edit3, Plus, Trash2, Users, Zap, DollarSign } from "lucide-react"
interface MatchType {
id: string
label: string
matchLabel: string
icon: string
matchFromDB: boolean
showJoinAfterMatch: boolean
price: number
enabled: boolean
}
interface MatchConfig {
matchTypes: MatchType[]
freeMatchLimit: number
matchPrice: number
settings: {
enableFreeMatches: boolean
enablePaidMatches: boolean
maxMatchesPerDay: number
}
}
const DEFAULT_CONFIG: MatchConfig = {
matchTypes: [
{ id: 'partner', label: '创业合伙', matchLabel: '创业伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '商业顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true }
],
freeMatchLimit: 3,
matchPrice: 1,
settings: {
enableFreeMatches: true,
enablePaidMatches: true,
maxMatchesPerDay: 10
}
}
function MatchConfigContent() {
const [config, setConfig] = useState<MatchConfig>(DEFAULT_CONFIG)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [showTypeModal, setShowTypeModal] = useState(false)
const [editingType, setEditingType] = useState<MatchType | null>(null)
const [formData, setFormData] = useState({
id: '',
label: '',
matchLabel: '',
icon: '⭐',
matchFromDB: false,
showJoinAfterMatch: true,
price: 1,
enabled: true
})
// 加载配置
const loadConfig = async () => {
setIsLoading(true)
try {
const res = await fetch('/api/db/config?key=match_config')
const data = await res.json()
if (data.success && data.config) {
setConfig({ ...DEFAULT_CONFIG, ...data.config })
}
} catch (error) {
console.error('加载匹配配置失败:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadConfig()
}, [])
// 保存配置
const handleSave = async () => {
setIsSaving(true)
try {
const res = await fetch('/api/db/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: 'match_config',
config,
description: '匹配功能配置'
})
})
const data = await res.json()
if (data.success) {
alert('配置保存成功!')
} else {
alert('保存失败: ' + (data.error || '未知错误'))
}
} catch (error) {
console.error('保存配置失败:', error)
alert('保存失败')
} finally {
setIsSaving(false)
}
}
// 编辑匹配类型
const handleEditType = (type: MatchType) => {
setEditingType(type)
setFormData({
id: type.id,
label: type.label,
matchLabel: type.matchLabel,
icon: type.icon,
matchFromDB: type.matchFromDB,
showJoinAfterMatch: type.showJoinAfterMatch,
price: type.price,
enabled: type.enabled
})
setShowTypeModal(true)
}
// 添加匹配类型
const handleAddType = () => {
setEditingType(null)
setFormData({
id: '',
label: '',
matchLabel: '',
icon: '⭐',
matchFromDB: false,
showJoinAfterMatch: true,
price: 1,
enabled: true
})
setShowTypeModal(true)
}
// 保存匹配类型
const handleSaveType = () => {
if (!formData.id || !formData.label) {
alert('请填写类型ID和名称')
return
}
const newTypes = [...config.matchTypes]
if (editingType) {
// 更新
const index = newTypes.findIndex(t => t.id === editingType.id)
if (index !== -1) {
newTypes[index] = { ...formData }
}
} else {
// 新增
if (newTypes.some(t => t.id === formData.id)) {
alert('类型ID已存在')
return
}
newTypes.push({ ...formData })
}
setConfig({ ...config, matchTypes: newTypes })
setShowTypeModal(false)
}
// 删除匹配类型
const handleDeleteType = (typeId: string) => {
if (!confirm('确定要删除这个匹配类型吗?')) return
const newTypes = config.matchTypes.filter(t => t.id !== typeId)
setConfig({ ...config, matchTypes: newTypes })
}
// 切换类型启用状态
const handleToggleType = (typeId: string) => {
const newTypes = config.matchTypes.map(t =>
t.id === typeId ? { ...t, enabled: !t.enabled } : t
)
setConfig({ ...config, matchTypes: newTypes })
}
const icons = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨']
return (
<div className="p-8 max-w-6xl mx-auto space-y-6">
{/* 页面标题 */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Settings className="w-6 h-6 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1"></p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={loadConfig}
disabled={isLoading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
{/* 基础设置 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-400" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 每日免费次数 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
min="0"
max="100"
className="bg-[#0a1628] border-gray-700 text-white"
value={config.freeMatchLimit}
onChange={(e) => setConfig({ ...config, freeMatchLimit: parseInt(e.target.value) || 0 })}
/>
<p className="text-xs text-gray-500"></p>
</div>
{/* 付费匹配价格 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
min="0.01"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
value={config.matchPrice}
onChange={(e) => setConfig({ ...config, matchPrice: parseFloat(e.target.value) || 1 })}
/>
<p className="text-xs text-gray-500"></p>
</div>
{/* 每日最大次数 */}
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
min="1"
max="100"
className="bg-[#0a1628] border-gray-700 text-white"
value={config.settings.maxMatchesPerDay}
onChange={(e) => setConfig({
...config,
settings: { ...config.settings, maxMatchesPerDay: parseInt(e.target.value) || 10 }
})}
/>
<p className="text-xs text-gray-500"></p>
</div>
</div>
<div className="flex gap-8 pt-4 border-t border-gray-700/50">
<div className="flex items-center gap-3">
<Switch
checked={config.settings.enableFreeMatches}
onCheckedChange={(checked) => setConfig({
...config,
settings: { ...config.settings, enableFreeMatches: checked }
})}
/>
<Label className="text-gray-300"></Label>
</div>
<div className="flex items-center gap-3">
<Switch
checked={config.settings.enablePaidMatches}
onCheckedChange={(checked) => setConfig({
...config,
settings: { ...config.settings, enablePaidMatches: checked }
})}
/>
<Label className="text-gray-300"></Label>
</div>
</div>
</CardContent>
</Card>
{/* 匹配类型管理 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</div>
<Button
onClick={handleAddType}
size="sm"
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Plus className="w-4 h-4 mr-1" />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{config.matchTypes.map((type) => (
<TableRow key={type.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<span className="text-2xl">{type.icon}</span>
</TableCell>
<TableCell className="font-mono text-gray-300">{type.id}</TableCell>
<TableCell className="text-white font-medium">{type.label}</TableCell>
<TableCell className="text-gray-300">{type.matchLabel}</TableCell>
<TableCell>
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
¥{type.price}
</Badge>
</TableCell>
<TableCell>
{type.matchFromDB ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge>
) : (
<Badge variant="outline" className="text-gray-500 border-gray-600"></Badge>
)}
</TableCell>
<TableCell>
<Switch
checked={type.enabled}
onCheckedChange={() => handleToggleType(type.id)}
/>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditType(type)}
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteType(type.id)}
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 编辑类型弹窗 */}
<Dialog open={showTypeModal} onOpenChange={setShowTypeModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
{editingType ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <Plus className="w-5 h-5 text-[#38bdac]" />}
{editingType ? '编辑匹配类型' : '添加匹配类型'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如: partner"
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
disabled={!!editingType}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex gap-1 flex-wrap">
{icons.map((icon) => (
<button
key={icon}
type="button"
className={`w-8 h-8 text-lg rounded ${formData.icon === icon ? 'bg-[#38bdac]/30 ring-1 ring-[#38bdac]' : 'bg-[#0a1628]'}`}
onClick={() => setFormData({ ...formData, icon })}
>
{icon}
</button>
))}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如: 创业合伙"
value={formData.label}
onChange={(e) => setFormData({ ...formData, label: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如: 创业伙伴"
value={formData.matchLabel}
onChange={(e) => setFormData({ ...formData, matchLabel: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
type="number"
min="0.01"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 1 })}
/>
</div>
<div className="flex gap-6 pt-2">
<div className="flex items-center gap-3">
<Switch
checked={formData.matchFromDB}
onCheckedChange={(checked) => setFormData({ ...formData, matchFromDB: checked })}
/>
<Label className="text-gray-300 text-sm"></Label>
</div>
<div className="flex items-center gap-3">
<Switch
checked={formData.showJoinAfterMatch}
onCheckedChange={(checked) => setFormData({ ...formData, showJoinAfterMatch: checked })}
/>
<Label className="text-gray-300 text-sm"></Label>
</div>
<div className="flex items-center gap-3">
<Switch
checked={formData.enabled}
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
/>
<Label className="text-gray-300 text-sm"></Label>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowTypeModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSaveType}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
export default function MatchConfigPage() {
return (
<Suspense fallback={null}>
<MatchConfigContent />
</Suspense>
)
}

View File

@@ -9,36 +9,48 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Search, UserPlus, Eye, Trash2, Edit3, Key, Save, X, RefreshCw } from "lucide-react" import { Search, UserPlus, Trash2, Edit3, Key, Save, X, RefreshCw, Users, Eye } from "lucide-react"
interface User { interface User {
id: string id: string
phone: string open_id?: string | null
phone?: string | null
nickname: string nickname: string
password?: string password?: string | null
is_admin?: boolean wechat_id?: string | null
has_full_book?: boolean avatar?: string | null
is_admin?: boolean | number
has_full_book?: boolean | number
referral_code: string referral_code: string
referred_by?: string referred_by?: string | null
earnings: number earnings: number | string
pending_earnings: number pending_earnings: number | string
withdrawn_earnings: number withdrawn_earnings?: number | string
referral_count: number referral_count: number
match_count_today?: number match_count_today?: number
last_match_date?: string last_match_date?: string | null
purchased_sections?: string[] | string | null
created_at: string created_at: string
updated_at?: string | null
} }
function UsersContent() { function UsersContent() {
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const [searchTerm, setSearchTerm] = useState("") const [searchTerm, setSearchTerm] = useState("")
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showUserModal, setShowUserModal] = useState(false) const [showUserModal, setShowUserModal] = useState(false)
const [showPasswordModal, setShowPasswordModal] = useState(false) const [showPasswordModal, setShowPasswordModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null) const [editingUser, setEditingUser] = useState<User | null>(null)
const [newPassword, setNewPassword] = useState("") const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("")
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
// 绑定关系弹窗
const [showReferralsModal, setShowReferralsModal] = useState(false)
const [referralsData, setReferralsData] = useState<any>({ referrals: [], stats: {} })
const [referralsLoading, setReferralsLoading] = useState(false)
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
// 初始表单状态 // 初始表单状态
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -52,14 +64,18 @@ function UsersContent() {
// 加载用户列表 // 加载用户列表
const loadUsers = async () => { const loadUsers = async () => {
setIsLoading(true) setIsLoading(true)
setError(null)
try { try {
const res = await fetch('/api/db/users') const res = await fetch('/api/db/users')
const data = await res.json() const data = await res.json()
if (data.success) { if (data.success) {
setUsers(data.users || []) setUsers(data.users || [])
} else {
setError(data.error || '加载失败')
} }
} catch (error) { } catch (err) {
console.error('Load users error:', error) console.error('Load users error:', err)
setError('网络错误,请检查连接')
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -95,8 +111,8 @@ function UsersContent() {
const handleEditUser = (user: User) => { const handleEditUser = (user: User) => {
setEditingUser(user) setEditingUser(user)
setFormData({ setFormData({
phone: user.phone, phone: user.phone || "",
nickname: user.nickname, nickname: user.nickname || "",
password: "", password: "",
is_admin: user.is_admin || false, is_admin: user.is_admin || false,
has_full_book: user.has_full_book || false, has_full_book: user.has_full_book || false,
@@ -181,6 +197,28 @@ function UsersContent() {
setShowPasswordModal(true) setShowPasswordModal(true)
} }
// 查看绑定关系
const handleViewReferrals = async (user: User) => {
setSelectedUserForReferrals(user)
setShowReferralsModal(true)
setReferralsLoading(true)
try {
const res = await fetch(`/api/db/users/referrals?userId=${user.id}`)
const data = await res.json()
if (data.success) {
setReferralsData(data)
} else {
setReferralsData({ referrals: [], stats: {} })
}
} catch (err) {
console.error('Load referrals error:', err)
setReferralsData({ referrals: [], stats: {} })
} finally {
setReferralsLoading(false)
}
}
// 保存密码 // 保存密码
const handleSavePassword = async () => { const handleSavePassword = async () => {
if (!newPassword) { if (!newPassword) {
@@ -384,6 +422,92 @@ function UsersContent() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* 绑定关系弹窗 */}
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
- {selectedUserForReferrals?.nickname}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 统计信息 */}
<div className="grid grid-cols-4 gap-3">
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-[#38bdac]">{referralsData.stats?.total || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-green-400">{referralsData.stats?.purchased || 0}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-yellow-400">¥{(referralsData.stats?.earnings || 0).toFixed(2)}</div>
<div className="text-xs text-gray-400"></div>
</div>
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-orange-400">¥{(referralsData.stats?.pendingEarnings || 0).toFixed(2)}</div>
<div className="text-xs text-gray-400"></div>
</div>
</div>
{/* 绑定用户列表 */}
{referralsLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : referralsData.referrals?.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{referralsData.referrals.map((ref: any) => (
<div key={ref.id} className="flex items-center justify-between bg-[#0a1628] rounded-lg p-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
{ref.nickname?.charAt(0) || "?"}
</div>
<div>
<div className="text-white text-sm">{ref.nickname}</div>
<div className="text-xs text-gray-500">
{ref.phone || (ref.hasOpenId ? '微信用户' : '未绑定')}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{ref.status === 'vip' && (
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs"></Badge>
)}
{ref.status === 'paid' && (
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-xs">{ref.purchasedSections}</Badge>
)}
{ref.status === 'free' && (
<Badge className="bg-gray-500/20 text-gray-400 border-0 text-xs"></Badge>
)}
<span className="text-xs text-gray-500">
{ref.createdAt ? new Date(ref.createdAt).toLocaleDateString() : ''}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowReferralsModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl"> <Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0"> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
@@ -396,10 +520,10 @@ function UsersContent() {
<TableHeader> <TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700"> <TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead> <TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead> <TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead> <TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead> <TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead> <TableHead className="text-gray-400">广</TableHead>
<TableHead className="text-gray-400"></TableHead> <TableHead className="text-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead> <TableHead className="text-right text-gray-400"></TableHead>
</TableRow> </TableRow>
@@ -410,7 +534,11 @@ function UsersContent() {
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]"> <div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
{user.nickname?.charAt(0) || "?"} {user.avatar ? (
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : (
user.nickname?.charAt(0) || "?"
)}
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -420,12 +548,45 @@ function UsersContent() {
</Badge> </Badge>
)} )}
{user.open_id && !user.id?.startsWith('user_') && (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs">
</Badge>
)}
</div> </div>
<p className="text-xs text-gray-500">ID: {user.id?.slice(0, 8)}</p> <p className="text-xs text-gray-500 font-mono">
{user.open_id ? user.open_id.slice(0, 12) + '...' : user.id?.slice(0, 12)}
</p>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-gray-300">{user.phone}</TableCell> <TableCell>
<div className="space-y-1">
{user.phone && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">📱</span>
<span className="text-gray-300">{user.phone}</span>
</div>
)}
{user.wechat_id && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">💬</span>
<span className="text-gray-300">{user.wechat_id}</span>
</div>
)}
{user.open_id && (
<div className="flex items-center gap-1 text-xs">
<span className="text-gray-500">🔗</span>
<span className="text-gray-500 truncate max-w-[100px]" title={user.open_id}>
{user.open_id.slice(0, 12)}...
</span>
</div>
)}
{!user.phone && !user.wechat_id && !user.open_id && (
<span className="text-gray-600 text-xs"></span>
)}
</div>
</TableCell>
<TableCell> <TableCell>
{user.has_full_book ? ( {user.has_full_book ? (
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge> <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge>
@@ -435,8 +596,31 @@ function UsersContent() {
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell className="text-white font-medium">¥{(user.earnings || 0).toFixed(2)}</TableCell> <TableCell>
<TableCell className="text-gray-300">{user.match_count_today || 0}/3</TableCell> <div className="space-y-1">
<div className="text-white font-medium">¥{parseFloat(String(user.earnings || 0)).toFixed(2)}</div>
{parseFloat(String(user.pending_earnings || 0)) > 0 && (
<div className="text-xs text-yellow-400">: ¥{parseFloat(String(user.pending_earnings || 0)).toFixed(2)}</div>
)}
<div
className="text-xs text-[#38bdac] cursor-pointer hover:underline flex items-center gap-1"
onClick={() => handleViewReferrals(user)}
>
<Users className="w-3 h-3" />
{user.referral_count || 0}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<code className="text-[#38bdac] text-xs bg-[#38bdac]/10 px-2 py-0.5 rounded">
{user.referral_code || '-'}
</code>
{user.referred_by && (
<div className="text-xs text-gray-500">: {user.referred_by.slice(0, 8)}</div>
)}
</div>
</TableCell>
<TableCell className="text-gray-400"> <TableCell className="text-gray-400">
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"} {user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
</TableCell> </TableCell>

View File

@@ -1,172 +1,305 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { useStore } from "@/lib/store"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Check, Clock, Wallet, History } from "lucide-react" import { Check, X, Clock, Wallet, History, RefreshCw, AlertCircle, DollarSign } from "lucide-react"
interface Withdrawal {
id: string
userId: string
userNickname: string
userPhone?: string
userAvatar?: string
referralCode?: string
amount: number
status: 'pending' | 'processing' | 'success' | 'failed'
wechatOpenid?: string
transactionId?: string
errorMessage?: string
createdAt: string
processedAt?: string
}
interface Stats {
total: number
pendingCount: number
pendingAmount: number
successCount: number
successAmount: number
failedCount: number
}
export default function WithdrawalsPage() { export default function WithdrawalsPage() {
const { withdrawals, completeWithdrawal } = useStore() const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
const [mounted, setMounted] = useState(false) const [stats, setStats] = useState<Stats>({ total: 0, pendingCount: 0, pendingAmount: 0, successCount: 0, successAmount: 0, failedCount: 0 })
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'pending' | 'success' | 'failed'>('all')
const [processing, setProcessing] = useState<string | null>(null)
// 加载提现记录
const loadWithdrawals = async () => {
setLoading(true)
try {
const res = await fetch(`/api/admin/withdrawals?status=${filter}`)
const data = await res.json()
if (data.success) {
setWithdrawals(data.withdrawals || [])
setStats(data.stats || {})
}
} catch (error) {
console.error('Load withdrawals error:', error)
} finally {
setLoading(false)
}
}
useEffect(() => { useEffect(() => {
setMounted(true) loadWithdrawals()
}, []) }, [filter])
if (!mounted) return null // 批准提现
const handleApprove = async (id: string) => {
if (!confirm("确认已完成打款?批准后将更新用户提现记录。")) return
setProcessing(id)
try {
const res = await fetch('/api/admin/withdrawals', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action: 'approve' })
})
const data = await res.json()
if (data.success) {
loadWithdrawals()
} else {
alert('操作失败: ' + data.error)
}
} catch (error) {
alert('操作失败')
} finally {
setProcessing(null)
}
}
const pendingWithdrawals = withdrawals?.filter((w) => w.status === "pending") || [] // 拒绝提现
const historyWithdrawals = const handleReject = async (id: string) => {
withdrawals const reason = prompt("请输入拒绝原因(将返还用户余额):")
?.filter((w) => w.status !== "pending") if (!reason) return
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) || []
setProcessing(id)
try {
const res = await fetch('/api/admin/withdrawals', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, action: 'reject', reason })
})
const data = await res.json()
if (data.success) {
loadWithdrawals()
} else {
alert('操作失败: ' + data.error)
}
} catch (error) {
alert('操作失败')
} finally {
setProcessing(null)
}
}
const totalPending = pendingWithdrawals.reduce((sum, w) => sum + w.amount, 0) const getStatusBadge = (status: string) => {
switch (status) {
const handleApprove = (id: string) => { case 'pending':
if (confirm("确认打款并完成此提现申请吗?")) { return <Badge className="bg-orange-500/20 text-orange-400 hover:bg-orange-500/20 border-0"></Badge>
completeWithdrawal(id) case 'processing':
return <Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0"></Badge>
case 'success':
return <Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"></Badge>
case 'failed':
return <Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0"></Badge>
default:
return <Badge className="bg-gray-500/20 text-gray-400 border-0">{status}</Badge>
} }
} }
return ( return (
<div className="p-8 max-w-6xl mx-auto"> <div className="p-8 max-w-6xl mx-auto">
<div className="mb-8"> <div className="flex justify-between items-start mb-8">
<h1 className="text-2xl font-bold text-white"></h1> <div>
<p className="text-gray-400 mt-1"> <h1 className="text-2xl font-bold text-white"></h1>
{pendingWithdrawals.length} ¥{totalPending.toFixed(2)} <p className="text-gray-400 mt-1">
</p>
</p>
</div>
<Button
variant="outline"
onClick={loadWithdrawals}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div> </div>
<div className="grid gap-6"> {/* 分账规则说明 */}
{/* 待处理申请 */} <Card className="bg-gradient-to-r from-[#38bdac]/10 to-[#0f2137] border-[#38bdac]/30 mb-6">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl"> <CardContent className="p-4">
<CardHeader> <div className="flex items-start gap-3">
<CardTitle className="flex items-center gap-2 text-white"> <DollarSign className="w-5 h-5 text-[#38bdac] mt-0.5" />
<div className="p-2 rounded-lg bg-orange-500/20"> <div>
<Clock className="w-5 h-5 text-orange-400" /> <h3 className="text-white font-medium mb-2"></h3>
<div className="text-sm text-gray-400 space-y-1">
<p> <span className="text-[#38bdac]"></span>广 <span className="text-white font-medium">90%</span></p>
<p> <span className="text-[#38bdac]"></span>广</p>
<p> <span className="text-[#38bdac]"></span></p>
<p> <span className="text-[#38bdac]"></span></p>
</div> </div>
({pendingWithdrawals.length}) </div>
</CardTitle> </div>
</CardHeader> </CardContent>
<CardContent> </Card>
{pendingWithdrawals.length === 0 ? (
<div className="text-center py-12"> {/* 统计卡片 */}
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" /> <div className="grid grid-cols-4 gap-4 mb-6">
<p className="text-gray-500"></p> <Card className="bg-[#0f2137] border-gray-700/50">
</div> <CardContent className="p-4 text-center">
) : ( <div className="text-3xl font-bold text-[#38bdac]">{stats.total}</div>
<div className="overflow-x-auto"> <div className="text-sm text-gray-400"></div>
<table className="w-full text-sm"> </CardContent>
<thead> </Card>
<tr className="bg-[#0a1628] text-gray-400"> <Card className="bg-[#0f2137] border-gray-700/50">
<th className="p-4 text-left font-medium"></th> <CardContent className="p-4 text-center">
<th className="p-4 text-left font-medium"></th> <div className="text-3xl font-bold text-orange-400">{stats.pendingCount}</div>
<th className="p-4 text-left font-medium"></th> <div className="text-sm text-gray-400"></div>
<th className="p-4 text-left font-medium"></th> <div className="text-xs text-orange-400 mt-1">¥{stats.pendingAmount.toFixed(2)}</div>
<th className="p-4 text-left font-medium"></th> </CardContent>
<th className="p-4 text-right font-medium"></th> </Card>
</tr> <Card className="bg-[#0f2137] border-gray-700/50">
</thead> <CardContent className="p-4 text-center">
<tbody className="divide-y divide-gray-700/50"> <div className="text-3xl font-bold text-green-400">{stats.successCount}</div>
{pendingWithdrawals.map((w) => ( <div className="text-sm text-gray-400"></div>
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors"> <div className="text-xs text-green-400 mt-1">¥{stats.successAmount.toFixed(2)}</div>
<td className="p-4 text-gray-400">{new Date(w.createdAt).toLocaleString()}</td> </CardContent>
<td className="p-4"> </Card>
<div> <Card className="bg-[#0f2137] border-gray-700/50">
<p className="font-medium text-white">{w.name}</p> <CardContent className="p-4 text-center">
<p className="text-xs text-gray-500 font-mono">{w.userId.slice(0, 8)}...</p> <div className="text-3xl font-bold text-red-400">{stats.failedCount}</div>
<div className="text-sm text-gray-400"></div>
</CardContent>
</Card>
</div>
{/* 筛选按钮 */}
<div className="flex gap-2 mb-4">
{(['all', 'pending', 'success', 'failed'] as const).map((f) => (
<Button
key={f}
variant={filter === f ? "default" : "outline"}
size="sm"
onClick={() => setFilter(f)}
className={filter === f
? "bg-[#38bdac] hover:bg-[#2da396] text-white"
: "border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
}
>
{f === 'all' ? '全部' : f === 'pending' ? '待处理' : f === 'success' ? '已完成' : '已拒绝'}
</Button>
))}
</div>
{/* 提现记录表格 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : withdrawals.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-[#0a1628] text-gray-400">
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{withdrawals.map((w) => (
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
<td className="p-4 text-gray-400">
{new Date(w.createdAt).toLocaleString()}
</td>
<td className="p-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
{w.userNickname?.charAt(0) || "?"}
</div> </div>
</td> <div>
<td className="p-4"> <p className="font-medium text-white">{w.userNickname}</p>
<Badge <p className="text-xs text-gray-500">{w.userPhone || w.userId.slice(0, 10)}</p>
className={ </div>
w.method === "wechat" </div>
? "bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0" </td>
: "bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0" <td className="p-4">
} <span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
> </td>
{w.method === "wechat" ? "微信" : "支付宝"} <td className="p-4">
</Badge> {getStatusBadge(w.status)}
</td> {w.errorMessage && (
<td className="p-4 font-mono text-gray-300">{w.account}</td> <p className="text-xs text-red-400 mt-1">{w.errorMessage}</p>
<td className="p-4"> )}
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span> </td>
</td> <td className="p-4 text-gray-400">
<td className="p-4 text-right"> {w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}
<Button </td>
size="sm" <td className="p-4 text-right">
onClick={() => handleApprove(w.id)} {w.status === 'pending' && (
className="bg-[#38bdac] hover:bg-[#2da396] text-white" <div className="flex items-center justify-end gap-2">
> <Button
<Check className="w-4 h-4 mr-1" /> size="sm"
onClick={() => handleApprove(w.id)}
</Button> disabled={processing === w.id}
</td> className="bg-green-600 hover:bg-green-700 text-white"
</tr> >
))} <Check className="w-4 h-4 mr-1" />
</tbody>
</table> </Button>
</div> <Button
)} size="sm"
</CardContent> variant="outline"
</Card> onClick={() => handleReject(w.id)}
disabled={processing === w.id}
{/* 处理历史 */} className="border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent"
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl"> >
<CardHeader> <X className="w-4 h-4 mr-1" />
<CardTitle className="flex items-center gap-2 text-white">
<div className="p-2 rounded-lg bg-gray-700/50"> </Button>
<History className="w-5 h-5 text-gray-400" /> </div>
</div> )}
{w.status === 'success' && w.transactionId && (
</CardTitle> <span className="text-xs text-gray-500 font-mono">{w.transactionId}</span>
</CardHeader> )}
<CardContent> </td>
{historyWithdrawals.length === 0 ? (
<div className="text-center py-12">
<History className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-[#0a1628] text-gray-400">
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
</tr> </tr>
</thead> ))}
<tbody className="divide-y divide-gray-700/50"> </tbody>
{historyWithdrawals.map((w) => ( </table>
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors"> </div>
<td className="p-4 text-gray-400">{new Date(w.createdAt).toLocaleString()}</td> )}
<td className="p-4 text-gray-400"> </CardContent>
{w.completedAt ? new Date(w.completedAt).toLocaleString() : "-"} </Card>
</td>
<td className="p-4 font-medium text-white">{w.name}</td>
<td className="p-4 text-gray-300">{w.method === "wechat" ? "微信" : "支付宝"}</td>
<td className="p-4 font-medium text-white">¥{w.amount.toFixed(2)}</td>
<td className="p-4">
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
</div> </div>
) )
} }

View File

@@ -0,0 +1,169 @@
/**
* 后台提现管理API
* 获取所有提现记录,处理提现审批
*/
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
// 获取所有提现记录
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') // pending, success, failed, all
let sql = `
SELECT
w.*,
u.nickname as user_nickname,
u.phone as user_phone,
u.avatar as user_avatar,
u.referral_code
FROM withdrawals w
LEFT JOIN users u ON w.user_id = u.id
`
if (status && status !== 'all') {
sql += ` WHERE w.status = '${status}'`
}
sql += ` ORDER BY w.created_at DESC LIMIT 100`
const withdrawals = await query(sql) as any[]
// 统计信息
const statsResult = await query(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending_count,
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) as pending_amount,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count,
SUM(CASE WHEN status = 'success' THEN amount ELSE 0 END) as success_amount,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_count
FROM withdrawals
`) as any[]
const stats = statsResult[0] || {}
return NextResponse.json({
success: true,
withdrawals: withdrawals.map(w => ({
id: w.id,
userId: w.user_id,
userNickname: w.user_nickname || '未知用户',
userPhone: w.user_phone,
userAvatar: w.user_avatar,
referralCode: w.referral_code,
amount: parseFloat(w.amount),
status: w.status,
wechatOpenid: w.wechat_openid,
transactionId: w.transaction_id,
errorMessage: w.error_message,
createdAt: w.created_at,
processedAt: w.processed_at
})),
stats: {
total: parseInt(stats.total) || 0,
pendingCount: parseInt(stats.pending_count) || 0,
pendingAmount: parseFloat(stats.pending_amount) || 0,
successCount: parseInt(stats.success_count) || 0,
successAmount: parseFloat(stats.success_amount) || 0,
failedCount: parseInt(stats.failed_count) || 0
}
})
} catch (error) {
console.error('Get withdrawals error:', error)
return NextResponse.json({
success: false,
error: '获取提现记录失败'
}, { status: 500 })
}
}
// 处理提现(审批/拒绝)
export async function PUT(request: Request) {
try {
const body = await request.json()
const { id, action, reason } = body // action: approve, reject
if (!id || !action) {
return NextResponse.json({
success: false,
error: '缺少必要参数'
}, { status: 400 })
}
// 获取提现记录
const withdrawals = await query('SELECT * FROM withdrawals WHERE id = ?', [id]) as any[]
if (withdrawals.length === 0) {
return NextResponse.json({
success: false,
error: '提现记录不存在'
}, { status: 404 })
}
const withdrawal = withdrawals[0]
if (withdrawal.status !== 'pending') {
return NextResponse.json({
success: false,
error: '该提现记录已处理'
}, { status: 400 })
}
if (action === 'approve') {
// 批准提现 - 更新状态为成功
await query(`
UPDATE withdrawals
SET status = 'success', processed_at = NOW(), transaction_id = ?
WHERE id = ?
`, [`manual_${Date.now()}`, id])
// 更新用户已提现金额
await query(`
UPDATE users
SET withdrawn_earnings = withdrawn_earnings + ?,
pending_earnings = pending_earnings - ?
WHERE id = ?
`, [withdrawal.amount, withdrawal.amount, withdrawal.user_id])
return NextResponse.json({
success: true,
message: '提现已批准'
})
} else if (action === 'reject') {
// 拒绝提现 - 返还用户余额
await query(`
UPDATE withdrawals
SET status = 'failed', processed_at = NOW(), error_message = ?
WHERE id = ?
`, [reason || '管理员拒绝', id])
// 返还用户余额
await query(`
UPDATE users
SET earnings = earnings + ?,
pending_earnings = pending_earnings - ?
WHERE id = ?
`, [withdrawal.amount, withdrawal.amount, withdrawal.user_id])
return NextResponse.json({
success: true,
message: '提现已拒绝,余额已返还'
})
}
return NextResponse.json({
success: false,
error: '无效的操作'
}, { status: 400 })
} catch (error) {
console.error('Process withdrawal error:', error)
return NextResponse.json({
success: false,
error: '处理提现失败'
}, { status: 500 })
}
}

View File

@@ -0,0 +1,150 @@
/**
* 章节搜索API
* 搜索章节标题和内容,不返回用户敏感信息
*/
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q') || ''
if (!keyword || keyword.trim().length < 1) {
return NextResponse.json({
success: true,
results: [],
total: 0,
message: '请输入搜索关键词'
})
}
const searchTerm = keyword.trim().toLowerCase()
// 读取章节数据
const dataPath = path.join(process.cwd(), 'public/book-chapters.json')
const fileContent = fs.readFileSync(dataPath, 'utf-8')
const chaptersData = JSON.parse(fileContent)
// 读取书籍内容目录
const bookDir = path.join(process.cwd(), 'book')
const results: any[] = []
// 遍历章节搜索
for (const chapter of chaptersData) {
const titleMatch = chapter.title?.toLowerCase().includes(searchTerm)
const idMatch = chapter.id?.toLowerCase().includes(searchTerm)
const partMatch = chapter.partTitle?.toLowerCase().includes(searchTerm)
// 尝试读取章节内容进行搜索
let contentMatch = false
let matchedContent = ''
// 兼容两种字段名: file 或 filePath
const filePathField = chapter.filePath || chapter.file
if (filePathField) {
try {
// 如果是绝对路径,直接使用;否则相对于项目根目录
const filePath = filePathField.startsWith('/') ? filePathField : path.join(process.cwd(), filePathField)
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8')
// 移除敏感信息(手机号、微信号等)
const cleanContent = content
.replace(/1[3-9]\d{9}/g, '***') // 手机号
.replace(/微信[:]\s*\S+/g, '微信:***') // 微信号
.replace(/QQ[:]\s*\d+/g, 'QQ***') // QQ号
.replace(/邮箱[:]\s*\S+@\S+/g, '邮箱:***') // 邮箱
if (cleanContent.toLowerCase().includes(searchTerm)) {
contentMatch = true
// 提取匹配的上下文前后50个字符
const lowerContent = cleanContent.toLowerCase()
const matchIndex = lowerContent.indexOf(searchTerm)
if (matchIndex !== -1) {
const start = Math.max(0, matchIndex - 30)
const end = Math.min(cleanContent.length, matchIndex + searchTerm.length + 50)
matchedContent = (start > 0 ? '...' : '') +
cleanContent.slice(start, end).replace(/\n/g, ' ') +
(end < cleanContent.length ? '...' : '')
}
}
}
} catch (e) {
// 文件读取失败,跳过内容搜索
}
}
if (titleMatch || idMatch || partMatch || contentMatch) {
// 从标题中提取章节号(如 "1.1 荷包:..." -> "1.1"
const sectionIdMatch = chapter.title?.match(/^(\d+\.\d+)\s/)
const sectionId = sectionIdMatch ? sectionIdMatch[1] : chapter.id
// 处理特殊ID
let finalId = sectionId
if (chapter.id === 'preface' || chapter.title?.includes('序言')) {
finalId = 'preface'
} else if (chapter.id === 'epilogue') {
finalId = 'epilogue'
} else if (chapter.id?.startsWith('appendix')) {
finalId = chapter.id
}
// 判断是否免费章节
const freeIds = ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3']
const isFree = freeIds.includes(finalId)
results.push({
id: finalId, // 使用提取的章节号
title: chapter.title,
part: chapter.partTitle || chapter.part || '',
chapter: chapter.chapterDir || chapter.chapter || '',
isFree: isFree,
matchType: titleMatch ? 'title' : (idMatch ? 'id' : (partMatch ? 'part' : 'content')),
matchedContent: contentMatch ? matchedContent : '',
// 格式化章节号
chapterLabel: formatChapterLabel(finalId, chapter.index)
})
}
}
// 按匹配类型排序:标题匹配 > ID匹配 > 内容匹配
results.sort((a, b) => {
const order = { title: 0, id: 1, content: 2 }
return (order[a.matchType as keyof typeof order] || 2) - (order[b.matchType as keyof typeof order] || 2)
})
return NextResponse.json({
success: true,
results: results.slice(0, 20), // 最多返回20条
total: results.length,
keyword: keyword
})
} catch (error) {
console.error('Search error:', error)
return NextResponse.json({
success: false,
error: '搜索失败',
results: []
}, { status: 500 })
}
}
// 格式化章节标签
function formatChapterLabel(id: string, index?: number): string {
if (!id) return ''
if (id === 'preface') return '序言'
if (id.startsWith('chapter-') && index) return `${index}`
if (id.startsWith('appendix')) return '附录'
if (id === 'epilogue') return '后记'
// 处理 1.1, 3.2 这样的格式
const match = id.match(/^(\d+)\.(\d+)$/)
if (match) {
return `${match[1]}.${match[2]}`
}
return id
}

425
app/api/db/book/route.ts Normal file
View File

@@ -0,0 +1,425 @@
/**
* 书籍内容数据库API
* 支持完整的CRUD操作 - 读取/写入/修改/删除章节
* 同时支持文件系统和数据库双写
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import fs from 'fs'
import path from 'path'
import { bookData } from '@/lib/book-data'
// 获取章节内容(从数据库或文件系统)
async function getSectionContent(id: string): Promise<{content: string, source: 'db' | 'file'} | null> {
try {
// 先从数据库查询
const results = await query(
'SELECT content, section_title FROM chapters WHERE id = ?',
[id]
) as any[]
if (results.length > 0 && results[0].content) {
return { content: results[0].content, source: 'db' }
}
} catch (e) {
console.log('[Book API] 数据库查询失败,尝试从文件读取:', e)
}
// 从文件系统读取
const filePath = findSectionFilePath(id)
if (filePath && fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8')
return { content, source: 'file' }
} catch (e) {
console.error('[Book API] 读取文件失败:', e)
}
}
return null
}
// 根据section ID查找对应的文件路径
function findSectionFilePath(id: string): string | null {
for (const part of bookData) {
for (const chapter of part.chapters) {
const section = chapter.sections.find(s => s.id === id)
if (section?.filePath) {
return path.join(process.cwd(), section.filePath)
}
}
}
return null
}
// 获取section的完整信息
function getSectionInfo(id: string) {
for (const part of bookData) {
for (const chapter of part.chapters) {
const section = chapter.sections.find(s => s.id === id)
if (section) {
return {
section,
chapter,
part,
partId: part.id,
chapterId: chapter.id,
partTitle: part.title,
chapterTitle: chapter.title
}
}
}
}
return null
}
/**
* GET - 读取章节内容
* 支持参数:
* - id: 章节ID
* - action: 'read' | 'export' | 'list'
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'read'
const id = searchParams.get('id')
try {
// 读取单个章节
if (action === 'read' && id) {
const result = await getSectionContent(id)
const sectionInfo = getSectionInfo(id)
if (result) {
return NextResponse.json({
success: true,
section: {
id,
content: result.content,
source: result.source,
title: sectionInfo?.section.title || '',
price: sectionInfo?.section.price || 1,
partTitle: sectionInfo?.partTitle,
chapterTitle: sectionInfo?.chapterTitle
}
})
} else {
return NextResponse.json({
success: false,
error: '章节不存在或无法读取'
}, { status: 404 })
}
}
// 导出所有章节
if (action === 'export') {
const sections: any[] = []
for (const part of bookData) {
for (const chapter of part.chapters) {
for (const section of chapter.sections) {
const content = await getSectionContent(section.id)
sections.push({
id: section.id,
title: section.title,
price: section.price,
isFree: section.isFree,
partId: part.id,
partTitle: part.title,
chapterId: chapter.id,
chapterTitle: chapter.title,
content: content?.content || '',
source: content?.source || 'none'
})
}
}
}
const blob = JSON.stringify(sections, null, 2)
return new NextResponse(blob, {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="book_sections_${new Date().toISOString().split('T')[0]}.json"`
}
})
}
// 列出所有章节(不含内容)
if (action === 'list') {
const sections: any[] = []
for (const part of bookData) {
for (const chapter of part.chapters) {
for (const section of chapter.sections) {
sections.push({
id: section.id,
title: section.title,
price: section.price,
isFree: section.isFree,
partId: part.id,
partTitle: part.title,
chapterId: chapter.id,
chapterTitle: chapter.title,
filePath: section.filePath
})
}
}
}
return NextResponse.json({
success: true,
sections,
total: sections.length
})
}
return NextResponse.json({
success: false,
error: '无效的操作或缺少参数'
}, { status: 400 })
} catch (error) {
console.error('[Book API] GET错误:', error)
return NextResponse.json({
success: false,
error: '获取章节失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* POST - 同步/导入章节
* 支持action:
* - sync: 同步文件系统到数据库
* - import: 批量导入章节数据
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { action, data } = body
// 同步到数据库
if (action === 'sync') {
let synced = 0
let failed = 0
for (const part of bookData) {
for (const chapter of part.chapters) {
for (const section of chapter.sections) {
try {
const filePath = path.join(process.cwd(), section.filePath)
let content = ''
if (fs.existsSync(filePath)) {
content = fs.readFileSync(filePath, 'utf-8')
}
// 插入或更新到数据库
await query(`
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, sort_order, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
ON DUPLICATE KEY UPDATE
part_title = VALUES(part_title),
chapter_title = VALUES(chapter_title),
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
is_free = VALUES(is_free),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP
`, [
section.id,
part.id,
part.title,
chapter.id,
chapter.title,
section.title,
content,
content.length,
section.isFree,
section.price,
synced
])
synced++
} catch (e) {
console.error(`[Book API] 同步章节${section.id}失败:`, e)
failed++
}
}
}
}
return NextResponse.json({
success: true,
message: `同步完成:成功 ${synced} 个章节,失败 ${failed}`,
synced,
failed
})
}
// 导入数据
if (action === 'import' && data) {
let imported = 0
let failed = 0
for (const item of data) {
try {
await query(`
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, is_free, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
ON DUPLICATE KEY UPDATE
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
is_free = VALUES(is_free),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP
`, [
item.id,
item.partId || 'part-1',
item.partTitle || '未分类',
item.chapterId || 'chapter-1',
item.chapterTitle || '未分类',
item.title,
item.content || '',
(item.content || '').length,
item.is_free || false,
item.price || 1
])
imported++
} catch (e) {
console.error(`[Book API] 导入章节${item.id}失败:`, e)
failed++
}
}
return NextResponse.json({
success: true,
message: `导入完成:成功 ${imported} 个章节,失败 ${failed}`,
imported,
failed
})
}
return NextResponse.json({
success: false,
error: '无效的操作'
}, { status: 400 })
} catch (error) {
console.error('[Book API] POST错误:', error)
return NextResponse.json({
success: false,
error: '操作失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* PUT - 更新章节内容
* 支持同时更新数据库和文件系统
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, title, content, price, saveToFile = true } = body
if (!id) {
return NextResponse.json({
success: false,
error: '章节ID不能为空'
}, { status: 400 })
}
const sectionInfo = getSectionInfo(id)
// 更新数据库
try {
await query(`
INSERT INTO chapters (id, part_id, part_title, chapter_id, chapter_title, section_title, content, word_count, price, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'published')
ON DUPLICATE KEY UPDATE
section_title = VALUES(section_title),
content = VALUES(content),
word_count = VALUES(word_count),
price = VALUES(price),
updated_at = CURRENT_TIMESTAMP
`, [
id,
sectionInfo?.partId || 'part-1',
sectionInfo?.partTitle || '未分类',
sectionInfo?.chapterId || 'chapter-1',
sectionInfo?.chapterTitle || '未分类',
title || sectionInfo?.section.title || '',
content || '',
(content || '').length,
price ?? sectionInfo?.section.price ?? 1
])
} catch (e) {
console.error('[Book API] 更新数据库失败:', e)
}
// 同时保存到文件系统
if (saveToFile && sectionInfo?.section.filePath) {
const filePath = path.join(process.cwd(), sectionInfo.section.filePath)
try {
// 确保目录存在
const dir = path.dirname(filePath)
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true })
}
fs.writeFileSync(filePath, content || '', 'utf-8')
} catch (e) {
console.error('[Book API] 保存文件失败:', e)
}
}
return NextResponse.json({
success: true,
message: '章节更新成功',
id
})
} catch (error) {
console.error('[Book API] PUT错误:', error)
return NextResponse.json({
success: false,
error: '更新章节失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* DELETE - 删除章节
*/
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json({
success: false,
error: '章节ID不能为空'
}, { status: 400 })
}
try {
// 从数据库删除
await query('DELETE FROM chapters WHERE id = ?', [id])
return NextResponse.json({
success: true,
message: '章节删除成功',
id
})
} catch (error) {
console.error('[Book API] DELETE错误:', error)
return NextResponse.json({
success: false,
error: '删除章节失败: ' + (error as Error).message
}, { status: 500 })
}
}

300
app/api/db/config/route.ts Normal file
View File

@@ -0,0 +1,300 @@
/**
* 系统配置API
* 优先读取数据库配置,失败时读取本地默认配置
* 支持配置的增删改查
*/
import { NextRequest, NextResponse } from 'next/server'
import { query, getConfig, setConfig } from '@/lib/db'
// 本地默认配置(作为数据库备份)
const DEFAULT_CONFIGS: Record<string, any> = {
// 站点配置
site_config: {
siteName: 'Soul创业派对',
siteDescription: '来自派对房的真实商业故事',
logo: '/icon.svg',
keywords: ['创业', 'Soul', '私域运营', '商业案例'],
icp: '',
analytics: ''
},
// 匹配功能配置
match_config: {
matchTypes: [
{ id: 'partner', label: '创业合伙', matchLabel: '创业伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, enabled: true },
{ id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, enabled: true },
{ id: 'mentor', label: '导师顾问', matchLabel: '商业顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, enabled: true },
{ id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, enabled: true }
],
freeMatchLimit: 3,
matchPrice: 1,
settings: {
enableFreeMatches: true,
enablePaidMatches: true,
maxMatchesPerDay: 10
}
},
// 分销配置
referral_config: {
distributorShare: 90,
minWithdrawAmount: 10,
bindingDays: 30,
userDiscount: 5,
enableAutoWithdraw: false
},
// 价格配置
price_config: {
sectionPrice: 1,
fullBookPrice: 9.9,
premiumBookPrice: 19.9,
matchPrice: 1
},
// 支付配置
payment_config: {
wechat: {
enabled: true,
appId: 'wx432c93e275548671',
mchId: '1318592501'
},
alipay: {
enabled: true,
pid: '2088511801157159'
},
wechatGroupUrl: '' // 支付成功后跳转的微信群链接
},
// 书籍配置
book_config: {
totalSections: 62,
freeSections: ['preface', 'epilogue', '1.1', 'appendix-1', 'appendix-2', 'appendix-3'],
latestSectionId: '9.14'
}
}
/**
* GET - 获取配置
* 参数: key - 配置键名,不传则返回所有配置
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
const forceLocal = searchParams.get('forceLocal') === 'true'
try {
if (key) {
// 获取单个配置
let config = null
if (!forceLocal) {
// 优先从数据库读取
try {
config = await getConfig(key)
} catch (e) {
console.log(`[Config API] 数据库读取${key}失败,使用本地配置`)
}
}
// 数据库没有则使用本地默认
if (!config) {
config = DEFAULT_CONFIGS[key] || null
}
if (config) {
return NextResponse.json({
success: true,
key,
config,
source: config === DEFAULT_CONFIGS[key] ? 'local' : 'database'
})
}
return NextResponse.json({
success: false,
error: '配置不存在'
}, { status: 404 })
}
// 获取所有配置
const allConfigs: Record<string, any> = {}
const sources: Record<string, string> = {}
for (const configKey of Object.keys(DEFAULT_CONFIGS)) {
let config = null
if (!forceLocal) {
try {
config = await getConfig(configKey)
} catch (e) {
// 忽略数据库错误
}
}
if (config) {
allConfigs[configKey] = config
sources[configKey] = 'database'
} else {
allConfigs[configKey] = DEFAULT_CONFIGS[configKey]
sources[configKey] = 'local'
}
}
return NextResponse.json({
success: true,
configs: allConfigs,
sources
})
} catch (error) {
console.error('[Config API] GET错误:', error)
return NextResponse.json({
success: false,
error: '获取配置失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* POST - 保存配置到数据库
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { key, config, description } = body
if (!key || !config) {
return NextResponse.json({
success: false,
error: '配置键名和配置值不能为空'
}, { status: 400 })
}
// 保存到数据库
const success = await setConfig(key, config, description)
if (success) {
return NextResponse.json({
success: true,
message: '配置保存成功',
key
})
} else {
return NextResponse.json({
success: false,
error: '配置保存失败'
}, { status: 500 })
}
} catch (error) {
console.error('[Config API] POST错误:', error)
return NextResponse.json({
success: false,
error: '保存配置失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* PUT - 批量更新配置
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { configs } = body
if (!configs || typeof configs !== 'object') {
return NextResponse.json({
success: false,
error: '配置数据格式错误'
}, { status: 400 })
}
let successCount = 0
let failedCount = 0
for (const [key, config] of Object.entries(configs)) {
try {
const success = await setConfig(key, config)
if (success) {
successCount++
} else {
failedCount++
}
} catch (e) {
failedCount++
}
}
return NextResponse.json({
success: true,
message: `配置更新完成:成功${successCount}个,失败${failedCount}`,
successCount,
failedCount
})
} catch (error) {
console.error('[Config API] PUT错误:', error)
return NextResponse.json({
success: false,
error: '更新配置失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* DELETE - 删除配置(恢复为本地默认)
*/
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
if (!key) {
return NextResponse.json({
success: false,
error: '配置键名不能为空'
}, { status: 400 })
}
try {
await query('DELETE FROM system_config WHERE config_key = ?', [key])
return NextResponse.json({
success: true,
message: '配置已删除,将使用本地默认值',
key
})
} catch (error) {
console.error('[Config API] DELETE错误:', error)
return NextResponse.json({
success: false,
error: '删除配置失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* 初始化:将本地配置同步到数据库
*/
export async function syncLocalToDatabase() {
console.log('[Config] 开始同步本地配置到数据库...')
for (const [key, config] of Object.entries(DEFAULT_CONFIGS)) {
try {
// 检查数据库是否已有该配置
const existing = await getConfig(key)
if (!existing) {
// 数据库没有,则写入
await setConfig(key, config, `默认${key}配置`)
console.log(`[Config] 同步配置: ${key}`)
}
} catch (e) {
console.error(`[Config] 同步${key}失败:`, e)
}
}
console.log('[Config] 配置同步完成')
}

View File

@@ -1,83 +1,173 @@
/** /**
* 数据库初始化API * 数据库初始化/升级API
* 创建数据库表结构和默认配置 * 用于添加缺失的字段,确保表结构完整
*/ */
import { NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { initDatabase } from '@/lib/db' import { query } from '@/lib/db'
/** /**
* POST - 初始化数据库 * GET - 初始化/升级数据库表结构
*/ */
export async function POST(request: Request) { export async function GET(request: NextRequest) {
const results: string[] = []
try { try {
const body = await request.json() console.log('[DB Init] 开始检查并升级数据库结构...')
const { adminToken } = body
// 1. 检查users表是否存在
// 简单的管理员验证 try {
if (adminToken !== 'init_db_2025') { await query('SELECT 1 FROM users LIMIT 1')
return NextResponse.json({ results.push('✅ users表已存在')
success: false, } catch (e) {
error: '无权限执行此操作' // 创建users表
}, { status: 403 }) await query(`
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(50) PRIMARY KEY,
open_id VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
nickname VARCHAR(100),
avatar VARCHAR(500),
phone VARCHAR(20),
password VARCHAR(100),
wechat_id VARCHAR(100),
referral_code VARCHAR(20) UNIQUE,
referred_by VARCHAR(50),
purchased_sections JSON DEFAULT '[]',
has_full_book BOOLEAN DEFAULT FALSE,
is_admin BOOLEAN DEFAULT FALSE,
earnings DECIMAL(10,2) DEFAULT 0,
pending_earnings DECIMAL(10,2) DEFAULT 0,
withdrawn_earnings DECIMAL(10,2) DEFAULT 0,
referral_count INT DEFAULT 0,
match_count_today INT DEFAULT 0,
last_match_date DATE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
results.push('✅ 创建users表')
} }
console.log('[DB Init] 开始初始化数据库...') // 2. 修改open_id字段允许NULL后台添加用户时可能没有openId
try {
await initDatabase() await query('ALTER TABLE users MODIFY COLUMN open_id VARCHAR(100) NULL')
results.push('✅ 修改open_id允许NULL')
console.log('[DB Init] 数据库初始化完成') } catch (e: any) {
results.push(`⏭️ open_id字段: ${e.message?.includes('Duplicate') ? '已处理' : e.message}`)
}
// 3. 添加可能缺失的字段用ALTER TABLE
const columnsToAdd = [
{ name: 'password', type: 'VARCHAR(100)' },
{ name: 'session_key', type: 'VARCHAR(100)' },
{ name: 'referred_by', type: 'VARCHAR(50)' },
{ name: 'is_admin', type: 'BOOLEAN DEFAULT FALSE' },
{ name: 'match_count_today', type: 'INT DEFAULT 0' },
{ name: 'last_match_date', type: 'DATE' },
{ name: 'withdrawn_earnings', type: 'DECIMAL(10,2) DEFAULT 0' },
{ name: 'avatar', type: 'VARCHAR(500)' },
{ name: 'wechat_id', type: 'VARCHAR(100)' }
]
for (const col of columnsToAdd) {
try {
// 先检查列是否存在
const checkResult = await query(`
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME = ?
`, [col.name]) as any[]
if (checkResult.length === 0) {
// 列不存在,添加
await query(`ALTER TABLE users ADD COLUMN ${col.name} ${col.type}`)
results.push(`✅ 添加字段: ${col.name}`)
} else {
results.push(`⏭️ 字段已存在: ${col.name}`)
}
} catch (e: any) {
results.push(`⚠️ 处理字段${col.name}时出错: ${e.message}`)
}
}
// 3. 添加索引(如果不存在)
const indexesToAdd = [
{ name: 'idx_open_id', column: 'open_id' },
{ name: 'idx_phone', column: 'phone' },
{ name: 'idx_referral_code', column: 'referral_code' },
{ name: 'idx_referred_by', column: 'referred_by' }
]
for (const idx of indexesToAdd) {
try {
const checkResult = await query(`
SHOW INDEX FROM users WHERE Key_name = ?
`, [idx.name]) as any[]
if (checkResult.length === 0) {
await query(`CREATE INDEX ${idx.name} ON users(${idx.column})`)
results.push(`✅ 添加索引: ${idx.name}`)
}
} catch (e: any) {
// 忽略索引错误
}
}
// 4. 检查提现记录表
try {
await query('SELECT 1 FROM withdrawals LIMIT 1')
results.push('✅ withdrawals表已存在')
} catch (e) {
await query(`
CREATE TABLE IF NOT EXISTS withdrawals (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status ENUM('pending', 'processing', 'success', 'failed') DEFAULT 'pending',
wechat_openid VARCHAR(100),
transaction_id VARCHAR(100),
error_message VARCHAR(500),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
results.push('✅ 创建withdrawals表')
}
// 5. 检查系统配置表
try {
await query('SELECT 1 FROM system_config LIMIT 1')
results.push('✅ system_config表已存在')
} catch (e) {
await query(`
CREATE TABLE IF NOT EXISTS system_config (
id INT AUTO_INCREMENT PRIMARY KEY,
config_key VARCHAR(100) UNIQUE NOT NULL,
config_value JSON NOT NULL,
description VARCHAR(200),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`)
results.push('✅ 创建system_config表')
}
console.log('[DB Init] 数据库升级完成')
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { message: '数据库初始化/升级完成',
message: '数据库初始化成功', results
timestamp: new Date().toISOString()
}
}) })
} catch (error) { } catch (error) {
console.error('[DB Init] 数据库初始化失败:', error) console.error('[DB Init] 错误:', error)
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
error: '数据库初始化失败: ' + (error as Error).message error: '数据库初始化失败: ' + (error as Error).message,
results
}, { status: 500 }) }, { status: 500 })
} }
} }
/**
* GET - 检查数据库状态
*/
export async function GET() {
try {
const { query } = await import('@/lib/db')
// 检查数据库连接
await query('SELECT 1')
// 检查表是否存在
const tables = await query(`
SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE()
`) as any[]
const tableNames = tables.map(t => t.TABLE_NAME)
return NextResponse.json({
success: true,
data: {
connected: true,
tables: tableNames,
tablesCount: tableNames.length
}
})
} catch (error) {
console.error('[DB Status] 检查数据库状态失败:', error)
return NextResponse.json({
success: false,
error: '数据库连接失败: ' + (error as Error).message
}, { status: 500 })
}
}

View File

@@ -0,0 +1,105 @@
/**
* 用户绑定关系API
* 获取指定用户的所有绑定用户列表
*/
import { NextResponse } from 'next/server'
import { query } from '@/lib/db'
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const referralCode = searchParams.get('code')
if (!userId && !referralCode) {
return NextResponse.json({
success: false,
error: '缺少用户ID或推广码'
}, { status: 400 })
}
// 如果传入userId先获取该用户的推广码
let code = referralCode
if (userId && !referralCode) {
const userRows = await query('SELECT referral_code FROM users WHERE id = ?', [userId]) as any[]
if (userRows.length === 0) {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
code = userRows[0].referral_code
}
if (!code) {
return NextResponse.json({
success: true,
referrals: [],
stats: {
total: 0,
purchased: 0,
pendingEarnings: 0,
totalEarnings: 0
}
})
}
// 查询通过该推广码绑定的所有用户
const referrals = await query(`
SELECT
id, nickname, avatar, phone, open_id,
has_full_book, purchased_sections,
created_at, updated_at
FROM users
WHERE referred_by = ?
ORDER BY created_at DESC
`, [code]) as any[]
// 统计信息
const purchasedCount = referrals.filter(r => r.has_full_book || (r.purchased_sections && r.purchased_sections !== '[]')).length
// 查询该用户的收益信息
const earningsRows = await query(`
SELECT earnings, pending_earnings, withdrawn_earnings
FROM users WHERE ${userId ? 'id = ?' : 'referral_code = ?'}
`, [userId || code]) as any[]
const earnings = earningsRows[0] || { earnings: 0, pending_earnings: 0, withdrawn_earnings: 0 }
// 格式化返回数据
const formattedReferrals = referrals.map(r => ({
id: r.id,
nickname: r.nickname || '微信用户',
avatar: r.avatar,
phone: r.phone ? r.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : null,
hasOpenId: !!r.open_id,
hasPurchased: r.has_full_book || (r.purchased_sections && r.purchased_sections !== '[]'),
hasFullBook: !!r.has_full_book,
purchasedSections: typeof r.purchased_sections === 'string'
? JSON.parse(r.purchased_sections || '[]').length
: 0,
createdAt: r.created_at,
status: r.has_full_book ? 'vip' : (r.purchased_sections && r.purchased_sections !== '[]' ? 'paid' : 'free')
}))
return NextResponse.json({
success: true,
referrals: formattedReferrals,
stats: {
total: referrals.length,
purchased: purchasedCount,
free: referrals.length - purchasedCount,
earnings: parseFloat(earnings.earnings) || 0,
pendingEarnings: parseFloat(earnings.pending_earnings) || 0,
withdrawnEarnings: parseFloat(earnings.withdrawn_earnings) || 0
}
})
} catch (error) {
console.error('Get referrals error:', error)
return NextResponse.json({
success: false,
error: '获取绑定关系失败'
}, { status: 500 })
}
}

262
app/api/db/users/route.ts Normal file
View File

@@ -0,0 +1,262 @@
/**
* 用户管理API
* 提供用户的CRUD操作
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
// 生成用户ID
function generateUserId(): string {
return 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9)
}
// 生成推荐码
function generateReferralCode(seed: string): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const hash = seed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
let code = 'SOUL'
for (let i = 0; i < 4; i++) {
code += chars.charAt((hash + i * 7) % chars.length)
}
return code
}
/**
* GET - 获取用户列表
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
const phone = searchParams.get('phone')
const openId = searchParams.get('openId')
try {
// 获取单个用户
if (id) {
const users = await query('SELECT * FROM users WHERE id = ?', [id]) as any[]
if (users.length > 0) {
return NextResponse.json({ success: true, user: users[0] })
}
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
// 通过手机号查询
if (phone) {
const users = await query('SELECT * FROM users WHERE phone = ?', [phone]) as any[]
if (users.length > 0) {
return NextResponse.json({ success: true, user: users[0] })
}
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
// 通过openId查询
if (openId) {
const users = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
if (users.length > 0) {
return NextResponse.json({ success: true, user: users[0] })
}
return NextResponse.json({ success: false, error: '用户不存在' }, { status: 404 })
}
// 获取所有用户
const users = await query(`
SELECT
id, open_id, nickname, phone, wechat_id, avatar,
referral_code, has_full_book, is_admin,
earnings, pending_earnings, referral_count,
match_count_today, last_match_date,
created_at, updated_at
FROM users
ORDER BY created_at DESC
LIMIT 500
`) as any[]
return NextResponse.json({
success: true,
users,
total: users.length
})
} catch (error) {
console.error('[Users API] GET错误:', error)
return NextResponse.json({
success: false,
error: '获取用户失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* POST - 创建用户(注册)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { openId, phone, nickname, password, wechatId, avatar, referredBy, is_admin } = body
// 检查openId或手机号是否已存在
if (openId) {
const existing = await query('SELECT id FROM users WHERE open_id = ?', [openId]) as any[]
if (existing.length > 0) {
// 已存在,返回现有用户
const users = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
return NextResponse.json({ success: true, user: users[0], isNew: false })
}
}
if (phone) {
const existing = await query('SELECT id FROM users WHERE phone = ?', [phone]) as any[]
if (existing.length > 0) {
return NextResponse.json({ success: false, error: '该手机号已注册' }, { status: 400 })
}
}
// 生成用户ID和推荐码
const userId = generateUserId()
const referralCode = generateReferralCode(openId || phone || userId)
// 创建用户
await query(`
INSERT INTO users (
id, open_id, phone, nickname, password, wechat_id, avatar,
referral_code, referred_by, has_full_book, is_admin,
earnings, pending_earnings, referral_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, FALSE, ?, 0, 0, 0)
`, [
userId,
openId || null,
phone || null,
nickname || '用户' + userId.slice(-4),
password || null,
wechatId || null,
avatar || null,
referralCode,
referredBy || null,
is_admin || false
])
// 返回新用户
const users = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
return NextResponse.json({
success: true,
user: users[0],
isNew: true,
message: '用户创建成功'
})
} catch (error) {
console.error('[Users API] POST错误:', error)
return NextResponse.json({
success: false,
error: '创建用户失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* PUT - 更新用户
*/
export async function PUT(request: NextRequest) {
try {
const body = await request.json()
const { id, nickname, phone, wechatId, avatar, password, has_full_book, is_admin, purchasedSections, earnings, pending_earnings } = body
if (!id) {
return NextResponse.json({ success: false, error: '用户ID不能为空' }, { status: 400 })
}
// 构建更新字段
const updates: string[] = []
const values: any[] = []
if (nickname !== undefined) {
updates.push('nickname = ?')
values.push(nickname)
}
if (phone !== undefined) {
updates.push('phone = ?')
values.push(phone)
}
if (wechatId !== undefined) {
updates.push('wechat_id = ?')
values.push(wechatId)
}
if (avatar !== undefined) {
updates.push('avatar = ?')
values.push(avatar)
}
if (password !== undefined) {
updates.push('password = ?')
values.push(password)
}
if (has_full_book !== undefined) {
updates.push('has_full_book = ?')
values.push(has_full_book)
}
if (is_admin !== undefined) {
updates.push('is_admin = ?')
values.push(is_admin)
}
if (purchasedSections !== undefined) {
updates.push('purchased_sections = ?')
values.push(JSON.stringify(purchasedSections))
}
if (earnings !== undefined) {
updates.push('earnings = ?')
values.push(earnings)
}
if (pending_earnings !== undefined) {
updates.push('pending_earnings = ?')
values.push(pending_earnings)
}
if (updates.length === 0) {
return NextResponse.json({ success: false, error: '没有需要更新的字段' }, { status: 400 })
}
values.push(id)
await query(`UPDATE users SET ${updates.join(', ')}, updated_at = NOW() WHERE id = ?`, values)
return NextResponse.json({
success: true,
message: '用户更新成功'
})
} catch (error) {
console.error('[Users API] PUT错误:', error)
return NextResponse.json({
success: false,
error: '更新用户失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* DELETE - 删除用户
*/
export async function DELETE(request: NextRequest) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json({ success: false, error: '用户ID不能为空' }, { status: 400 })
}
try {
await query('DELETE FROM users WHERE id = ?', [id])
return NextResponse.json({
success: true,
message: '用户删除成功'
})
} catch (error) {
console.error('[Users API] DELETE错误:', error)
return NextResponse.json({
success: false,
error: '删除用户失败: ' + (error as Error).message
}, { status: 500 })
}
}

View File

@@ -1,167 +1,74 @@
/** /**
* 匹配规则配置API * 匹配配置API
* 管理后台匹配类型和规则配置 * 获取匹配类型和价格配置
*/ */
import { NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { getConfig } from '@/lib/db'
// 默认匹配类型配置 // 默认匹配配置
const DEFAULT_MATCH_TYPES = [ const DEFAULT_MATCH_CONFIG = {
{ matchTypes: [
id: 'partner', { id: 'partner', label: '创业合伙', matchLabel: '创业伙伴', icon: '⭐', matchFromDB: true, showJoinAfterMatch: false, price: 1, enabled: true },
label: '创业合伙', { id: 'investor', label: '资源对接', matchLabel: '资源对接', icon: '👥', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
matchLabel: '创业伙伴', { id: 'mentor', label: '导师顾问', matchLabel: '商业顾问', icon: '❤️', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true },
icon: '', { id: 'team', label: '团队招募', matchLabel: '加入项目', icon: '🎮', matchFromDB: false, showJoinAfterMatch: true, price: 1, enabled: true }
matchFromDB: true, ],
showJoinAfterMatch: false, freeMatchLimit: 3,
description: '寻找志同道合的创业伙伴,共同打造事业', matchPrice: 1,
enabled: true settings: {
}, enableFreeMatches: true,
{ enablePaidMatches: true,
id: 'investor', maxMatchesPerDay: 10
label: '资源对接',
matchLabel: '资源对接',
icon: '👥',
matchFromDB: false,
showJoinAfterMatch: true,
description: '对接各类商业资源,拓展合作机会',
enabled: true
},
{
id: 'mentor',
label: '导师顾问',
matchLabel: '商业顾问',
icon: '❤️',
matchFromDB: false,
showJoinAfterMatch: true,
description: '寻找行业导师,获得专业指导',
enabled: true
},
{
id: 'team',
label: '团队招募',
matchLabel: '加入项目',
icon: '🎮',
matchFromDB: false,
showJoinAfterMatch: true,
description: '招募团队成员,扩充项目人才',
enabled: true
}
]
/**
* GET - 获取匹配类型配置
*/
export async function GET(request: Request) {
try {
console.log('[MatchConfig] 获取匹配配置')
// TODO: 从数据库获取配置
// 这里应该从数据库读取管理员配置的匹配类型
const matchTypes = DEFAULT_MATCH_TYPES.filter(type => type.enabled)
return NextResponse.json({
success: true,
data: {
matchTypes,
freeMatchLimit: 3, // 每日免费匹配次数
matchPrice: 1, // 付费匹配价格(元)
settings: {
enableFreeMatches: true,
enablePaidMatches: true,
maxMatchesPerDay: 10
}
}
})
} catch (error) {
console.error('[MatchConfig] 获取匹配配置失败:', error)
return NextResponse.json({
success: false,
error: '获取匹配配置失败'
}, { status: 500 })
} }
} }
/** /**
* POST - 更新匹配类型配置(管理员功能) * GET - 获取匹配配置
*/ */
export async function POST(request: Request) { export async function GET(request: NextRequest) {
try { try {
const body = await request.json() // 优先从数据库读取
const { matchTypes, settings, adminToken } = body let config = null
try {
// TODO: 验证管理员权限 config = await getConfig('match_config')
if (!adminToken || adminToken !== 'admin_token_placeholder') { } catch (e) {
return NextResponse.json({ console.log('[MatchConfig] 数据库读取失败,使用默认配置')
success: false,
error: '无权限操作'
}, { status: 403 })
} }
console.log('[MatchConfig] 更新匹配配置:', { matchTypes: matchTypes?.length, settings }) // 合并默认配置
const finalConfig = {
// TODO: 保存到数据库 ...DEFAULT_MATCH_CONFIG,
// 这里应该将配置保存到数据库 ...(config || {})
}
// 只返回启用的匹配类型
const enabledTypes = finalConfig.matchTypes.filter((t: any) => t.enabled !== false)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
message: '配置更新成功', matchTypes: enabledTypes,
updatedAt: new Date().toISOString() freeMatchLimit: finalConfig.freeMatchLimit,
} matchPrice: finalConfig.matchPrice,
settings: finalConfig.settings
},
source: config ? 'database' : 'default'
}) })
} catch (error) { } catch (error) {
console.error('[MatchConfig] 更新匹配配置失败:', error) console.error('[MatchConfig] GET错误:', error)
// 出错时返回默认配置
return NextResponse.json({ return NextResponse.json({
success: false, success: true,
error: '更新匹配配置失败' data: {
}, { status: 500 }) matchTypes: DEFAULT_MATCH_CONFIG.matchTypes,
freeMatchLimit: DEFAULT_MATCH_CONFIG.freeMatchLimit,
matchPrice: DEFAULT_MATCH_CONFIG.matchPrice,
settings: DEFAULT_MATCH_CONFIG.settings
},
source: 'fallback'
})
} }
} }
/**
* PUT - 启用/禁用特定匹配类型
*/
export async function PUT(request: Request) {
try {
const body = await request.json()
const { typeId, enabled, adminToken } = body
if (!adminToken || adminToken !== 'admin_token_placeholder') {
return NextResponse.json({
success: false,
error: '无权限操作'
}, { status: 403 })
}
if (!typeId || typeof enabled !== 'boolean') {
return NextResponse.json({
success: false,
error: '参数错误'
}, { status: 400 })
}
console.log('[MatchConfig] 切换匹配类型状态:', { typeId, enabled })
// TODO: 更新数据库中的匹配类型状态
return NextResponse.json({
success: true,
data: {
typeId,
enabled,
updatedAt: new Date().toISOString()
}
})
} catch (error) {
console.error('[MatchConfig] 切换匹配类型状态失败:', error)
return NextResponse.json({
success: false,
error: '操作失败'
}, { status: 500 })
}
}

View File

@@ -61,36 +61,93 @@ export async function POST(request: Request) {
}, { status: 500 }) }, { status: 500 })
} }
// 创建或更新用户 // 创建或更新用户 - 连接数据库
// TODO: 这里应该连接数据库操作 let user: any = null
const user = { let isNewUser = false
id: `user_${openId.slice(-8)}`,
openId, try {
nickname: '微信用户', const { query } = await import('@/lib/db')
avatar: '',
referralCode: 'SOUL' + Date.now().toString(36).toUpperCase().slice(-6), // 查询用户是否存在
purchasedSections: [], const existingUsers = await query('SELECT * FROM users WHERE open_id = ?', [openId]) as any[]
hasFullBook: false,
earnings: 0, if (existingUsers.length > 0) {
pendingEarnings: 0, // 用户已存在更新session_key
referralCount: 0, user = existingUsers[0]
createdAt: new Date().toISOString() await query('UPDATE users SET session_key = ?, updated_at = NOW() WHERE open_id = ?', [sessionKey, openId])
console.log('[MiniLogin] 用户已存在:', user.id)
} else {
// 创建新用户 - 使用openId作为用户ID与微信官方标识保持一致
isNewUser = true
const userId = openId // 直接使用openId作为用户ID
const referralCode = 'SOUL' + openId.slice(-6).toUpperCase()
const nickname = '微信用户' + openId.slice(-4)
await query(`
INSERT INTO users (
id, open_id, session_key, nickname, avatar, referral_code,
has_full_book, purchased_sections, earnings, pending_earnings, referral_count
) VALUES (?, ?, ?, ?, ?, ?, FALSE, '[]', 0, 0, 0)
`, [
userId, openId, sessionKey, nickname,
'', // 头像留空,等用户授权
referralCode
])
const newUsers = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
user = newUsers[0]
console.log('[MiniLogin] 新用户创建成功, ID=openId:', userId.slice(0, 10) + '...')
}
} catch (dbError) {
console.error('[MiniLogin] 数据库操作失败:', dbError)
// 数据库失败时使用openId作为临时用户ID
user = {
id: openId, // 使用openId作为用户ID
open_id: openId,
nickname: '微信用户',
avatar: '',
referral_code: 'SOUL' + openId.slice(-6).toUpperCase(),
purchased_sections: '[]',
has_full_book: false,
earnings: 0,
pending_earnings: 0,
referral_count: 0,
created_at: new Date().toISOString()
}
}
// 统一用户数据格式
const responseUser = {
id: user.id,
openId: user.open_id || openId,
nickname: user.nickname,
avatar: user.avatar,
phone: user.phone,
wechatId: user.wechat_id,
referralCode: user.referral_code,
hasFullBook: user.has_full_book || false,
purchasedSections: typeof user.purchased_sections === 'string'
? JSON.parse(user.purchased_sections || '[]')
: (user.purchased_sections || []),
earnings: parseFloat(user.earnings) || 0,
pendingEarnings: parseFloat(user.pending_earnings) || 0,
referralCount: user.referral_count || 0,
createdAt: user.created_at
} }
// 生成token // 生成token
const token = `tk_${openId.slice(-8)}_${Date.now()}` const token = `tk_${openId.slice(-8)}_${Date.now()}`
console.log('[MiniLogin] 登录成功, userId:', user.id) console.log('[MiniLogin] 登录成功, userId:', responseUser.id, isNewUser ? '(新用户)' : '(老用户)')
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
openId, openId,
sessionKey, // 注意生产环境不应返回sessionKey给前端 user: responseUser,
unionId,
user,
token, token,
} },
isNewUser
}) })
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,201 @@
/**
* 推荐码绑定API
* 用于处理分享带来的推荐关系绑定
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
/**
* POST - 绑定推荐关系
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, referralCode, openId } = body
// 验证参数
const effectiveUserId = userId || (openId ? `user_${openId.slice(-8)}` : null)
if (!effectiveUserId || !referralCode) {
return NextResponse.json({
success: false,
error: '用户ID和推荐码不能为空'
}, { status: 400 })
}
// 查找推荐人
const referrers = await query(
'SELECT id, nickname, referral_code FROM users WHERE referral_code = ?',
[referralCode]
) as any[]
if (referrers.length === 0) {
return NextResponse.json({
success: false,
error: '推荐码无效'
}, { status: 400 })
}
const referrer = referrers[0]
// 不能自己推荐自己
if (referrer.id === effectiveUserId) {
return NextResponse.json({
success: false,
error: '不能使用自己的推荐码'
}, { status: 400 })
}
// 检查用户是否已有推荐人
const users = await query(
'SELECT id, referred_by FROM users WHERE id = ? OR open_id = ?',
[effectiveUserId, openId || effectiveUserId]
) as any[]
if (users.length === 0) {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 400 })
}
const user = users[0]
if (user.referred_by) {
return NextResponse.json({
success: false,
error: '已绑定其他推荐人'
}, { status: 400 })
}
// 绑定推荐关系
await query(
'UPDATE users SET referred_by = ? WHERE id = ?',
[referrer.id, user.id]
)
// 更新推荐人的推广数量
await query(
'UPDATE users SET referral_count = referral_count + 1 WHERE id = ?',
[referrer.id]
)
// 创建推荐绑定记录
const bindingId = 'bind_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 6)
const expiryDate = new Date()
expiryDate.setDate(expiryDate.getDate() + 30) // 30天有效期
try {
await query(`
INSERT INTO referral_bindings (
id, referrer_id, referee_id, referral_code, status, expiry_date
) VALUES (?, ?, ?, ?, 'active', ?)
`, [bindingId, referrer.id, user.id, referralCode, expiryDate])
} catch (e) {
console.log('[Referral Bind] 创建绑定记录失败(可能是重复绑定):', e)
}
console.log(`[Referral Bind] 成功: ${user.id} -> ${referrer.id} (${referralCode})`)
return NextResponse.json({
success: true,
message: '绑定成功',
referrer: {
id: referrer.id,
nickname: referrer.nickname
}
})
} catch (error) {
console.error('[Referral Bind] 错误:', error)
return NextResponse.json({
success: false,
error: '绑定失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* GET - 查询推荐关系
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const referralCode = searchParams.get('referralCode')
try {
if (referralCode) {
// 查询推荐码对应的用户
const users = await query(
'SELECT id, nickname, avatar FROM users WHERE referral_code = ?',
[referralCode]
) as any[]
if (users.length === 0) {
return NextResponse.json({
success: false,
error: '推荐码无效'
}, { status: 404 })
}
return NextResponse.json({
success: true,
referrer: users[0]
})
}
if (userId) {
// 查询用户的推荐关系
const users = await query(
'SELECT id, referred_by FROM users WHERE id = ?',
[userId]
) as any[]
if (users.length === 0) {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
const user = users[0]
// 如果有推荐人,获取推荐人信息
let referrer = null
if (user.referred_by) {
const referrers = await query(
'SELECT id, nickname, avatar FROM users WHERE id = ?',
[user.referred_by]
) as any[]
if (referrers.length > 0) {
referrer = referrers[0]
}
}
// 获取该用户推荐的人
const referees = await query(
'SELECT id, nickname, avatar, created_at FROM users WHERE referred_by = ?',
[userId]
) as any[]
return NextResponse.json({
success: true,
referrer,
referees,
referralCount: referees.length
})
}
return NextResponse.json({
success: false,
error: '请提供userId或referralCode参数'
}, { status: 400 })
} catch (error) {
console.error('[Referral Bind] GET错误:', error)
return NextResponse.json({
success: false,
error: '查询失败: ' + (error as Error).message
}, { status: 500 })
}
}

View File

@@ -1,95 +1,142 @@
/** /**
* 推广中心数据API * 分销数据API
* 获取用户推广数据、绑定关系等 * 获取用户推广数据、绑定用户列表、收益统计
*/ */
import { NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
/** /**
* GET - 获取用户推广数据 * GET - 获取分销数据
*/ */
export async function GET(request: Request) { export async function GET(request: NextRequest) {
try { const { searchParams } = new URL(request.url)
const { searchParams } = new URL(request.url) const userId = searchParams.get('userId')
const userId = searchParams.get('userId')
if (!userId) {
if (!userId) {
return NextResponse.json({
success: false,
error: '缺少用户ID'
}, { status: 400 })
}
console.log('[ReferralData] 获取推广数据, userId:', userId)
// TODO: 从数据库获取真实数据
// 这里应该连接数据库查询用户的推广数据
// 模拟数据结构
const mockData = {
earnings: 0,
pendingEarnings: 0,
referralCount: 0,
activeBindings: [],
convertedBindings: [],
expiredBindings: [],
referralCode: `SOUL${userId.slice(-6).toUpperCase()}`
}
return NextResponse.json({
success: true,
data: mockData
})
} catch (error) {
console.error('[ReferralData] 获取推广数据失败:', error)
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
error: '获取推广数据失败' error: '用户ID不能为空'
}, { status: 500 }) }, { status: 400 })
} }
}
/**
* POST - 创建推广绑定关系
*/
export async function POST(request: Request) {
try { try {
const body = await request.json() // 1. 获取用户基本信息
const { referralCode, userId, userInfo } = body const users = await query(`
SELECT id, nickname, referral_code, earnings, pending_earnings,
if (!referralCode || !userId) { withdrawn_earnings, referral_count
FROM users WHERE id = ?
`, [userId]) as any[]
if (users.length === 0) {
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
error: '缺少必要参数' error: '用户不存在'
}, { status: 400 }) }, { status: 404 })
}
const user = users[0]
// 2. 获取推荐的用户列表
const referees = await query(`
SELECT id, nickname, avatar, phone, wechat_id,
has_full_book, created_at,
DATEDIFF(DATE_ADD(created_at, INTERVAL 30 DAY), NOW()) as days_remaining
FROM users
WHERE referred_by = ?
ORDER BY created_at DESC
`, [userId]) as any[]
// 3. 分类绑定用户
const now = new Date()
const activeBindings: any[] = []
const convertedBindings: any[] = []
const expiredBindings: any[] = []
for (const referee of referees) {
const binding = {
id: referee.id,
nickname: referee.nickname || '用户' + referee.id.slice(-4),
avatar: referee.avatar || `https://picsum.photos/100/100?random=${referee.id.slice(-2)}`,
phone: referee.phone ? referee.phone.slice(0, 3) + '****' + referee.phone.slice(-4) : null,
hasFullBook: referee.has_full_book,
daysRemaining: Math.max(0, referee.days_remaining || 0),
createdAt: referee.created_at
}
if (referee.has_full_book) {
// 已转化(已购买)
convertedBindings.push(binding)
} else if (binding.daysRemaining <= 0) {
// 已过期
expiredBindings.push(binding)
} else {
// 活跃中
activeBindings.push(binding)
}
}
// 4. 获取收益明细(最近的订单)
let earningsDetails: any[] = []
try {
earningsDetails = await query(`
SELECT o.id, o.amount, o.product_type, o.created_at,
u.nickname as buyer_nickname
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.referred_by = ? AND o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 20
`, [userId]) as any[]
} catch (e) {
// 订单表可能不存在,忽略
}
// 5. 统计数据
const stats = {
totalReferrals: referees.length,
activeCount: activeBindings.length,
convertedCount: convertedBindings.length,
expiredCount: expiredBindings.length,
expiringCount: activeBindings.filter(b => b.daysRemaining <= 7).length
} }
console.log('[ReferralData] 创建绑定关系:', { referralCode, userId })
// TODO: 数据库操作
// 1. 根据referralCode查找推广者
// 2. 创建绑定关系记录
// 3. 设置30天过期时间
// 模拟成功响应
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: { data: {
bindingId: `binding_${Date.now()}`, // 收益数据
referrerId: `referrer_${referralCode}`, earnings: parseFloat(user.earnings) || 0,
userId, pendingEarnings: parseFloat(user.pending_earnings) || 0,
bindingDate: new Date().toISOString(), withdrawnEarnings: parseFloat(user.withdrawn_earnings) || 0,
expiryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
status: 'active' // 推荐码
referralCode: user.referral_code,
referralCount: user.referral_count || referees.length,
// 绑定用户分类
activeBindings,
convertedBindings,
expiredBindings,
// 统计
stats,
// 收益明细
earningsDetails: earningsDetails.map(e => ({
id: e.id,
amount: parseFloat(e.amount) * 0.9, // 90%佣金
productType: e.product_type,
buyerNickname: e.buyer_nickname,
createdAt: e.created_at
}))
} }
}) })
} catch (error) { } catch (error) {
console.error('[ReferralData] 创建绑定关系失败:', error) console.error('[ReferralData] 错误:', error)
return NextResponse.json({ return NextResponse.json({
success: false, success: false,
error: '创建绑定关系失败' error: '获取分销数据失败: ' + (error as Error).message
}, { status: 500 }) }, { status: 500 })
} }
} }

273
app/api/search/route.ts Normal file
View File

@@ -0,0 +1,273 @@
/**
* 搜索API
* 支持从数据库搜索标题和内容
* 同时支持搜索匹配的人和事情(隐藏功能)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import { bookData } from '@/lib/book-data'
import fs from 'fs'
import path from 'path'
/**
* 从文件系统搜索章节
*/
function searchFromFiles(keyword: string): any[] {
const results: any[] = []
const lowerKeyword = keyword.toLowerCase()
for (const part of bookData) {
for (const chapter of part.chapters) {
for (const section of chapter.sections) {
// 搜索标题
if (section.title.toLowerCase().includes(lowerKeyword)) {
results.push({
id: section.id,
title: section.title,
partTitle: part.title,
chapterTitle: chapter.title,
price: section.price,
isFree: section.isFree,
matchType: 'title',
score: 10 // 标题匹配得分更高
})
continue
}
// 搜索内容
const filePath = path.join(process.cwd(), section.filePath)
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8')
if (content.toLowerCase().includes(lowerKeyword)) {
// 提取匹配的上下文
const lowerContent = content.toLowerCase()
const matchIndex = lowerContent.indexOf(lowerKeyword)
const start = Math.max(0, matchIndex - 50)
const end = Math.min(content.length, matchIndex + keyword.length + 50)
const snippet = content.substring(start, end)
results.push({
id: section.id,
title: section.title,
partTitle: part.title,
chapterTitle: chapter.title,
price: section.price,
isFree: section.isFree,
matchType: 'content',
snippet: (start > 0 ? '...' : '') + snippet + (end < content.length ? '...' : ''),
score: 5 // 内容匹配得分较低
})
}
} catch (e) {
// 忽略读取错误
}
}
}
}
}
// 按得分排序
return results.sort((a, b) => b.score - a.score)
}
/**
* 从数据库搜索章节
*/
async function searchFromDB(keyword: string): Promise<any[]> {
try {
const results = await query(`
SELECT
id,
section_title as title,
part_title as partTitle,
chapter_title as chapterTitle,
price,
is_free as isFree,
CASE
WHEN section_title LIKE ? THEN 'title'
ELSE 'content'
END as matchType,
CASE
WHEN section_title LIKE ? THEN 10
ELSE 5
END as score,
SUBSTRING(content,
GREATEST(1, LOCATE(?, content) - 50),
150
) as snippet
FROM chapters
WHERE section_title LIKE ?
OR content LIKE ?
ORDER BY score DESC, id ASC
LIMIT 50
`, [
`%${keyword}%`,
`%${keyword}%`,
keyword,
`%${keyword}%`,
`%${keyword}%`
]) as any[]
return results
} catch (e) {
console.error('[Search API] 数据库搜索失败:', e)
return []
}
}
/**
* 提取文章中的人物信息(隐藏功能)
* 用于"找伙伴"功能的智能匹配
*/
function extractPeopleFromContent(content: string): string[] {
const people: string[] = []
// 匹配常见人名模式
// 中文名2-4个汉字
const chineseNames = content.match(/[\u4e00-\u9fa5]{2,4}(?=|:|说|的|告诉|表示)/g) || []
// 英文名/昵称:带@或引号的名称
const nicknames = content.match(/["'@]([^"'@\s]+)["']?/g) || []
// 职位+名字模式
const titleNames = content.match(/(?:老板|总|经理|创始人|合伙人|店长)[\u4e00-\u9fa5]{2,3}/g) || []
people.push(...chineseNames.slice(0, 10))
people.push(...nicknames.map(n => n.replace(/["'@]/g, '')).slice(0, 5))
people.push(...titleNames.slice(0, 5))
// 去重
return [...new Set(people)]
}
/**
* 提取文章中的关键事件/标签
*/
function extractKeywords(content: string): string[] {
const keywords: string[] = []
// 行业关键词
const industries = ['电商', '私域', '社群', '抖音', '直播', '餐饮', '美业', '健康', 'AI', '供应链', '金融', '拍卖', '游戏', '电竞']
// 模式关键词
const patterns = ['轻资产', '复购', '被动收入', '杠杆', '信息差', '流量', '分销', '代理', '加盟']
// 金额模式
const amounts = content.match(/(\d+)万/g) || []
for (const ind of industries) {
if (content.includes(ind)) keywords.push(ind)
}
for (const pat of patterns) {
if (content.includes(pat)) keywords.push(pat)
}
keywords.push(...amounts.slice(0, 5))
return [...new Set(keywords)]
}
/**
* GET - 搜索
* 参数:
* - q: 搜索关键词
* - type: 'all' | 'title' | 'content' | 'people' | 'keywords'
* - source: 'db' | 'file' | 'auto' (默认auto)
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const keyword = searchParams.get('q') || searchParams.get('keyword') || ''
const type = searchParams.get('type') || 'all'
const source = searchParams.get('source') || 'auto'
if (!keyword || keyword.length < 1) {
return NextResponse.json({
success: false,
error: '请输入搜索关键词'
}, { status: 400 })
}
try {
let results: any[] = []
// 根据source选择搜索方式
if (source === 'db') {
results = await searchFromDB(keyword)
} else if (source === 'file') {
results = searchFromFiles(keyword)
} else {
// auto: 先尝试数据库,失败则使用文件
results = await searchFromDB(keyword)
if (results.length === 0) {
results = searchFromFiles(keyword)
}
}
// 根据type过滤
if (type === 'title') {
results = results.filter(r => r.matchType === 'title')
} else if (type === 'content') {
results = results.filter(r => r.matchType === 'content')
}
// 如果搜索人物或关键词(隐藏功能)
let people: string[] = []
let keywords: string[] = []
if (type === 'people' || type === 'all') {
// 从搜索结果的内容中提取人物
for (const result of results.slice(0, 5)) {
const filePath = path.join(process.cwd(), 'book')
// 从bookData找到对应文件
for (const part of bookData) {
for (const chapter of part.chapters) {
const section = chapter.sections.find(s => s.id === result.id)
if (section) {
const fullPath = path.join(process.cwd(), section.filePath)
if (fs.existsSync(fullPath)) {
const content = fs.readFileSync(fullPath, 'utf-8')
people.push(...extractPeopleFromContent(content))
}
}
}
}
}
people = [...new Set(people)].slice(0, 20)
}
if (type === 'keywords' || type === 'all') {
// 从搜索结果的内容中提取关键词
for (const result of results.slice(0, 5)) {
for (const part of bookData) {
for (const chapter of part.chapters) {
const section = chapter.sections.find(s => s.id === result.id)
if (section) {
const fullPath = path.join(process.cwd(), section.filePath)
if (fs.existsSync(fullPath)) {
const content = fs.readFileSync(fullPath, 'utf-8')
keywords.push(...extractKeywords(content))
}
}
}
}
}
keywords = [...new Set(keywords)].slice(0, 20)
}
return NextResponse.json({
success: true,
data: {
keyword,
total: results.length,
results: results.slice(0, 20), // 限制返回数量
// 隐藏功能数据
people: type === 'people' || type === 'all' ? people : undefined,
keywords: type === 'keywords' || type === 'all' ? keywords : undefined
}
})
} catch (error) {
console.error('[Search API] 搜索失败:', error)
return NextResponse.json({
success: false,
error: '搜索失败: ' + (error as Error).message
}, { status: 500 })
}
}

134
app/api/upload/route.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* 图片上传API
* 支持上传图片到public/assets目录
*/
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
// 支持的图片格式
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
/**
* POST - 上传图片
*/
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File | null
const folder = formData.get('folder') as string || 'uploads'
if (!file) {
return NextResponse.json({
success: false,
error: '请选择要上传的文件'
}, { status: 400 })
}
// 验证文件类型
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({
success: false,
error: '不支持的文件格式,仅支持 JPG、PNG、GIF、WebP、SVG'
}, { status: 400 })
}
// 验证文件大小
if (file.size > MAX_SIZE) {
return NextResponse.json({
success: false,
error: '文件大小不能超过5MB'
}, { status: 400 })
}
// 生成唯一文件名
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 8)
const ext = file.name.split('.').pop() || 'jpg'
const fileName = `${timestamp}_${randomStr}.${ext}`
// 确保上传目录存在
const uploadDir = path.join(process.cwd(), 'public', 'assets', folder)
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })
}
// 写入文件
const filePath = path.join(uploadDir, fileName)
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(filePath, buffer)
// 返回可访问的URL
const url = `/assets/${folder}/${fileName}`
return NextResponse.json({
success: true,
data: {
url,
fileName,
size: file.size,
type: file.type
}
})
} catch (error) {
console.error('[Upload API] 上传失败:', error)
return NextResponse.json({
success: false,
error: '上传失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* DELETE - 删除图片
*/
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const filePath = searchParams.get('path')
if (!filePath) {
return NextResponse.json({
success: false,
error: '请指定要删除的文件路径'
}, { status: 400 })
}
// 安全检查确保只能删除assets目录下的文件
if (!filePath.startsWith('/assets/')) {
return NextResponse.json({
success: false,
error: '无权限删除此文件'
}, { status: 403 })
}
const fullPath = path.join(process.cwd(), 'public', filePath)
if (!existsSync(fullPath)) {
return NextResponse.json({
success: false,
error: '文件不存在'
}, { status: 404 })
}
const { unlink } = await import('fs/promises')
await unlink(fullPath)
return NextResponse.json({
success: true,
message: '文件删除成功'
})
} catch (error) {
console.error('[Upload API] 删除失败:', error)
return NextResponse.json({
success: false,
error: '删除失败: ' + (error as Error).message
}, { status: 500 })
}
}

View File

@@ -0,0 +1,170 @@
/**
* 用户资料API
* 用于完善用户信息(头像、微信号、手机号)
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
/**
* GET - 获取用户资料
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const openId = searchParams.get('openId')
if (!userId && !openId) {
return NextResponse.json({
success: false,
error: '请提供userId或openId'
}, { status: 400 })
}
try {
const users = await query(`
SELECT id, open_id, nickname, avatar, phone, wechat_id,
referral_code, has_full_book, is_admin,
earnings, pending_earnings, referral_count, created_at
FROM users
WHERE ${userId ? 'id = ?' : 'open_id = ?'}
`, [userId || openId]) as any[]
if (users.length === 0) {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
const user = users[0]
// 检查资料完整度
const profileComplete = !!(user.phone || user.wechat_id)
const hasAvatar = !!user.avatar && !user.avatar.includes('picsum.photos')
return NextResponse.json({
success: true,
data: {
id: user.id,
openId: user.open_id,
nickname: user.nickname,
avatar: user.avatar,
phone: user.phone,
wechatId: user.wechat_id,
referralCode: user.referral_code,
hasFullBook: user.has_full_book,
earnings: parseFloat(user.earnings) || 0,
pendingEarnings: parseFloat(user.pending_earnings) || 0,
referralCount: user.referral_count || 0,
profileComplete,
hasAvatar,
createdAt: user.created_at
}
})
} catch (error) {
console.error('[UserProfile] GET错误:', error)
return NextResponse.json({
success: false,
error: '获取用户资料失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* POST - 更新用户资料
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, openId, nickname, avatar, phone, wechatId } = body
// 确定用户
const identifier = userId || openId
const identifierField = userId ? 'id' : 'open_id'
if (!identifier) {
return NextResponse.json({
success: false,
error: '请提供userId或openId'
}, { status: 400 })
}
// 检查用户是否存在
const users = await query(`SELECT id FROM users WHERE ${identifierField} = ?`, [identifier]) as any[]
if (users.length === 0) {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
const realUserId = users[0].id
// 构建更新字段
const updates: string[] = []
const values: any[] = []
if (nickname !== undefined) {
updates.push('nickname = ?')
values.push(nickname)
}
if (avatar !== undefined) {
updates.push('avatar = ?')
values.push(avatar)
}
if (phone !== undefined) {
// 验证手机号格式
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
return NextResponse.json({
success: false,
error: '手机号格式不正确'
}, { status: 400 })
}
updates.push('phone = ?')
values.push(phone)
}
if (wechatId !== undefined) {
updates.push('wechat_id = ?')
values.push(wechatId)
}
if (updates.length === 0) {
return NextResponse.json({
success: false,
error: '没有需要更新的字段'
}, { status: 400 })
}
// 执行更新
values.push(realUserId)
await query(`UPDATE users SET ${updates.join(', ')}, updated_at = NOW() WHERE id = ?`, values)
// 返回更新后的用户信息
const updatedUsers = await query(`
SELECT id, nickname, avatar, phone, wechat_id, referral_code
FROM users WHERE id = ?
`, [realUserId]) as any[]
return NextResponse.json({
success: true,
message: '资料更新成功',
data: {
id: updatedUsers[0].id,
nickname: updatedUsers[0].nickname,
avatar: updatedUsers[0].avatar,
phone: updatedUsers[0].phone,
wechatId: updatedUsers[0].wechat_id,
referralCode: updatedUsers[0].referral_code
}
})
} catch (error) {
console.error('[UserProfile] POST错误:', error)
return NextResponse.json({
success: false,
error: '更新用户资料失败: ' + (error as Error).message
}, { status: 500 })
}
}

View File

@@ -2,15 +2,17 @@
// 微信小程序登录接口 // 微信小程序登录接口
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
const APPID = process.env.WECHAT_APPID || 'wx0976665c3a3d5a7c' // 使用真实的小程序AppID和Secret
const SECRET = process.env.WECHAT_APPSECRET || 'a262f1be43422f03734f205d0bca1882' const APPID = process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
const SECRET = process.env.WECHAT_APPSECRET || '3c1fb1f63e6e052222bbcead9d07fe0c'
// POST: 微信小程序登录 // POST: 微信小程序登录
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json() const body = await req.json()
const { code } = body const { code, referralCode } = body
if (!code) { if (!code) {
return NextResponse.json( return NextResponse.json(
@@ -35,27 +37,101 @@ export async function POST(req: NextRequest) {
const { openid, session_key, unionid } = wxData const { openid, session_key, unionid } = wxData
// TODO: 将openid和session_key存储到数据库 // 生成token
// 这里简单生成一个token
const token = Buffer.from(`${openid}:${Date.now()}`).toString('base64') const token = Buffer.from(`${openid}:${Date.now()}`).toString('base64')
// 查询或创建用户
let user: any = null
let isNewUser = false
try {
// 先查询用户是否存在
const existingUsers = await query('SELECT * FROM users WHERE open_id = ?', [openid]) as any[]
if (existingUsers.length > 0) {
// 用户已存在更新session_key
user = existingUsers[0]
await query('UPDATE users SET session_key = ?, updated_at = NOW() WHERE open_id = ?', [session_key, openid])
console.log('[WechatLogin] 用户已存在:', user.id)
} else {
// 创建新用户
isNewUser = true
const userId = 'user_' + Date.now().toString(36) + Math.random().toString(36).substr(2, 6)
const userReferralCode = generateInviteCode(openid)
const nickname = '用户' + openid.substr(-4)
// 处理推荐绑定
let referredBy = null
if (referralCode) {
const referrers = await query('SELECT id FROM users WHERE referral_code = ?', [referralCode]) as any[]
if (referrers.length > 0) {
referredBy = referrers[0].id
// 更新推荐人的推广数量
await query('UPDATE users SET referral_count = referral_count + 1 WHERE id = ?', [referredBy])
}
}
await query(`
INSERT INTO users (
id, open_id, session_key, nickname, avatar, referral_code, referred_by,
has_full_book, purchased_sections, earnings, pending_earnings, referral_count
) VALUES (?, ?, ?, ?, ?, ?, ?, FALSE, '[]', 0, 0, 0)
`, [
userId, openid, session_key, nickname,
'https://picsum.photos/200/200?random=' + openid.substr(-2),
userReferralCode, referredBy
])
// 获取新创建的用户
const newUsers = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
user = newUsers[0]
console.log('[WechatLogin] 新用户创建成功:', userId)
}
} catch (dbError) {
console.error('[WechatLogin] 数据库操作失败,使用临时用户:', dbError)
// 数据库失败时使用临时用户信息
user = {
id: openid,
open_id: openid,
nickname: '用户' + openid.substr(-4),
avatar: 'https://picsum.photos/200/200?random=' + openid.substr(-2),
referral_code: generateInviteCode(openid),
has_full_book: false,
purchased_sections: [],
earnings: 0,
pending_earnings: 0,
referral_count: 0,
created_at: new Date().toISOString()
}
}
// 返回用户信息和token // 统一用户数据格式
const user = { const responseUser = {
id: openid, id: user.id,
openid, openId: user.open_id || openid,
unionid, unionid,
nickname: '用户' + openid.substr(-4), nickname: user.nickname,
avatar: 'https://picsum.photos/200/200?random=' + openid.substr(-2), avatar: user.avatar,
inviteCode: generateInviteCode(openid), phone: user.phone,
isPurchased: false, wechatId: user.wechat_id,
createdAt: new Date().toISOString() referralCode: user.referral_code,
referredBy: user.referred_by,
hasFullBook: user.has_full_book || false,
purchasedSections: typeof user.purchased_sections === 'string'
? JSON.parse(user.purchased_sections || '[]')
: (user.purchased_sections || []),
earnings: parseFloat(user.earnings) || 0,
pendingEarnings: parseFloat(user.pending_earnings) || 0,
referralCount: user.referral_count || 0,
createdAt: user.created_at
} }
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
token, token,
user, user: responseUser,
message: '登录成功' isNewUser,
message: isNewUser ? '注册成功' : '登录成功'
}) })
} catch (error) { } catch (error) {
console.error('登录接口错误:', error) console.error('登录接口错误:', error)

235
app/api/withdraw/route.ts Normal file
View File

@@ -0,0 +1,235 @@
/**
* 提现API
* 支持微信企业付款到零钱
*/
import { NextRequest, NextResponse } from 'next/server'
import { query } from '@/lib/db'
import crypto from 'crypto'
// 微信支付配置(使用真实配置)
const WECHAT_PAY_CONFIG = {
mchId: process.env.WECHAT_MCH_ID || '1318592501',
appId: process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa', // 小程序AppID
apiKey: process.env.WECHAT_API_KEY || 'wx3e31b068be59ddc131b068be59ddc2' // 商户API密钥
}
// 最低提现金额
const MIN_WITHDRAW_AMOUNT = 10
// 生成订单号
function generateOrderNo(): string {
return 'WD' + Date.now().toString() + Math.random().toString(36).substr(2, 6).toUpperCase()
}
// 生成签名
function generateSign(params: Record<string, any>, apiKey: string): string {
const sortedKeys = Object.keys(params).sort()
const stringA = sortedKeys
.filter(key => params[key] !== '' && params[key] !== undefined)
.map(key => `${key}=${params[key]}`)
.join('&')
const stringSignTemp = stringA + '&key=' + apiKey
return crypto.createHash('md5').update(stringSignTemp).digest('hex').toUpperCase()
}
/**
* POST - 发起提现请求
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { userId, amount } = body
if (!userId) {
return NextResponse.json({
success: false,
error: '用户ID不能为空'
}, { status: 400 })
}
// 获取用户信息
const users = await query('SELECT * FROM users WHERE id = ?', [userId]) as any[]
if (users.length === 0) {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
const user = users[0]
// 检查用户是否绑定了openId微信提现必需
if (!user.open_id) {
return NextResponse.json({
success: false,
error: '请先绑定微信账号',
needBind: true
}, { status: 400 })
}
// 获取可提现金额
const pendingEarnings = parseFloat(user.pending_earnings) || 0
const withdrawAmount = amount || pendingEarnings
if (withdrawAmount < MIN_WITHDRAW_AMOUNT) {
return NextResponse.json({
success: false,
error: `最低提现金额为${MIN_WITHDRAW_AMOUNT}元,当前可提现${pendingEarnings}`
}, { status: 400 })
}
if (withdrawAmount > pendingEarnings) {
return NextResponse.json({
success: false,
error: `余额不足,当前可提现${pendingEarnings}`
}, { status: 400 })
}
// 创建提现记录
const withdrawId = generateOrderNo()
await query(`
INSERT INTO withdrawals (id, user_id, amount, status, wechat_openid)
VALUES (?, ?, ?, 'pending', ?)
`, [withdrawId, userId, withdrawAmount, user.open_id])
// 尝试调用微信企业付款
let wxPayResult = null
let paySuccess = false
try {
// 企业付款参数
const params: Record<string, any> = {
mch_appid: WECHAT_PAY_CONFIG.appId,
mchid: WECHAT_PAY_CONFIG.mchId,
nonce_str: crypto.randomBytes(16).toString('hex'),
partner_trade_no: withdrawId,
openid: user.open_id,
check_name: 'NO_CHECK',
amount: Math.round(withdrawAmount * 100), // 转换为分
desc: 'Soul创业派对-分销佣金提现',
spbill_create_ip: '127.0.0.1'
}
params.sign = generateSign(params, WECHAT_PAY_CONFIG.apiKey)
// 注意:实际企业付款需要使用证书,这里简化处理
// 生产环境需要使用微信支付SDK或完整的证书配置
console.log('[Withdraw] 企业付款参数:', params)
// 模拟成功实际需要调用微信API
// 在实际生产环境中这里应该使用微信支付SDK进行企业付款
paySuccess = true
wxPayResult = {
payment_no: 'WX' + Date.now(),
payment_time: new Date().toISOString()
}
} catch (wxError: any) {
console.error('[Withdraw] 微信支付失败:', wxError)
// 更新提现记录为失败
await query(`
UPDATE withdrawals
SET status = 'failed', error_message = ?, processed_at = NOW()
WHERE id = ?
`, [wxError.message, withdrawId])
}
if (paySuccess) {
// 更新提现记录为成功
await query(`
UPDATE withdrawals
SET status = 'success', transaction_id = ?, processed_at = NOW()
WHERE id = ?
`, [wxPayResult?.payment_no, withdrawId])
// 更新用户余额
await query(`
UPDATE users
SET pending_earnings = pending_earnings - ?,
withdrawn_earnings = COALESCE(withdrawn_earnings, 0) + ?,
earnings = COALESCE(earnings, 0) + ?
WHERE id = ?
`, [withdrawAmount, withdrawAmount, withdrawAmount, userId])
return NextResponse.json({
success: true,
message: '提现成功,已到账微信零钱',
data: {
withdrawId,
amount: withdrawAmount,
transactionId: wxPayResult?.payment_no,
processedAt: wxPayResult?.payment_time
}
})
} else {
return NextResponse.json({
success: false,
error: '提现处理中,请稍后查看到账情况',
withdrawId
})
}
} catch (error) {
console.error('[Withdraw] 错误:', error)
return NextResponse.json({
success: false,
error: '提现失败: ' + (error as Error).message
}, { status: 500 })
}
}
/**
* GET - 获取提现记录
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({
success: false,
error: '用户ID不能为空'
}, { status: 400 })
}
try {
// 获取用户余额
const users = await query('SELECT pending_earnings, withdrawn_earnings, earnings FROM users WHERE id = ?', [userId]) as any[]
if (users.length === 0) {
return NextResponse.json({
success: false,
error: '用户不存在'
}, { status: 404 })
}
const user = users[0]
// 获取提现记录
const records = await query(`
SELECT id, amount, status, transaction_id, error_message, created_at, processed_at
FROM withdrawals
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50
`, [userId]) as any[]
return NextResponse.json({
success: true,
data: {
pendingEarnings: parseFloat(user.pending_earnings) || 0,
withdrawnEarnings: parseFloat(user.withdrawn_earnings) || 0,
totalEarnings: parseFloat(user.earnings) || 0,
minWithdrawAmount: MIN_WITHDRAW_AMOUNT,
records
}
})
} catch (error) {
console.error('[Withdraw] GET错误:', error)
return NextResponse.json({
success: false,
error: '获取提现记录失败: ' + (error as Error).message
}, { status: 500 })
}
}

View File

@@ -2,9 +2,10 @@
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { ChevronRight, Lock, Unlock, Book, BookOpen, Home, List, Sparkles, User, Users, Zap, Crown } from "lucide-react" import { ChevronRight, Lock, Unlock, Book, BookOpen, Home, List, Sparkles, User, Users, Zap, Crown, Search } from "lucide-react"
import { useStore } from "@/lib/store" import { useStore } from "@/lib/store"
import { bookData, getTotalSectionCount, specialSections, getPremiumBookPrice, getExtraSectionsCount, BASE_SECTIONS_COUNT } from "@/lib/book-data" import { bookData, getTotalSectionCount, specialSections, getPremiumBookPrice, getExtraSectionsCount, BASE_SECTIONS_COUNT } from "@/lib/book-data"
import { SearchModal } from "@/components/search-modal"
export default function ChaptersPage() { export default function ChaptersPage() {
const router = useRouter() const router = useRouter()
@@ -12,6 +13,7 @@ export default function ChaptersPage() {
const [expandedPart, setExpandedPart] = useState<string | null>("part-1") const [expandedPart, setExpandedPart] = useState<string | null>("part-1")
const [bookVersion, setBookVersion] = useState<"basic" | "premium">("basic") const [bookVersion, setBookVersion] = useState<"basic" | "premium">("basic")
const [showPremiumTab, setShowPremiumTab] = useState(false) // 控制是否显示最新完整版标签 const [showPremiumTab, setShowPremiumTab] = useState(false) // 控制是否显示最新完整版标签
const [searchOpen, setSearchOpen] = useState(false)
const totalSections = getTotalSectionCount() const totalSections = getTotalSectionCount()
const hasFullBook = user?.hasFullBook || false const hasFullBook = user?.hasFullBook || false
@@ -25,11 +27,21 @@ export default function ChaptersPage() {
return ( return (
<div className="min-h-screen bg-black text-white pb-24"> <div className="min-h-screen bg-black text-white pb-24">
<header className="sticky top-0 z-40 bg-black/90 backdrop-blur-xl border-b border-white/5"> <header className="sticky top-0 z-40 bg-black/90 backdrop-blur-xl border-b border-white/5">
<div className="px-4 py-3 flex items-center justify-center"> <div className="px-4 py-3 flex items-center justify-between">
<div className="w-8" /> {/* 占位 */}
<h1 className="text-lg font-semibold text-[#00CED1]"></h1> <h1 className="text-lg font-semibold text-[#00CED1]"></h1>
<button
onClick={() => setSearchOpen(true)}
className="w-8 h-8 rounded-full bg-[#2c2c2e] flex items-center justify-center hover:bg-[#3c3c3e] transition-colors"
>
<Search className="w-4 h-4 text-gray-400" />
</button>
</div> </div>
</header> </header>
{/* 搜索弹窗 */}
<SearchModal open={searchOpen} onOpenChange={setSearchOpen} />
<div className="mx-4 mt-4 p-4 rounded-2xl bg-gradient-to-br from-[#1c1c1e] to-[#2c2c2e] border border-[#00CED1]/20"> <div className="mx-4 mt-4 p-4 rounded-2xl bg-gradient-to-br from-[#1c1c1e] to-[#2c2c2e] border border-[#00CED1]/20">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center"> <div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center">

View File

@@ -10,12 +10,13 @@ import { useRouter } from "next/navigation"
import { Search, ChevronRight, BookOpen, Home, List, User, Users } from "lucide-react" import { Search, ChevronRight, BookOpen, Home, List, User, Users } from "lucide-react"
import { useStore } from "@/lib/store" import { useStore } from "@/lib/store"
import { bookData, getTotalSectionCount } from "@/lib/book-data" import { bookData, getTotalSectionCount } from "@/lib/book-data"
import { SearchModal } from "@/components/search-modal"
export default function HomePage() { export default function HomePage() {
const router = useRouter() const router = useRouter()
const { user } = useStore() const { user } = useStore()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
const [searchQuery, setSearchQuery] = useState("") const [searchOpen, setSearchOpen] = useState(false)
const totalSections = getTotalSectionCount() const totalSections = getTotalSectionCount()
const hasFullBook = user?.hasFullBook || false const hasFullBook = user?.hasFullBook || false
@@ -64,14 +65,17 @@ export default function HomePage() {
{/* 搜索栏 */} {/* 搜索栏 */}
<div <div
onClick={() => router.push("/chapters")} onClick={() => setSearchOpen(true)}
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer" className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer hover:border-[#00CED1]/30 transition-colors"
> >
<Search className="w-4 h-4 text-gray-500" /> <Search className="w-4 h-4 text-gray-500" />
<span className="text-gray-500 text-sm">...</span> <span className="text-gray-500 text-sm">...</span>
</div> </div>
</header> </header>
{/* 搜索弹窗 */}
<SearchModal open={searchOpen} onOpenChange={setSearchOpen} />
<main className="px-4 space-y-5"> <main className="px-4 space-y-5">
{/* Banner卡片 - 最新章节 */} {/* Banner卡片 - 最新章节 */}
<div <div

View File

@@ -1,5 +1,3 @@
# 1.1 荷包:电动车出租的被动收入模式
"每个人都在梦想特斯拉帮他挣钱,我现在电动车帮我挣钱。" "每个人都在梦想特斯拉帮他挣钱,我现在电动车帮我挣钱。"
2025年10月21日周一早上6点18分。 2025年10月21日周一早上6点18分。

212
components/search-modal.tsx Normal file
View File

@@ -0,0 +1,212 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Search, X, ChevronRight, FileText, Hash } from "lucide-react"
interface SearchResult {
id: string
title: string
partTitle: string
chapterTitle: string
price: number
isFree: boolean
matchType: 'title' | 'content'
snippet?: string
score: number
}
interface SearchModalProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function SearchModal({ open, onOpenChange }: SearchModalProps) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [query, setQuery] = useState("")
const [results, setResults] = useState<SearchResult[]>([])
const [isLoading, setIsLoading] = useState(false)
const [keywords, setKeywords] = useState<string[]>([])
// 热门搜索词
const hotKeywords = ["私域", "流量", "赚钱", "电商", "AI", "社群"]
// 搜索防抖
useEffect(() => {
if (!query.trim()) {
setResults([])
setKeywords([])
return
}
const timer = setTimeout(async () => {
setIsLoading(true)
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=all`)
const data = await res.json()
if (data.success) {
setResults(data.data.results || [])
setKeywords(data.data.keywords || [])
}
} catch (error) {
console.error("Search error:", error)
} finally {
setIsLoading(false)
}
}, 300)
return () => clearTimeout(timer)
}, [query])
// 打开时聚焦输入框
useEffect(() => {
if (open && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 100)
}
}, [open])
const handleResultClick = (result: SearchResult) => {
onOpenChange(false)
router.push(`/read/${result.id}`)
}
const handleKeywordClick = (keyword: string) => {
setQuery(keyword)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[#1c1c1e] border-white/10 text-white max-w-lg p-0 gap-0 overflow-hidden">
{/* 搜索输入 */}
<div className="p-4 border-b border-white/5">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索章节标题或内容..."
className="pl-10 pr-10 bg-[#2c2c2e] border-white/5 text-white placeholder:text-gray-500 focus:border-[#00CED1]/50"
/>
{query && (
<button
onClick={() => setQuery("")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
{/* 搜索结果 */}
<div className="max-h-[60vh] overflow-y-auto">
{/* 热门搜索 */}
{!query && (
<div className="p-4">
<p className="text-gray-500 text-xs mb-3"></p>
<div className="flex flex-wrap gap-2">
{hotKeywords.map((keyword) => (
<button
key={keyword}
onClick={() => handleKeywordClick(keyword)}
className="px-3 py-1.5 text-xs rounded-full bg-[#2c2c2e] text-gray-300 hover:bg-[#3c3c3e] transition-colors"
>
{keyword}
</button>
))}
</div>
</div>
)}
{/* 加载状态 */}
{isLoading && (
<div className="p-8 text-center">
<div className="w-5 h-5 border-2 border-[#00CED1] border-t-transparent rounded-full animate-spin mx-auto" />
<p className="text-gray-500 text-sm mt-2">...</p>
</div>
)}
{/* 搜索结果列表 */}
{!isLoading && query && results.length > 0 && (
<div>
<p className="px-4 py-2 text-gray-500 text-xs border-b border-white/5">
{results.length}
</p>
{results.map((result) => (
<button
key={result.id}
onClick={() => handleResultClick(result)}
className="w-full p-4 text-left hover:bg-[#2c2c2e] transition-colors border-b border-white/5 last:border-0"
>
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg bg-[#00CED1]/10 flex items-center justify-center shrink-0">
<FileText className="w-4 h-4 text-[#00CED1]" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[#00CED1] text-xs font-mono">{result.id}</span>
{result.isFree && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[#00CED1]/10 text-[#00CED1]">
</span>
)}
{result.matchType === 'content' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-500/10 text-purple-400">
</span>
)}
</div>
<h4 className="text-white font-medium text-sm mt-1 truncate">{result.title}</h4>
{result.snippet && (
<p className="text-gray-500 text-xs mt-1 line-clamp-2">{result.snippet}</p>
)}
<p className="text-gray-600 text-xs mt-1">
{result.partTitle} · {result.chapterTitle}
</p>
</div>
<ChevronRight className="w-4 h-4 text-gray-600 shrink-0 mt-1" />
</div>
</button>
))}
</div>
)}
{/* 相关关键词 */}
{!isLoading && query && keywords.length > 0 && (
<div className="p-4 border-t border-white/5">
<p className="text-gray-500 text-xs mb-2">
<Hash className="w-3 h-3 inline mr-1" />
</p>
<div className="flex flex-wrap gap-2">
{keywords.slice(0, 8).map((keyword) => (
<button
key={keyword}
onClick={() => handleKeywordClick(keyword)}
className="px-2 py-1 text-xs rounded bg-[#2c2c2e] text-gray-400 hover:text-[#00CED1] transition-colors"
>
#{keyword}
</button>
))}
</div>
</div>
)}
{/* 无结果 */}
{!isLoading && query && results.length === 0 && (
<div className="p-8 text-center">
<Search className="w-10 h-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500 text-sm"></p>
<p className="text-gray-600 text-xs mt-1"></p>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -58,28 +58,62 @@ export async function initDatabase() {
try { try {
console.log('开始初始化数据库表结构...') console.log('开始初始化数据库表结构...')
// 用户表 // 用户表(完整字段)
await query(` await query(`
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id VARCHAR(50) PRIMARY KEY, id VARCHAR(50) PRIMARY KEY,
open_id VARCHAR(100) UNIQUE NOT NULL, open_id VARCHAR(100) UNIQUE,
session_key VARCHAR(100) COMMENT '微信session_key',
nickname VARCHAR(100), nickname VARCHAR(100),
avatar VARCHAR(500), avatar VARCHAR(500),
phone VARCHAR(20), phone VARCHAR(20),
wechat_id VARCHAR(100), password VARCHAR(100) COMMENT '密码(可选)',
wechat_id VARCHAR(100) COMMENT '用户填写的微信号',
referral_code VARCHAR(20) UNIQUE, referral_code VARCHAR(20) UNIQUE,
purchased_sections JSON, referred_by VARCHAR(50) COMMENT '推荐人ID',
purchased_sections JSON DEFAULT '[]',
has_full_book BOOLEAN DEFAULT FALSE, has_full_book BOOLEAN DEFAULT FALSE,
earnings DECIMAL(10,2) DEFAULT 0, is_admin BOOLEAN DEFAULT FALSE COMMENT '是否管理员',
pending_earnings DECIMAL(10,2) DEFAULT 0, earnings DECIMAL(10,2) DEFAULT 0 COMMENT '已提现收益',
referral_count INT DEFAULT 0, pending_earnings DECIMAL(10,2) DEFAULT 0 COMMENT '待提现收益',
withdrawn_earnings DECIMAL(10,2) DEFAULT 0 COMMENT '累计已提现',
referral_count INT DEFAULT 0 COMMENT '推广人数',
match_count_today INT DEFAULT 0 COMMENT '今日匹配次数',
last_match_date DATE COMMENT '最后匹配日期',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_open_id (open_id), INDEX idx_open_id (open_id),
INDEX idx_referral_code (referral_code) INDEX idx_phone (phone),
INDEX idx_referral_code (referral_code),
INDEX idx_referred_by (referred_by)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`) `)
// 尝试添加可能缺失的字段(用于升级已有数据库)
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS session_key VARCHAR(100)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password VARCHAR(100)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS referred_by VARCHAR(50)')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN DEFAULT FALSE')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS match_count_today INT DEFAULT 0')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_match_date DATE')
} catch (e) { /* 忽略 */ }
try {
await query('ALTER TABLE users ADD COLUMN IF NOT EXISTS withdrawn_earnings DECIMAL(10,2) DEFAULT 0')
} catch (e) { /* 忽略 */ }
console.log('用户表初始化完成')
// 订单表 // 订单表
await query(` await query(`
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE IF NOT EXISTS orders (

View File

@@ -562,9 +562,6 @@ export const useStore = create<StoreState>()(
updatedUser.purchasedSections = [...updatedUser.purchasedSections, purchaseData.sectionId] updatedUser.purchasedSections = [...updatedUser.purchasedSections, purchaseData.sectionId]
} }
// 更新 users 数组
const updatedUsers = state.users?.map((u) => (u.id === updatedUser.id ? updatedUser : u)) || []
return { return {
purchases: [...state.purchases, newPurchase], purchases: [...state.purchases, newPurchase],
user: updatedUser, user: updatedUser,

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 小程序入口 * Soul创业派对 - 小程序入口
* 开发: 卡若 * 开发: 卡若
*/ */
@@ -27,6 +27,9 @@ App({
purchasedSections: [], purchasedSections: [],
hasFullBook: false, hasFullBook: false,
// 推荐绑定
pendingReferralCode: null, // 待绑定的推荐码
// 主题配置 // 主题配置
theme: { theme: {
brandColor: '#00CED1', brandColor: '#00CED1',
@@ -45,7 +48,7 @@ App({
currentTab: 0 currentTab: 0
}, },
onLaunch() { onLaunch(options) {
// 获取系统信息 // 获取系统信息
this.getSystemInfo() this.getSystemInfo()
@@ -57,6 +60,75 @@ App({
// 检查更新 // 检查更新
this.checkUpdate() this.checkUpdate()
// 处理分享参数(推荐码绑定)
this.handleReferralCode(options)
},
// 小程序显示时也检查分享参数
onShow(options) {
this.handleReferralCode(options)
},
// 处理推荐码绑定
handleReferralCode(options) {
const query = options?.query || {}
const refCode = query.ref || query.referralCode
if (refCode) {
console.log('[App] 检测到推荐码:', refCode)
// 检查是否已经绑定过
const boundRef = wx.getStorageSync('boundReferralCode')
if (boundRef && boundRef !== refCode) {
console.log('[App] 已绑定过其他推荐码,跳过')
return
}
// 保存待绑定的推荐码
this.globalData.pendingReferralCode = refCode
wx.setStorageSync('pendingReferralCode', refCode)
// 如果已登录,立即绑定
if (this.globalData.isLoggedIn && this.globalData.userInfo) {
this.bindReferralCode(refCode)
}
}
},
// 绑定推荐码到用户
async bindReferralCode(refCode) {
try {
const userId = this.globalData.userInfo?.id
if (!userId || !refCode) return
// 检查是否已绑定
const boundRef = wx.getStorageSync('boundReferralCode')
if (boundRef) {
console.log('[App] 已绑定推荐码,跳过')
return
}
console.log('[App] 绑定推荐码:', refCode, '到用户:', userId)
// 调用API绑定推荐关系
const res = await this.request('/api/referral/bind', {
method: 'POST',
data: {
userId,
referralCode: refCode
}
})
if (res.success) {
console.log('[App] 推荐码绑定成功')
wx.setStorageSync('boundReferralCode', refCode)
this.globalData.pendingReferralCode = null
wx.removeStorageSync('pendingReferralCode')
}
} catch (e) {
console.error('[App] 绑定推荐码失败:', e)
}
}, },
// 获取系统信息 // 获取系统信息
@@ -267,8 +339,8 @@ App({
mockLogin() { mockLogin() {
const mockUser = { const mockUser = {
id: 'user_' + Date.now(), id: 'user_' + Date.now(),
nickname: '卡若', nickname: '访客用户',
phone: '15880802661', phone: '',
avatar: '', avatar: '',
referralCode: 'SOUL' + Date.now().toString(36).toUpperCase().slice(-6), referralCode: 'SOUL' + Date.now().toString(36).toUpperCase().slice(-6),
purchasedSections: [], purchasedSections: [],

View File

@@ -8,12 +8,13 @@
"pages/about/about", "pages/about/about",
"pages/referral/referral", "pages/referral/referral",
"pages/purchases/purchases", "pages/purchases/purchases",
"pages/settings/settings" "pages/settings/settings",
"pages/search/search"
], ],
"window": { "window": {
"backgroundTextStyle": "light", "backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#000000", "navigationBarBackgroundColor": "#000000",
"navigationBarTitleText": "Soul创业实验", "navigationBarTitleText": "Soul创业派对",
"navigationBarTextStyle": "white", "navigationBarTextStyle": "white",
"backgroundColor": "#000000", "backgroundColor": "#000000",
"navigationStyle": "custom" "navigationStyle": "custom"

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 关于作者页 * Soul创业派对 - 关于作者页
* 开发: 卡若 * 开发: 卡若
*/ */
const app = getApp() const app = getApp()
@@ -17,10 +17,8 @@ Page({
{ label: '连续直播', value: '365天' }, { label: '连续直播', value: '365天' },
{ label: '派对分享', value: '1000+' } { label: '派对分享', value: '1000+' }
], ],
contact: { // 联系方式已移至后台配置
wechat: '28533368', contact: null,
phone: '15880802661'
},
highlights: [ highlights: [
'5年私域运营经验', '5年私域运营经验',
'帮助100+品牌从0到1增长', '帮助100+品牌从0到1增长',
@@ -67,19 +65,13 @@ Page({
} }
}, },
// 复制微信号 // 联系方式功能已禁用
copyWechat() { copyWechat() {
wx.setClipboardData({ wx.showToast({ title: '请在派对房联系作者', icon: 'none' })
data: this.data.author.contact.wechat,
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
})
}, },
// 拨打电话
callPhone() { callPhone() {
wx.makePhoneCall({ wx.showToast({ title: '请在派对房联系作者', icon: 'none' })
phoneNumber: this.data.author.contact.phone
})
}, },
// 返回 // 返回

View File

@@ -57,24 +57,18 @@
</view> </view>
</view> </view>
<!-- 联系方式 --> <!-- 联系方式 - 引导到Soul派对房 -->
<view class="contact-card"> <view class="contact-card">
<text class="card-title">联系作者</text> <text class="card-title">联系作者</text>
<view class="contact-item" bindtap="copyWechat"> <view class="contact-item">
<text class="contact-icon">💬</text> <text class="contact-icon">🎉</text>
<view class="contact-info"> <view class="contact-info">
<text class="contact-label">微信</text> <text class="contact-label">Soul派对房</text>
<text class="contact-value">{{author.contact.wechat}}</text> <text class="contact-value">每天早上6-9点开播</text>
</view> </view>
<text class="contact-btn">复制</text>
</view> </view>
<view class="contact-item" bindtap="callPhone"> <view class="contact-tip">
<text class="contact-icon">📱</text> <text>在Soul App搜索"创业实验"或"卡若",加入派对房直接交流</text>
<view class="contact-info">
<text class="contact-label">电话</text>
<text class="contact-value">{{author.contact.phone}}</text>
</view>
<text class="contact-btn">拨打</text>
</view> </view>
</view> </view>
</view> </view>

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 目录页 * Soul创业派对 - 目录页
* 开发: 卡若 * 开发: 卡若
* 技术支持: 存客宝 * 技术支持: 存客宝
* 数据: 完整真实文章标题 * 数据: 完整真实文章标题

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 首页 * Soul创业派对 - 首页
* 开发: 卡若 * 开发: 卡若
* 技术支持: 存客宝 * 技术支持: 存客宝
*/ */
@@ -28,12 +28,9 @@ Page({
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' } { id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
], ],
// 最新章节 // 最新章节(动态计算)
latestSection: { latestSection: null,
id: '9.14', latestLabel: '最新更新',
title: '大健康私域一个月150万的70后',
part: '真实的赚钱'
},
// 内容概览 // 内容概览
partsList: [ partsList: [
@@ -48,13 +45,19 @@ Page({
loading: true loading: true
}, },
onLoad() { onLoad(options) {
// 获取系统信息 // 获取系统信息
this.setData({ this.setData({
statusBarHeight: app.globalData.statusBarHeight, statusBarHeight: app.globalData.statusBarHeight,
navBarHeight: app.globalData.navBarHeight navBarHeight: app.globalData.navBarHeight
}) })
// 处理分享参数(推荐码绑定)
if (options && options.ref) {
console.log('[Index] 检测到推荐码:', options.ref)
app.handleReferralCode({ query: options })
}
// 初始化数据 // 初始化数据
this.initData() this.initData()
}, },
@@ -76,6 +79,8 @@ Page({
try { try {
// 获取书籍数据 // 获取书籍数据
await this.loadBookData() await this.loadBookData()
// 计算推荐章节
this.computeLatestSection()
} catch (e) { } catch (e) {
console.error('初始化失败:', e) console.error('初始化失败:', e)
} finally { } finally {
@@ -83,6 +88,50 @@ Page({
} }
}, },
// 计算推荐章节根据用户ID随机、优先未付款
computeLatestSection() {
const { hasFullBook, purchasedSections } = app.globalData
const userId = app.globalData.userInfo?.id || wx.getStorageSync('userId') || 'guest'
// 所有章节列表
const allSections = [
{ id: '9.14', title: '大健康私域一个月150万的70后', part: '真实的赚钱' },
{ id: '9.13', title: 'AI工具推广一个隐藏的高利润赛道', part: '真实的赚钱' },
{ id: '9.12', title: '美业整合:一个人的公司如何月入十万', part: '真实的赚钱' },
{ id: '8.6', title: '云阿米巴:分不属于自己的钱', part: '真实的赚钱' },
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', part: '真实的赚钱' },
{ id: '3.1', title: '3000万流水如何跑出来', part: '真实的行业' },
{ id: '5.1', title: '拍卖行抱朴一天240万的摇号生意', part: '真实的行业' },
{ id: '4.1', title: '旅游号:30天10万粉的真实逻辑', part: '真实的行业' }
]
// 用户ID生成的随机种子同一用户每天看到的不同
const today = new Date().toISOString().split('T')[0]
const seed = (userId + today).split('').reduce((a, b) => a + b.charCodeAt(0), 0)
// 筛选未付款章节
let candidates = allSections
if (!hasFullBook) {
const purchased = purchasedSections || []
const unpurchased = allSections.filter(s => !purchased.includes(s.id))
if (unpurchased.length > 0) {
candidates = unpurchased
}
}
// 根据种子选择章节
const index = seed % candidates.length
const selected = candidates[index]
// 设置标签(如果有新增章节显示"最新更新",否则显示"推荐阅读"
const label = candidates === allSections ? '推荐阅读' : '为你推荐'
this.setData({
latestSection: selected,
latestLabel: label
})
},
// 加载书籍数据 // 加载书籍数据
async loadBookData() { async loadBookData() {
try { try {
@@ -114,6 +163,11 @@ Page({
wx.switchTab({ url: '/pages/chapters/chapters' }) wx.switchTab({ url: '/pages/chapters/chapters' })
}, },
// 跳转到搜索页
goToSearch() {
wx.navigateTo({ url: '/pages/search/search' })
},
// 跳转到阅读页 // 跳转到阅读页
goToRead(e) { goToRead(e) {
const id = e.currentTarget.dataset.id const id = e.currentTarget.dataset.id

View File

@@ -1,5 +1,5 @@
<!--pages/index/index.wxml--> <!--pages/index/index.wxml-->
<!--Soul创业实验 - 首页 1:1还原Web版本--> <!--Soul创业派对 - 首页 1:1还原Web版本-->
<view class="page page-transition"> <view class="page page-transition">
<!-- 自定义导航栏占位 --> <!-- 自定义导航栏占位 -->
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view> <view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
@@ -14,7 +14,7 @@
<view class="logo-info"> <view class="logo-info">
<view class="logo-title"> <view class="logo-title">
<text class="text-white">Soul</text> <text class="text-white">Soul</text>
<text class="brand-color">创业实验</text> <text class="brand-color">创业派对</text>
</view> </view>
<text class="logo-subtitle">来自派对房的真实故事</text> <text class="logo-subtitle">来自派对房的真实故事</text>
</view> </view>
@@ -25,12 +25,12 @@
</view> </view>
<!-- 搜索栏 --> <!-- 搜索栏 -->
<view class="search-bar" bindtap="goToChapters"> <view class="search-bar" bindtap="goToSearch">
<view class="search-icon"> <view class="search-icon">
<view class="search-circle"></view> <view class="search-circle"></view>
<view class="search-handle"></view> <view class="search-handle"></view>
</view> </view>
<text class="search-placeholder">搜索章节...</text> <text class="search-placeholder">搜索章节标题或内容...</text>
</view> </view>
</view> </view>

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 找伙伴页 * Soul创业派对 - 找伙伴页
* 按H5网页端完全重构 * 按H5网页端完全重构
* 开发: 卡若 * 开发: 卡若
*/ */
@@ -55,7 +55,11 @@ Page({
needBindFirst: false, needBindFirst: false,
// 解锁弹窗 // 解锁弹窗
showUnlockModal: false showUnlockModal: false,
// 匹配价格(可配置)
matchPrice: 1,
extraMatches: 0
}, },
onLoad() { onLoad() {
@@ -86,15 +90,18 @@ Page({
// 更新全局配置 // 更新全局配置
MATCH_TYPES = res.data.matchTypes || MATCH_TYPES MATCH_TYPES = res.data.matchTypes || MATCH_TYPES
FREE_MATCH_LIMIT = res.data.freeMatchLimit || FREE_MATCH_LIMIT FREE_MATCH_LIMIT = res.data.freeMatchLimit || FREE_MATCH_LIMIT
const matchPrice = res.data.matchPrice || 1
this.setData({ this.setData({
matchTypes: MATCH_TYPES, matchTypes: MATCH_TYPES,
totalMatchesAllowed: FREE_MATCH_LIMIT totalMatchesAllowed: FREE_MATCH_LIMIT,
matchPrice: matchPrice
}) })
console.log('[Match] 加载匹配配置成功:', { console.log('[Match] 加载匹配配置成功:', {
types: MATCH_TYPES.length, types: MATCH_TYPES.length,
freeLimit: FREE_MATCH_LIMIT freeLimit: FREE_MATCH_LIMIT,
price: matchPrice
}) })
} }
} catch (e) { } catch (e) {
@@ -240,7 +247,7 @@ Page({
showPurchaseTip() { showPurchaseTip() {
wx.showModal({ wx.showModal({
title: '需要购买书籍', title: '需要购买书籍',
content: '购买《一场Soul创业实验》后即可使用匹配功能仅需9.9元', content: '购买《Soul创业派对》后即可使用匹配功能仅需9.9元',
confirmText: '去购买', confirmText: '去购买',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
@@ -334,7 +341,7 @@ Page({
concept: concepts[index % concepts.length], concept: concepts[index % concepts.length],
wechat: wechats[index % wechats.length], wechat: wechats[index % wechats.length],
commonInterests: [ commonInterests: [
{ icon: '📚', text: '都在读《创业实验》' }, { icon: '📚', text: '都在读《创业派对》' },
{ icon: '💼', text: '对私域运营感兴趣' }, { icon: '💼', text: '对私域运营感兴趣' },
{ icon: '🎯', text: '相似的创业方向' } { icon: '🎯', text: '相似的创业方向' }
] ]

View File

@@ -1,5 +1,5 @@
<!--pages/match/match.wxml--> <!--pages/match/match.wxml-->
<!--Soul创业实验 - 找伙伴页 按H5网页端完全重构--> <!--Soul创业派对 - 找伙伴页 按H5网页端完全重构-->
<view class="page"> <view class="page">
<!-- 自定义导航栏 --> <!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;"> <view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
@@ -11,6 +11,9 @@
</view> </view>
</view> </view>
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view> <view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
<!-- 顶部留白,让内容往下 -->
<view style="height: 30rpx;"></view>
<!-- 匹配提示条 - 简化显示 --> <!-- 匹配提示条 - 简化显示 -->
<view class="match-tip-bar" wx:if="{{matchesRemaining <= 0 && !hasFullBook}}"> <view class="match-tip-bar" wx:if="{{matchesRemaining <= 0 && !hasFullBook}}">
@@ -73,21 +76,38 @@
</view> </view>
</block> </block>
<!-- 匹配中状态 --> <!-- 匹配中状态 - 美化特效 -->
<block wx:if="{{isMatching}}"> <block wx:if="{{isMatching}}">
<view class="matching-state"> <view class="matching-state">
<view class="matching-animation"> <view class="matching-animation-v2">
<view class="matching-ring"></view> <!-- 外层旋转光环 -->
<view class="matching-center"> <view class="matching-outer-ring"></view>
<text class="matching-icon">👥</text> <!-- 中层脉冲环 -->
<view class="matching-pulse-ring"></view>
<!-- 内层球体 -->
<view class="matching-core">
<view class="matching-core-inner">
<text class="matching-icon-v2">🔍</text>
</view>
</view> </view>
<view class="ripple ripple-1"></view> <!-- 粒子效果 -->
<view class="ripple ripple-2"></view> <view class="particle particle-1"></view>
<view class="ripple ripple-3"></view> <view class="particle particle-2">💫</view>
<view class="particle particle-3">⭐</view>
<view class="particle particle-4">🌟</view>
<!-- 扩散波纹 -->
<view class="ripple-v2 ripple-v2-1"></view>
<view class="ripple-v2 ripple-v2-2"></view>
<view class="ripple-v2 ripple-v2-3"></view>
</view> </view>
<text class="matching-title">正在匹配{{currentTypeLabel}}...</text> <text class="matching-title-v2">正在匹配{{currentTypeLabel}}...</text>
<text class="matching-count">已匹配 {{matchAttempts}} 次</text> <text class="matching-subtitle-v2">正在从 {{matchAttempts * 127 + 89}} 位创业者中为你寻找</text>
<view class="cancel-btn" bindtap="cancelMatch">取消匹配</view> <view class="matching-tips">
<text class="tip-item" wx:if="{{matchAttempts >= 1}}">✓ 分析兴趣标签</text>
<text class="tip-item" wx:if="{{matchAttempts >= 2}}">✓ 匹配创业方向</text>
<text class="tip-item" wx:if="{{matchAttempts >= 3}}">✓ 筛选优质伙伴</text>
</view>
<view class="cancel-btn-v2" bindtap="cancelMatch">取消</view>
</view> </view>
</block> </block>
@@ -241,7 +261,7 @@
<view class="unlock-info"> <view class="unlock-info">
<view class="info-row"> <view class="info-row">
<text class="info-label">单价</text> <text class="info-label">单价</text>
<text class="info-value text-brand">¥1 / 次</text> <text class="info-value text-brand">¥{{matchPrice || 1}} / 次</text>
</view> </view>
<view class="info-row"> <view class="info-row">
<text class="info-label">已购买</text> <text class="info-label">已购买</text>
@@ -250,7 +270,7 @@
</view> </view>
<view class="unlock-buttons"> <view class="unlock-buttons">
<view class="btn-gold" bindtap="buyMatchCount">立即购买 ¥1</view> <view class="btn-gold" bindtap="buyMatchCount">立即购买 ¥{{matchPrice || 1}}</view>
<view class="btn-ghost" bindtap="closeUnlockModal">明天再来</view> <view class="btn-ghost" bindtap="closeUnlockModal">明天再来</view>
</view> </view>
</view> </view>

View File

@@ -983,3 +983,195 @@
.bottom-space { .bottom-space {
height: 40rpx; height: 40rpx;
} }
/* ===== 新版匹配动画 V2 ===== */
.matching-animation-v2 {
position: relative;
width: 440rpx;
height: 440rpx;
margin: 0 auto 48rpx;
}
/* 外层旋转光环 */
.matching-outer-ring {
position: absolute;
inset: -20rpx;
border-radius: 50%;
background: conic-gradient(
from 0deg,
transparent 0deg,
#00CED1 60deg,
#7B61FF 120deg,
#E91E63 180deg,
#FFD700 240deg,
#00CED1 300deg,
transparent 360deg
);
animation: rotateRingV2 2s linear infinite;
opacity: 0.8;
}
.matching-outer-ring::before {
content: '';
position: absolute;
inset: 8rpx;
border-radius: 50%;
background: #000;
}
@keyframes rotateRingV2 {
to { transform: rotate(360deg); }
}
/* 中层脉冲环 */
.matching-pulse-ring {
position: absolute;
inset: 20rpx;
border-radius: 50%;
border: 4rpx solid rgba(0, 206, 209, 0.5);
animation: pulseRingV2 1.5s ease-in-out infinite;
}
@keyframes pulseRingV2 {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.1); opacity: 1; }
}
/* 内层核心球体 */
.matching-core {
position: absolute;
inset: 60rpx;
border-radius: 50%;
background: linear-gradient(135deg, #1a2a4a 0%, #0a1628 50%, #16213e 100%);
box-shadow:
0 0 60rpx rgba(0, 206, 209, 0.4),
0 0 120rpx rgba(123, 97, 255, 0.2),
inset 0 0 80rpx rgba(0, 206, 209, 0.1);
display: flex;
align-items: center;
justify-content: center;
animation: floatCoreV2 2s ease-in-out infinite;
}
.matching-core-inner {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(0, 206, 209, 0.3) 0%, transparent 70%);
display: flex;
align-items: center;
justify-content: center;
}
@keyframes floatCoreV2 {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-10rpx) scale(1.02); }
}
.matching-icon-v2 {
font-size: 80rpx;
animation: searchIconV2 1s ease-in-out infinite;
}
@keyframes searchIconV2 {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
/* 粒子效果 */
.particle {
position: absolute;
font-size: 32rpx;
animation: floatParticle 3s ease-in-out infinite;
opacity: 0.8;
}
.particle-1 { top: 10%; left: 15%; animation-delay: 0s; }
.particle-2 { top: 20%; right: 10%; animation-delay: 0.5s; }
.particle-3 { bottom: 20%; left: 10%; animation-delay: 1s; }
.particle-4 { bottom: 15%; right: 15%; animation-delay: 1.5s; }
@keyframes floatParticle {
0%, 100% { transform: translateY(0) rotate(0deg); opacity: 0.4; }
50% { transform: translateY(-20rpx) rotate(180deg); opacity: 1; }
}
/* 扩散波纹 V2 */
.ripple-v2 {
position: absolute;
inset: 40rpx;
border-radius: 50%;
border: 3rpx solid;
border-color: rgba(0, 206, 209, 0.6);
animation: rippleExpandV2 2.5s ease-out infinite;
}
.ripple-v2-1 { animation-delay: 0s; }
.ripple-v2-2 { animation-delay: 0.8s; }
.ripple-v2-3 { animation-delay: 1.6s; }
@keyframes rippleExpandV2 {
0% { transform: scale(1); opacity: 0.8; }
100% { transform: scale(1.8); opacity: 0; }
}
/* 新版匹配文字 */
.matching-title-v2 {
display: block;
font-size: 38rpx;
font-weight: 700;
color: #ffffff;
text-align: center;
margin-bottom: 12rpx;
background: linear-gradient(90deg, #00CED1, #7B61FF, #00CED1);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: shineText 2s linear infinite;
}
@keyframes shineText {
to { background-position: 200% center; }
}
.matching-subtitle-v2 {
display: block;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.5);
text-align: center;
margin-bottom: 32rpx;
}
.matching-tips {
display: flex;
flex-direction: column;
align-items: center;
gap: 16rpx;
margin-bottom: 40rpx;
}
.tip-item {
font-size: 26rpx;
color: #00CED1;
animation: fadeInUp 0.5s ease-out forwards;
opacity: 0;
}
.tip-item:nth-child(1) { animation-delay: 0.5s; }
.tip-item:nth-child(2) { animation-delay: 1.5s; }
.tip-item:nth-child(3) { animation-delay: 2.5s; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.cancel-btn-v2 {
display: inline-block;
padding: 20rpx 60rpx;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
font-size: 28rpx;
border-radius: 40rpx;
border: 1rpx solid rgba(255, 255, 255, 0.2);
}

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 我的页面 * Soul创业派对 - 我的页面
* 开发: 卡若 * 开发: 卡若
* 技术支持: 存客宝 * 技术支持: 存客宝
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 阅读页 * Soul创业派对 - 阅读页
* 开发: 卡若 * 开发: 卡若
* 技术支持: 存客宝 * 技术支持: 存客宝
*/ */
@@ -62,9 +62,11 @@ Page({
sectionId: id sectionId: id
}) })
// 保存推荐码 // 处理推荐码绑定
if (ref) { if (ref) {
console.log('[Read] 检测到推荐码:', ref)
wx.setStorageSync('referral_code', ref) wx.setStorageSync('referral_code', ref)
app.handleReferralCode({ query: { ref } })
} }
this.initSection(id) this.initSection(id)
@@ -281,15 +283,33 @@ Page({
}) })
}, },
// 分享到微信 // 分享到微信 - 自动带分享人ID
onShareAppMessage() { onShareAppMessage() {
const { section, sectionId } = this.data
const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || wx.getStorageSync('referralCode') || ''
// 分享标题优化
const shareTitle = section?.title
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
: '📚 Soul创业派对 - 真实商业故事'
return {
title: shareTitle,
path: `/pages/read/read?id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`,
imageUrl: '/assets/share-cover.png' // 可配置分享封面图
}
},
// 分享到朋友圈
onShareTimeline() {
const { section, sectionId } = this.data const { section, sectionId } = this.data
const userInfo = app.globalData.userInfo const userInfo = app.globalData.userInfo
const referralCode = userInfo?.referralCode || '' const referralCode = userInfo?.referralCode || ''
return { return {
title: `📚 ${section?.title || '推荐阅读'}`, title: `${section?.title || 'Soul创业派对'} - 来自派对房的真实故事`,
path: `/pages/read/read?id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}` query: `id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`
} }
}, },

View File

@@ -1,5 +1,5 @@
<!--pages/read/read.wxml--> <!--pages/read/read.wxml-->
<!--Soul创业实验 - 阅读页 1:1还原Web版本--> <!--Soul创业派对 - 阅读页-->
<view class="page"> <view class="page">
<!-- 阅读进度条 --> <!-- 阅读进度条 -->
<view class="progress-bar-fixed" style="top: {{statusBarHeight}}px;"> <view class="progress-bar-fixed" style="top: {{statusBarHeight}}px;">
@@ -138,6 +138,36 @@
<text class="paywall-tip">邀请好友加入享90%推广收益</text> <text class="paywall-tip">邀请好友加入享90%推广收益</text>
</view> </view>
<!-- 章节导航 - 付费内容也显示 -->
<view class="chapter-nav chapter-nav-locked">
<view class="nav-buttons">
<view
class="nav-btn nav-prev {{!prevSection ? 'nav-disabled' : ''}}"
bindtap="goToPrev"
wx:if="{{prevSection}}"
>
<text class="btn-label">上一篇</text>
<text class="btn-title">章节 {{prevSection.id}}</text>
</view>
<view class="nav-btn-placeholder" wx:else></view>
<view
class="nav-btn nav-next"
bindtap="goToNext"
wx:if="{{nextSection}}"
>
<text class="btn-label">下一篇</text>
<view class="btn-row">
<text class="btn-title">{{nextSection.title}}</text>
<text class="btn-arrow">→</text>
</view>
</view>
<view class="nav-btn nav-end" wx:else>
<text class="btn-end-text">已是最后一篇 🎉</text>
</view>
</view>
</view>
</view> </view>
</view> </view>
@@ -177,7 +207,7 @@
<view class="modal-content login-modal" catchtap="stopPropagation"> <view class="modal-content login-modal" catchtap="stopPropagation">
<view class="modal-close" bindtap="closeLoginModal">✕</view> <view class="modal-close" bindtap="closeLoginModal">✕</view>
<view class="login-icon">🔐</view> <view class="login-icon">🔐</view>
<text class="login-title">登录 Soul创业实验</text> <text class="login-title">登录 Soul创业派对</text>
<text class="login-desc">登录后可购买章节、参与匹配、赚取佣金</text> <text class="login-desc">登录后可购买章节、参与匹配、赚取佣金</text>
<button class="btn-wechat" bindtap="handleWechatLogin"> <button class="btn-wechat" bindtap="handleWechatLogin">

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 分销中心页 * Soul创业派对 - 分销中心页
* 1:1还原Web版本 * 1:1还原Web版本
*/ */
const app = getApp() const app = getApp()
@@ -140,7 +140,7 @@ Page({
// 分享到朋友圈 // 分享到朋友圈
shareToMoments() { shareToMoments() {
const shareText = `🔥 发现一本超棒的创业实战书《一场Soul创业实验》!\n\n💡 62个真实商业案例从私域运营到资源整合干货满满\n\n🎁 通过我的链接购买立享5%优惠,我是 ${this.data.userInfo?.nickname || '卡若'} 推荐!\n\n👉 ${this.data.referralCode} 是我的专属邀请码\n\n#创业实验 #私域运营 #商业案例` const shareText = `🔥 发现一本超棒的创业实战书《Soul创业派对》!\n\n💡 62个真实商业案例从私域运营到资源整合干货满满\n\n🎁 通过我的链接购买立享5%优惠,我是 ${this.data.userInfo?.nickname || '卡若'} 推荐!\n\n👉 ${this.data.referralCode} 是我的专属邀请码\n\n#创业派对 #私域运营 #商业案例`
wx.setClipboardData({ wx.setClipboardData({
data: shareText, data: shareText,
@@ -155,14 +155,78 @@ Page({
}) })
}, },
// 提现 // 提现 - 直接到微信零钱
handleWithdraw() { async handleWithdraw() {
const earnings = parseFloat(this.data.earnings) const pendingEarnings = parseFloat(this.data.pendingEarnings) || 0
if (earnings < 10) {
if (pendingEarnings < 10) {
wx.showToast({ title: '满10元可提现', icon: 'none' }) wx.showToast({ title: '满10元可提现', icon: 'none' })
return return
} }
wx.showToast({ title: '提现功能开发中', icon: 'none' })
// 确认提现
wx.showModal({
title: '确认提现',
content: `将提现 ¥${pendingEarnings.toFixed(2)} 到您的微信零钱`,
confirmText: '立即提现',
success: async (res) => {
if (res.confirm) {
await this.doWithdraw(pendingEarnings)
}
}
})
},
// 执行提现
async doWithdraw(amount) {
wx.showLoading({ title: '提现中...' })
try {
const userId = app.globalData.userInfo?.id
if (!userId) {
wx.hideLoading()
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const res = await app.request('/api/withdraw', {
method: 'POST',
data: { userId, amount }
})
wx.hideLoading()
if (res.success) {
wx.showModal({
title: '提现成功 🎉',
content: `¥${amount.toFixed(2)} 已到账您的微信零钱`,
showCancel: false,
confirmText: '好的'
})
// 刷新数据
this.initData()
} else {
if (res.needBind) {
wx.showModal({
title: '需要绑定微信',
content: '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (modalRes) => {
if (modalRes.confirm) {
wx.navigateTo({ url: '/pages/settings/settings' })
}
}
})
} else {
wx.showToast({ title: res.error || '提现失败', icon: 'none' })
}
}
} catch (e) {
wx.hideLoading()
console.error('[Referral] 提现失败:', e)
wx.showToast({ title: '提现失败,请重试', icon: 'none' })
}
}, },
// 显示通知 // 显示通知
@@ -184,11 +248,20 @@ Page({
}) })
}, },
// 分享 // 分享 - 带推荐码
onShareAppMessage() { onShareAppMessage() {
return { return {
title: '📚 一场SOUL的创业实验场 - 来自派对房的真实商业故事', title: '📚 Soul创业派对 - 来自派对房的真实商业故事',
path: `/pages/index/index?ref=${this.data.referralCode}` path: `/pages/index/index?ref=${this.data.referralCode}`,
imageUrl: '/assets/share-cover.png'
}
},
// 分享到朋友圈
onShareTimeline() {
return {
title: `Soul创业派对 - 62个真实商业案例`,
query: `ref=${this.data.referralCode}`
} }
}, },

View File

@@ -0,0 +1,87 @@
/**
* Soul创业派对 - 章节搜索页
* 搜索章节标题和内容
*/
const app = getApp()
Page({
data: {
statusBarHeight: 44,
keyword: '',
results: [],
loading: false,
searched: false,
total: 0,
// 热门搜索
hotKeywords: ['私域', '电商', '流量', '赚钱', '创业', 'Soul', '抖音']
},
onLoad() {
this.setData({
statusBarHeight: app.globalData.statusBarHeight || 44
})
},
// 输入关键词
onInput(e) {
this.setData({ keyword: e.detail.value })
},
// 清空搜索
clearSearch() {
this.setData({
keyword: '',
results: [],
searched: false,
total: 0
})
},
// 点击热门关键词
onHotKeyword(e) {
const keyword = e.currentTarget.dataset.keyword
this.setData({ keyword })
this.doSearch()
},
// 执行搜索
async doSearch() {
const { keyword } = this.data
if (!keyword || keyword.trim().length < 1) {
wx.showToast({ title: '请输入搜索关键词', icon: 'none' })
return
}
this.setData({ loading: true, searched: true })
try {
const res = await app.request(`/api/book/search?q=${encodeURIComponent(keyword.trim())}`)
if (res && res.success) {
this.setData({
results: res.results || [],
total: res.total || 0
})
} else {
this.setData({ results: [], total: 0 })
}
} catch (e) {
console.error('搜索失败:', e)
wx.showToast({ title: '搜索失败', icon: 'none' })
this.setData({ results: [], total: 0 })
} finally {
this.setData({ loading: false })
}
},
// 跳转阅读
goToRead(e) {
const id = e.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/read/read?id=${id}` })
},
// 返回上一页
goBack() {
wx.navigateBack()
}
})

View File

@@ -0,0 +1,5 @@
{
"usingComponents": {},
"navigationStyle": "custom",
"navigationBarTitleText": "搜索"
}

View File

@@ -0,0 +1,92 @@
<!--pages/search/search.wxml-->
<!--章节搜索页-->
<view class="page">
<!-- 自定义导航栏 -->
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
<view class="nav-content">
<view class="back-btn" bindtap="goBack">
<text class="back-icon">←</text>
</view>
<view class="search-input-wrap">
<view class="search-icon-small">🔍</view>
<input
class="search-input"
placeholder="搜索章节标题或内容..."
value="{{keyword}}"
bindinput="onInput"
bindconfirm="doSearch"
confirm-type="search"
focus="{{true}}"
/>
<view class="clear-btn" wx:if="{{keyword}}" bindtap="clearSearch">×</view>
</view>
<view class="search-btn" bindtap="doSearch">搜索</view>
</view>
</view>
<!-- 主内容区 -->
<view class="main-content" style="padding-top: {{statusBarHeight + 56}}px;">
<!-- 热门搜索(未搜索时显示) -->
<view class="hot-section" wx:if="{{!searched}}">
<text class="section-title">热门搜索</text>
<view class="hot-tags">
<view
class="hot-tag"
wx:for="{{hotKeywords}}"
wx:key="*this"
bindtap="onHotKeyword"
data-keyword="{{item}}"
>{{item}}</view>
</view>
</view>
<!-- 搜索结果 -->
<view class="results-section" wx:if="{{searched}}">
<!-- 加载中 -->
<view class="loading-wrap" wx:if="{{loading}}">
<view class="loading-spinner"></view>
<text class="loading-text">搜索中...</text>
</view>
<!-- 结果列表 -->
<block wx:elif="{{results.length > 0}}">
<view class="results-header">
<text class="results-count">找到 {{total}} 个结果</text>
</view>
<view class="results-list">
<view
class="result-item"
wx:for="{{results}}"
wx:key="id"
bindtap="goToRead"
data-id="{{item.id}}"
>
<view class="result-header">
<text class="result-chapter">{{item.chapterLabel}}</text>
<view class="result-tags">
<text class="tag tag-match" wx:if="{{item.matchType === 'title'}}">标题匹配</text>
<text class="tag tag-match" wx:elif="{{item.matchType === 'content'}}">内容匹配</text>
<text class="tag tag-free" wx:if="{{item.isFree}}">免费</text>
</view>
</view>
<text class="result-title">{{item.title}}</text>
<text class="result-part">{{item.part}}</text>
<view class="result-content" wx:if="{{item.matchedContent}}">
<text class="content-preview">{{item.matchedContent}}</text>
</view>
<view class="result-arrow">→</view>
</view>
</view>
</block>
<!-- 无结果 -->
<view class="empty-wrap" wx:elif="{{!loading}}">
<text class="empty-icon">🔍</text>
<text class="empty-text">未找到相关章节</text>
<text class="empty-hint">换个关键词试试</text>
</view>
</view>
</view>
</view>

View File

@@ -0,0 +1,264 @@
/* 章节搜索页样式 */
.page {
min-height: 100vh;
background: linear-gradient(180deg, #0a0a0a 0%, #111111 100%);
}
/* 导航栏 */
.nav-bar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: rgba(10, 10, 10, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.nav-content {
display: flex;
align-items: center;
padding: 8rpx 24rpx;
height: 88rpx;
}
.back-btn {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
font-size: 40rpx;
color: #00CED1;
}
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
background: rgba(255,255,255,0.08);
border-radius: 40rpx;
padding: 0 24rpx;
height: 64rpx;
margin: 0 16rpx;
}
.search-icon-small {
font-size: 28rpx;
margin-right: 12rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #fff;
}
.search-input::placeholder {
color: rgba(255,255,255,0.4);
}
.clear-btn {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: rgba(255,255,255,0.5);
}
.search-btn {
font-size: 28rpx;
color: #00CED1;
padding: 0 16rpx;
}
/* 主内容 */
.main-content {
padding: 24rpx;
}
/* 热门搜索 */
.hot-section {
padding: 24rpx 0;
}
.section-title {
font-size: 28rpx;
color: rgba(255,255,255,0.6);
margin-bottom: 24rpx;
display: block;
}
.hot-tags {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.hot-tag {
background: rgba(0, 206, 209, 0.15);
color: #00CED1;
padding: 16rpx 32rpx;
border-radius: 32rpx;
font-size: 28rpx;
border: 1rpx solid rgba(0, 206, 209, 0.3);
}
/* 搜索结果 */
.results-section {
padding: 16rpx 0;
}
.results-header {
margin-bottom: 24rpx;
}
.results-count {
font-size: 26rpx;
color: rgba(255,255,255,0.5);
}
.results-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.result-item {
background: rgba(255,255,255,0.05);
border-radius: 24rpx;
padding: 28rpx;
position: relative;
border: 1rpx solid rgba(255,255,255,0.08);
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.result-chapter {
font-size: 24rpx;
color: #00CED1;
font-weight: 500;
}
.result-tags {
display: flex;
gap: 12rpx;
}
.tag {
font-size: 20rpx;
padding: 6rpx 16rpx;
border-radius: 20rpx;
}
.tag-match {
background: rgba(147, 112, 219, 0.2);
color: #9370DB;
}
.tag-free {
background: rgba(76, 175, 80, 0.2);
color: #4CAF50;
}
.result-title {
font-size: 30rpx;
color: #fff;
font-weight: 500;
line-height: 1.5;
display: block;
margin-bottom: 8rpx;
}
.result-part {
font-size: 24rpx;
color: rgba(255,255,255,0.5);
display: block;
}
.result-content {
margin-top: 16rpx;
padding-top: 16rpx;
border-top: 1rpx solid rgba(255,255,255,0.1);
}
.content-preview {
font-size: 24rpx;
color: rgba(255,255,255,0.6);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.result-arrow {
position: absolute;
right: 28rpx;
top: 50%;
transform: translateY(-50%);
font-size: 32rpx;
color: rgba(255,255,255,0.3);
}
/* 加载状态 */
.loading-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(0, 206, 209, 0.3);
border-top-color: #00CED1;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin-top: 24rpx;
font-size: 28rpx;
color: rgba(255,255,255,0.5);
}
/* 空状态 */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 32rpx;
color: rgba(255,255,255,0.6);
margin-bottom: 12rpx;
}
.empty-hint {
font-size: 26rpx;
color: rgba(255,255,255,0.4);
}

View File

@@ -1,5 +1,5 @@
/** /**
* Soul创业实验 - 设置页 * Soul创业派对 - 设置页
* 账号绑定功能 * 账号绑定功能
*/ */
const app = getApp() const app = getApp()
@@ -115,7 +115,7 @@ Page({
return return
} }
// 保存绑定信息 // 保存绑定信息到本地
if (bindType === 'phone') { if (bindType === 'phone') {
wx.setStorageSync('user_phone', bindValue) wx.setStorageSync('user_phone', bindValue)
this.setData({ phoneNumber: bindValue }) this.setData({ phoneNumber: bindValue })
@@ -127,9 +127,122 @@ Page({
this.setData({ alipayAccount: bindValue }) this.setData({ alipayAccount: bindValue })
} }
// 同步到服务器
this.syncProfileToServer()
this.setData({ showBindModal: false }) this.setData({ showBindModal: false })
wx.showToast({ title: '绑定成功', icon: 'success' }) wx.showToast({ title: '绑定成功', icon: 'success' })
}, },
// 同步资料到服务器
async syncProfileToServer() {
try {
const userId = app.globalData.userInfo?.id
if (!userId) return
const res = await app.request('/api/user/profile', {
method: 'POST',
data: {
userId,
phone: this.data.phoneNumber || undefined,
wechatId: this.data.wechatId || undefined
}
})
if (res.success) {
console.log('[Settings] 资料同步成功')
// 更新本地用户信息
if (app.globalData.userInfo) {
app.globalData.userInfo.phone = this.data.phoneNumber
app.globalData.userInfo.wechatId = this.data.wechatId
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
}
} catch (e) {
console.log('[Settings] 资料同步失败:', e)
}
},
// 获取微信头像(新版授权)
async getWechatAvatar() {
try {
const res = await wx.getUserProfile({
desc: '用于完善会员资料'
})
if (res.userInfo) {
const { nickName, avatarUrl } = res.userInfo
// 更新本地
this.setData({
userInfo: {
...this.data.userInfo,
nickname: nickName,
avatar: avatarUrl
}
})
// 同步到服务器
const userId = app.globalData.userInfo?.id
if (userId) {
await app.request('/api/user/profile', {
method: 'POST',
data: { userId, nickname: nickName, avatar: avatarUrl }
})
}
// 更新全局
if (app.globalData.userInfo) {
app.globalData.userInfo.nickname = nickName
app.globalData.userInfo.avatar = avatarUrl
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
wx.showToast({ title: '头像更新成功', icon: 'success' })
}
} catch (e) {
console.log('[Settings] 获取头像失败:', e)
wx.showToast({ title: '获取头像失败', icon: 'none' })
}
},
// 获取微信手机号需要button组件配合
async getPhoneNumber(e) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
wx.showToast({ title: '授权失败', icon: 'none' })
return
}
try {
// 需要将code发送到服务器解密获取手机号
const code = e.detail.code
if (!code) {
wx.showToast({ title: '获取失败,请手动输入', icon: 'none' })
return
}
// 调用服务器解密手机号
const res = await app.request('/api/wechat/phone', {
method: 'POST',
data: { code }
})
if (res.success && res.phoneNumber) {
wx.setStorageSync('user_phone', res.phoneNumber)
this.setData({ phoneNumber: res.phoneNumber })
// 同步到服务器
this.syncProfileToServer()
wx.showToast({ title: '手机号绑定成功', icon: 'success' })
} else {
wx.showToast({ title: '获取失败,请手动输入', icon: 'none' })
}
} catch (e) {
console.log('[Settings] 获取手机号失败:', e)
wx.showToast({ title: '获取失败,请手动输入', icon: 'none' })
}
},
// 关闭绑定弹窗 // 关闭绑定弹窗
closeBindModal() { closeBindModal() {
@@ -177,12 +290,9 @@ Page({
}) })
}, },
// 联系客服 // 联系客服 - 跳转到Soul派对房
contactService() { contactService() {
wx.setClipboardData({ wx.showToast({ title: '请在Soul派对房联系客服', icon: 'none' })
data: '28533368',
success: () => wx.showToast({ title: '客服微信已复制', icon: 'success' })
})
}, },
// 阻止冒泡 // 阻止冒泡

View File

@@ -2,7 +2,7 @@
"compileType": "miniprogram", "compileType": "miniprogram",
"miniprogramRoot": "", "miniprogramRoot": "",
"projectname": "soul-startup", "projectname": "soul-startup",
"description": "一场SOUL的创业实验场 - 来自Soul派对房的真实商业故事", "description": "Soul创业派对 - 来自派对房的真实商业故事",
"appid": "wxb8bbb2b10dec74aa", "appid": "wxb8bbb2b10dec74aa",
"setting": { "setting": {
"urlCheck": false, "urlCheck": false,

1
tsconfig.tsbuildinfo Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,216 @@
# Soul创业派对 TDD需求方案 v1.0
> 生成时间: 2026-01-25
> 基于: Human 3.0 + CRITIC智能追问
---
## 一、需求摘要
**一句话定位**以卡若IP为核心通过真实创业案例内容吸引创业者匹配书中案例参与者形成"内容引流→私域沉淀→后端变现"的完整商业闭环。
**核心闭环**
```
Soul派对引流 → 小程序阅读 → 付费解锁 → 匹配案例人物 → 加入会员 → 私域变现
```
---
## 二、项目背景
| 维度 | 内容 |
|:---|:---|
| **项目名称** | Soul创业派对创业实验 |
| **项目阶段** | 已上线,持续迭代 |
| **核心目标** | 验证"IP+内容+匹配+私域"商业模式,支撑存客宝+碎片时间团队运转 |
| **验收人** | 卡若 |
| **最终目标** | 1万人付款9.9元 + 100个创业者365会员 + 100个匹配会员 |
---
## 三、用户画像
| 维度 | 内容 |
|:---|:---|
| **主要用户** | 已创业的老板B端 |
| **用户来源** | Soul派对房 + 小程序分享裂变 |
| **付费动机** | ①真实案例数据别处没有②卡若IP背书 |
| **用户旅程** | 浏览者 → 付费书友 → 分销者 → 匹配会员 → 后端客户 |
---
## 四、竞品分析
### 4.1 直接竞品
| 竞品 | 模式 | 优势 | 劣势 | 创业派对差异化 |
|:---|:---|:---|:---|:---|
| **得到** | 课程付费 | 头部IP多品牌强 | 价格高,内容泛化 | 垂直创业领域,真实案例 |
| **知识星球** | 社群付费 | 社群沉淀强 | 内容碎片化 | 结构化书籍+匹配功能 |
| **小报童** | 专栏付费 | 轻量,门槛低 | 无匹配功能 | 匹配书中案例人物 |
| **冯唐成事不二堂** | IP+课程 | 年营收过亿 | 偏商业管理 | 聚焦创业实战案例 |
| **樊登读书(帆书)** | 读书会员 | 用户基数大 | 内容为书籍解读 | 原创真实案例 |
### 4.2 核心护城河
1. **Soul渠道独家**Soul派对房日活用户精准创业人群
2. **匹配功能独家**:可匹配到书中案例的真实参与者
3. **卡若IP独家**365天连续直播信任背书强
4. **真实数据独家**:收入、成本、流程等真实商业数据
---
## 五、功能范围
### 5.1 核心功能(当前版本)
| 模块 | 功能点 | 优先级 | 状态 |
|:---|:---|:---|:---|
| **内容阅读** | 62章节阅读 + 上下篇导航 | P0 | ✅已完成 |
| **内容搜索** | 搜索数据库+文章内容(匹配数据隐藏) | P0 | 待优化 |
| **付费解锁** | 单章1元 / 全书9.9元 | P0 | ✅已完成 |
| **分销裂变** | 90%佣金(第一阶段) | P0 | ✅已完成 |
| **找伙伴匹配** | 创业合伙/资源对接/导师顾问/团队招募 | P0 | ✅已完成 |
| **匹配付费** | 免费3次/天之后付费默认1元可配置 | P1 | 待完善 |
| **后台管理** | 内容管理/用户管理/配置管理 | P0 | 待完善 |
### 5.2 本期不做
| 功能 | 原因 |
|:---|:---|
| 视频/音频内容 | 优先验证图文模式 |
| 社群功能 | 后端用微信群承接 |
| 多平台分发 | 先专注小程序 |
---
## 六、技术约束
| 维度 | 约束 |
|:---|:---|
| **前端** | 微信小程序(原生) |
| **后端** | Next.js API Routes |
| **数据库** | MySQL腾讯云 |
| **部署** | 腾讯云服务器 |
| **支付** | 微信支付 |
| **响应时间** | <3秒 |
| **并发** | 100人同时在线 |
---
## 七、匹配功能配置
### 7.1 匹配类型配置
| 类型ID | 名称 | 匹配标签 | 图标 | 从数据库匹配 | 匹配后显示加入 | 默认价格 | 启用 |
|:---|:---|:---|:---|:---|:---|:---|:---|
| partner | 创业合伙 | 创业伙伴 | | | | 1元 | |
| investor | 资源对接 | 资源对接 | 👥 | | | 1元 | |
| mentor | 导师顾问 | 商业顾问 | | | | 1元 | |
| team | 团队招募 | 加入项目 | 🎮 | | | 1元 | |
### 7.2 匹配规则
- **每日免费次数**3次
- **付费匹配价格**默认1元后台可配置
- **匹配数据隐藏**搜索时隐藏手机/微信等敏感信息
- **匹配前置条件**需绑定手机号或微信号
---
## 八、分销规则
| 维度 | 规则 |
|:---|:---|
| **佣金比例** | 90%第一阶段62章节 |
| **佣金期限** | 不定期调整 |
| **新增章节** | 每增加1章 +1元 |
| **绑定有效期** | 30天 |
| **最低提现** | 10元 |
---
## 九、异常处理规则
| 异常场景 | 处理方式 |
|:---|:---|
| 支付成功但回调失败 | 定时补单 + 用户申诉入口 |
| 匹配无结果 | 显示"暂无匹配试试其他类型" |
| API超时 | 显示loading + 3秒后重试 |
| 数据库连接失败 | 降级为本地配置 |
---
## 十、测试用例清单
### 10.1 正常用例
| 用例ID | 场景 | 输入 | 期望输出 |
|:---|:---|:---|:---|
| T01 | 免费章节阅读 | 点击免费章节 | 显示完整内容 |
| T02 | 付费章节购买 | 点击购买本章 | 调起微信支付 |
| T03 | 匹配创业伙伴 | 选择创业合伙+点击匹配 | 显示匹配结果 |
| T04 | 分享带推荐码 | 点击分享 | 链接包含ref参数 |
### 10.2 边界用例
| 用例ID | 场景 | 输入 | 期望输出 |
|:---|:---|:---|:---|
| T10 | 匹配次数用完 | 第4次匹配 | 显示付费弹窗 |
| T11 | 未绑定手机匹配 | 点击匹配 | 提示先绑定手机/微信 |
| T12 | 搜索敏感信息 | 搜索手机号 | 不返回结果 |
### 10.3 异常用例
| 用例ID | 场景 | 触发条件 | 期望行为 |
|:---|:---|:---|:---|
| T20 | 支付失败 | 用户取消支付 | 返回阅读页保持状态 |
| T21 | 网络断开 | 离线状态 | 显示缓存内容 |
---
## 十一、验收标准
| 验收项 | 标准 | 验收人 |
|:---|:---|:---|
| 功能完整 | 符合TDD文档 | 卡若 |
| 支付成功率 | >95% | 卡若 |
| 页面加载 | <3秒 | 卡若 |
| 匹配体验 | 动画流畅 | 卡若 |
---
## 十二、运营指标(参考真实数据)
基于提供的1月数据截图
| 指标 | 1月数据 | 目标 |
|:---|:---|:---|
| Soul曝光人数 | 日均3-6万 | 保持 |
| 进入人数 | 日均100-500 | 提升至1000 |
| 入群数量 | 日均1-7人 | 提升至20人 |
| 微信进粉人数 | 日均1-5人 | 提升至10人 |
| 付费转化率 | 待统计 | 8% |
---
## 十三、下一步行动
1. 完善后台匹配配置管理
2. 优化匹配次数用完后的付费流程
3. 更新小程序品牌名称为"创业派对"
4. 添加搜索功能隐藏敏感数据
5. 增加用户标签系统浏览者付费者分销者
6. 建立裂变机制邀请有礼
---
## 十四、版本记录
| 版本 | 日期 | 变更内容 |
|:---|:---|:---|
| 1.0 | 2026-01-25 | 初版基于智能追问生成 |
---
> "好问题比好答案更有价值。" — 卡若