feat: 完善后台管理+搜索功能+分销系统
主要更新: - 后台菜单精简(9项→6项) - 新增搜索功能(敏感信息过滤) - 分销绑定和提现系统完善 - 数据库初始化API(自动修复表结构) - 用户管理:显示绑定关系详情 - 小程序:上下章导航优化、匹配页面重构 - 修复hydration和数据类型问题
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
Upload,
|
||||
Eye,
|
||||
Database,
|
||||
Plus,
|
||||
Image as ImageIcon,
|
||||
Trash2,
|
||||
Search,
|
||||
} from "lucide-react"
|
||||
|
||||
interface EditingSection {
|
||||
@@ -34,6 +38,9 @@ interface EditingSection {
|
||||
price: number
|
||||
content?: string
|
||||
filePath?: string
|
||||
isNew?: boolean
|
||||
partId?: string
|
||||
chapterId?: string
|
||||
}
|
||||
|
||||
export default function ContentPage() {
|
||||
@@ -46,9 +53,26 @@ export default function ContentPage() {
|
||||
const [feishuDocUrl, setFeishuDocUrl] = useState("")
|
||||
const [showFeishuModal, setShowFeishuModal] = useState(false)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [showNewSectionModal, setShowNewSectionModal] = useState(false)
|
||||
const [importData, setImportData] = useState("")
|
||||
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 imageInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// 新建章节表单
|
||||
const [newSection, setNewSection] = useState({
|
||||
id: "",
|
||||
title: "",
|
||||
price: 1,
|
||||
partId: "part-1",
|
||||
chapterId: "chapter-1",
|
||||
content: "",
|
||||
})
|
||||
|
||||
const togglePart = (partId: string) => {
|
||||
setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId]))
|
||||
@@ -69,8 +93,8 @@ export default function ContentPage() {
|
||||
if (data.success) {
|
||||
setEditingSection({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
price: section.price,
|
||||
title: data.section.title || section.title,
|
||||
price: data.section.price || section.price,
|
||||
content: data.section.content || "",
|
||||
filePath: section.filePath,
|
||||
})
|
||||
@@ -103,6 +127,7 @@ export default function ContentPage() {
|
||||
const handleSaveSection = async () => {
|
||||
if (!editingSection) return
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/db/book', {
|
||||
method: 'PUT',
|
||||
@@ -126,6 +151,110 @@ export default function ContentPage() {
|
||||
} catch (error) {
|
||||
console.error("Save section error:", error)
|
||||
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 = ``
|
||||
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)
|
||||
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()
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message)
|
||||
alert(data.data?.message || '初始化成功')
|
||||
} else {
|
||||
alert("初始化失败: " + (data.error || "未知错误"))
|
||||
}
|
||||
@@ -506,6 +639,116 @@ export default function ContentPage() {
|
||||
</DialogContent>
|
||||
</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)}>
|
||||
<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 className="space-y-2">
|
||||
<Label className="text-gray-300">文件路径</Label>
|
||||
<Label className="text-gray-300">价格 (元)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white text-sm"
|
||||
value={editingSection.filePath || ""}
|
||||
disabled
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={editingSection.price}
|
||||
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,17 +787,43 @@ export default function ContentPage() {
|
||||
onChange={(e) => setEditingSection({ ...editingSection, title: e.target.value })}
|
||||
/>
|
||||
</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">
|
||||
<Label className="text-gray-300">价格 (元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white w-32"
|
||||
value={editingSection.price}
|
||||
onChange={(e) => setEditingSection({ ...editingSection, price: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">内容 (Markdown格式)</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">内容 (Markdown格式)</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<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 ? (
|
||||
<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" />
|
||||
@@ -579,9 +849,22 @@ export default function ContentPage() {
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveSection} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存修改
|
||||
<Button
|
||||
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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -596,6 +879,13 @@ export default function ContentPage() {
|
||||
<BookOpen className="w-4 h-4 mr-2" />
|
||||
章节管理
|
||||
</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
|
||||
value="hooks"
|
||||
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
|
||||
@@ -606,6 +896,15 @@ export default function ContentPage() {
|
||||
</TabsList>
|
||||
|
||||
<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) => (
|
||||
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
|
||||
<CardHeader
|
||||
@@ -679,6 +978,66 @@ export default function ContentPage() {
|
||||
))}
|
||||
</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">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
|
||||
@@ -1,44 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { LayoutDashboard, FileText, Users, CreditCard, QrCode, Settings, LogOut, Wallet, Globe, Share2 } from "lucide-react"
|
||||
import { useStore } from "@/lib/store"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { LayoutDashboard, FileText, Users, CreditCard, Settings, LogOut, Wallet, Globe, BookOpen } from "lucide-react"
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { user, isLoggedIn } = useStore()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
// router.push("/my")
|
||||
}
|
||||
}, [isLoggedIn, router])
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// 简化菜单:按功能归类,保留核心功能
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "数据概览", href: "/admin" },
|
||||
{ icon: Globe, label: "网站配置", href: "/admin/site" },
|
||||
{ icon: FileText, label: "内容管理", href: "/admin/content" },
|
||||
{ icon: BookOpen, label: "内容管理", href: "/admin/content" },
|
||||
{ icon: Users, label: "用户管理", href: "/admin/users" },
|
||||
{ icon: Share2, label: "分销管理", href: "/admin/distribution" },
|
||||
{ icon: CreditCard, label: "支付配置", href: "/admin/payment" },
|
||||
{ icon: Wallet, label: "提现管理", href: "/admin/withdrawals" },
|
||||
{ icon: QrCode, label: "二维码", href: "/admin/qrcodes" },
|
||||
{ icon: Wallet, label: "分账管理", href: "/admin/withdrawals" },
|
||||
{ icon: CreditCard, label: "支付设置", href: "/admin/payment" },
|
||||
{ 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 (
|
||||
<div className="flex min-h-screen bg-[#0a1628]">
|
||||
{/* Sidebar - 深色侧边栏 */}
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
|
||||
517
app/admin/match/page.tsx
Normal file
517
app/admin/match/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -9,36 +9,48 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
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 {
|
||||
id: string
|
||||
phone: string
|
||||
open_id?: string | null
|
||||
phone?: string | null
|
||||
nickname: string
|
||||
password?: string
|
||||
is_admin?: boolean
|
||||
has_full_book?: boolean
|
||||
password?: string | null
|
||||
wechat_id?: string | null
|
||||
avatar?: string | null
|
||||
is_admin?: boolean | number
|
||||
has_full_book?: boolean | number
|
||||
referral_code: string
|
||||
referred_by?: string
|
||||
earnings: number
|
||||
pending_earnings: number
|
||||
withdrawn_earnings: number
|
||||
referred_by?: string | null
|
||||
earnings: number | string
|
||||
pending_earnings: number | string
|
||||
withdrawn_earnings?: number | string
|
||||
referral_count: number
|
||||
match_count_today?: number
|
||||
last_match_date?: string
|
||||
last_match_date?: string | null
|
||||
purchased_sections?: string[] | string | null
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
function UsersContent() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [newPassword, setNewPassword] = useState("")
|
||||
const [confirmPassword, setConfirmPassword] = useState("")
|
||||
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({
|
||||
@@ -52,14 +64,18 @@ function UsersContent() {
|
||||
// 加载用户列表
|
||||
const loadUsers = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/db/users')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setUsers(data.users || [])
|
||||
} else {
|
||||
setError(data.error || '加载失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load users error:', error)
|
||||
} catch (err) {
|
||||
console.error('Load users error:', err)
|
||||
setError('网络错误,请检查连接')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -95,8 +111,8 @@ function UsersContent() {
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setFormData({
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
phone: user.phone || "",
|
||||
nickname: user.nickname || "",
|
||||
password: "",
|
||||
is_admin: user.is_admin || false,
|
||||
has_full_book: user.has_full_book || false,
|
||||
@@ -181,6 +197,28 @@ function UsersContent() {
|
||||
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 () => {
|
||||
if (!newPassword) {
|
||||
@@ -384,6 +422,92 @@ function UsersContent() {
|
||||
</DialogContent>
|
||||
</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">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
@@ -396,10 +520,10 @@ function UsersContent() {
|
||||
<TableHeader>
|
||||
<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-right text-gray-400">操作</TableHead>
|
||||
</TableRow>
|
||||
@@ -410,7 +534,11 @@ function UsersContent() {
|
||||
<TableCell>
|
||||
<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]">
|
||||
{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 className="flex items-center gap-2">
|
||||
@@ -420,12 +548,45 @@ function UsersContent() {
|
||||
管理员
|
||||
</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>
|
||||
<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>
|
||||
</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>
|
||||
{user.has_full_book ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">全书已购</Badge>
|
||||
@@ -435,8 +596,31 @@ function UsersContent() {
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-white font-medium">¥{(user.earnings || 0).toFixed(2)}</TableCell>
|
||||
<TableCell className="text-gray-300">{user.match_count_today || 0}/3</TableCell>
|
||||
<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">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : "-"}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,172 +1,305 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useStore } from "@/lib/store"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
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() {
|
||||
const { withdrawals, completeWithdrawal } = useStore()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||
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(() => {
|
||||
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 =
|
||||
withdrawals
|
||||
?.filter((w) => w.status !== "pending")
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) || []
|
||||
// 拒绝提现
|
||||
const handleReject = async (id: string) => {
|
||||
const reason = prompt("请输入拒绝原因(将返还用户余额):")
|
||||
if (!reason) return
|
||||
|
||||
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 handleApprove = (id: string) => {
|
||||
if (confirm("确认打款并完成此提现申请吗?")) {
|
||||
completeWithdrawal(id)
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge className="bg-orange-500/20 text-orange-400 hover:bg-orange-500/20 border-0">待处理</Badge>
|
||||
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 (
|
||||
<div className="p-8 max-w-6xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">提现管理</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
待处理 {pendingWithdrawals.length} 笔,共 ¥{totalPending.toFixed(2)}
|
||||
</p>
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">分账提现管理</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
管理用户分销收益的提现申请
|
||||
</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 className="grid gap-6">
|
||||
{/* 待处理申请 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<div className="p-2 rounded-lg bg-orange-500/20">
|
||||
<Clock className="w-5 h-5 text-orange-400" />
|
||||
{/* 分账规则说明 */}
|
||||
<Card className="bg-gradient-to-r from-[#38bdac]/10 to-[#0f2137] border-[#38bdac]/30 mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<DollarSign className="w-5 h-5 text-[#38bdac] mt-0.5" />
|
||||
<div>
|
||||
<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>
|
||||
待处理申请 ({pendingWithdrawals.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{pendingWithdrawals.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">
|
||||
{pendingWithdrawals.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>
|
||||
<p className="font-medium text-white">{w.name}</p>
|
||||
<p className="text-xs text-gray-500 font-mono">{w.userId.slice(0, 8)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-[#38bdac]">{stats.total}</div>
|
||||
<div className="text-sm text-gray-400">总申请</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-orange-400">{stats.pendingCount}</div>
|
||||
<div className="text-sm text-gray-400">待处理</div>
|
||||
<div className="text-xs text-orange-400 mt-1">¥{stats.pendingAmount.toFixed(2)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-400">{stats.successCount}</div>
|
||||
<div className="text-sm text-gray-400">已完成</div>
|
||||
<div className="text-xs text-green-400 mt-1">¥{stats.successAmount.toFixed(2)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<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>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Badge
|
||||
className={
|
||||
w.method === "wechat"
|
||||
? "bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0"
|
||||
: "bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0"
|
||||
}
|
||||
>
|
||||
{w.method === "wechat" ? "微信" : "支付宝"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4 font-mono text-gray-300">{w.account}</td>
|
||||
<td className="p-4">
|
||||
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApprove(w.id)}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
确认打款
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 处理历史 */}
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<div className="p-2 rounded-lg bg-gray-700/50">
|
||||
<History className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
处理历史
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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>
|
||||
<div>
|
||||
<p className="font-medium text-white">{w.userNickname}</p>
|
||||
<p className="text-xs text-gray-500">{w.userPhone || w.userId.slice(0, 10)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="font-bold text-orange-400">¥{w.amount.toFixed(2)}</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{getStatusBadge(w.status)}
|
||||
{w.errorMessage && (
|
||||
<p className="text-xs text-red-400 mt-1">{w.errorMessage}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
{w.status === 'pending' && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApprove(w.id)}
|
||||
disabled={processing === w.id}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
批准
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleReject(w.id)}
|
||||
disabled={processing === w.id}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{w.status === 'success' && w.transactionId && (
|
||||
<span className="text-xs text-gray-500 font-mono">{w.transactionId}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{historyWithdrawals.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 text-gray-400">
|
||||
{w.completedAt ? new Date(w.completedAt).toLocaleString() : "-"}
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
169
app/api/admin/withdrawals/route.ts
Normal file
169
app/api/admin/withdrawals/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
150
app/api/book/search/route.ts
Normal file
150
app/api/book/search/route.ts
Normal 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
425
app/api/db/book/route.ts
Normal 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
300
app/api/db/config/route.ts
Normal 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] 配置同步完成')
|
||||
}
|
||||
@@ -1,83 +1,173 @@
|
||||
/**
|
||||
* 数据库初始化API
|
||||
* 创建数据库表结构和默认配置
|
||||
* 数据库初始化/升级API
|
||||
* 用于添加缺失的字段,确保表结构完整
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { initDatabase } from '@/lib/db'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
|
||||
/**
|
||||
* POST - 初始化数据库
|
||||
* GET - 初始化/升级数据库表结构
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
const results: string[] = []
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { adminToken } = body
|
||||
|
||||
// 简单的管理员验证
|
||||
if (adminToken !== 'init_db_2025') {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '无权限执行此操作'
|
||||
}, { status: 403 })
|
||||
console.log('[DB Init] 开始检查并升级数据库结构...')
|
||||
|
||||
// 1. 检查users表是否存在
|
||||
try {
|
||||
await query('SELECT 1 FROM users LIMIT 1')
|
||||
results.push('✅ users表已存在')
|
||||
} catch (e) {
|
||||
// 创建users表
|
||||
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] 开始初始化数据库...')
|
||||
|
||||
await initDatabase()
|
||||
|
||||
console.log('[DB Init] 数据库初始化完成')
|
||||
|
||||
|
||||
// 2. 修改open_id字段允许NULL(后台添加用户时可能没有openId)
|
||||
try {
|
||||
await query('ALTER TABLE users MODIFY COLUMN open_id VARCHAR(100) NULL')
|
||||
results.push('✅ 修改open_id允许NULL')
|
||||
} 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({
|
||||
success: true,
|
||||
data: {
|
||||
message: '数据库初始化成功',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
message: '数据库初始化/升级完成',
|
||||
results
|
||||
})
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DB Init] 数据库初始化失败:', error)
|
||||
console.error('[DB Init] 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '数据库初始化失败: ' + (error as Error).message
|
||||
error: '数据库初始化失败: ' + (error as Error).message,
|
||||
results
|
||||
}, { 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 })
|
||||
}
|
||||
}
|
||||
105
app/api/db/users/referrals/route.ts
Normal file
105
app/api/db/users/referrals/route.ts
Normal 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
262
app/api/db/users/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
{
|
||||
id: 'partner',
|
||||
label: '创业合伙',
|
||||
matchLabel: '创业伙伴',
|
||||
icon: '⭐',
|
||||
matchFromDB: true,
|
||||
showJoinAfterMatch: false,
|
||||
description: '寻找志同道合的创业伙伴,共同打造事业',
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: 'investor',
|
||||
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 })
|
||||
// 默认匹配配置
|
||||
const DEFAULT_MATCH_CONFIG = {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - 更新匹配类型配置(管理员功能)
|
||||
* GET - 获取匹配配置
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { matchTypes, settings, adminToken } = body
|
||||
|
||||
// TODO: 验证管理员权限
|
||||
if (!adminToken || adminToken !== 'admin_token_placeholder') {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '无权限操作'
|
||||
}, { status: 403 })
|
||||
// 优先从数据库读取
|
||||
let config = null
|
||||
try {
|
||||
config = await getConfig('match_config')
|
||||
} catch (e) {
|
||||
console.log('[MatchConfig] 数据库读取失败,使用默认配置')
|
||||
}
|
||||
|
||||
console.log('[MatchConfig] 更新匹配配置:', { matchTypes: matchTypes?.length, settings })
|
||||
|
||||
// TODO: 保存到数据库
|
||||
// 这里应该将配置保存到数据库
|
||||
|
||||
// 合并默认配置
|
||||
const finalConfig = {
|
||||
...DEFAULT_MATCH_CONFIG,
|
||||
...(config || {})
|
||||
}
|
||||
|
||||
// 只返回启用的匹配类型
|
||||
const enabledTypes = finalConfig.matchTypes.filter((t: any) => t.enabled !== false)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
message: '配置更新成功',
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
matchTypes: enabledTypes,
|
||||
freeMatchLimit: finalConfig.freeMatchLimit,
|
||||
matchPrice: finalConfig.matchPrice,
|
||||
settings: finalConfig.settings
|
||||
},
|
||||
source: config ? 'database' : 'default'
|
||||
})
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MatchConfig] 更新匹配配置失败:', error)
|
||||
console.error('[MatchConfig] GET错误:', error)
|
||||
|
||||
// 出错时返回默认配置
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '更新匹配配置失败'
|
||||
}, { status: 500 })
|
||||
success: true,
|
||||
data: {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -61,36 +61,93 @@ export async function POST(request: Request) {
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
// 创建或更新用户
|
||||
// TODO: 这里应该连接数据库操作
|
||||
const user = {
|
||||
id: `user_${openId.slice(-8)}`,
|
||||
openId,
|
||||
nickname: '微信用户',
|
||||
avatar: '',
|
||||
referralCode: 'SOUL' + Date.now().toString(36).toUpperCase().slice(-6),
|
||||
purchasedSections: [],
|
||||
hasFullBook: false,
|
||||
earnings: 0,
|
||||
pendingEarnings: 0,
|
||||
referralCount: 0,
|
||||
createdAt: new Date().toISOString()
|
||||
// 创建或更新用户 - 连接数据库
|
||||
let user: any = null
|
||||
let isNewUser = false
|
||||
|
||||
try {
|
||||
const { query } = await import('@/lib/db')
|
||||
|
||||
// 查询用户是否存在
|
||||
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 = ?', [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
|
||||
const token = `tk_${openId.slice(-8)}_${Date.now()}`
|
||||
|
||||
console.log('[MiniLogin] 登录成功, userId:', user.id)
|
||||
console.log('[MiniLogin] 登录成功, userId:', responseUser.id, isNewUser ? '(新用户)' : '(老用户)')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
openId,
|
||||
sessionKey, // 注意:生产环境不应返回sessionKey给前端
|
||||
unionId,
|
||||
user,
|
||||
user: responseUser,
|
||||
token,
|
||||
}
|
||||
},
|
||||
isNewUser
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
|
||||
201
app/api/referral/bind/route.ts
Normal file
201
app/api/referral/bind/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const userId = searchParams.get('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)
|
||||
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: '获取推广数据失败'
|
||||
}, { status: 500 })
|
||||
error: '用户ID不能为空'
|
||||
}, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST - 创建推广绑定关系
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { referralCode, userId, userInfo } = body
|
||||
|
||||
if (!referralCode || !userId) {
|
||||
// 1. 获取用户基本信息
|
||||
const users = await query(`
|
||||
SELECT id, nickname, referral_code, earnings, pending_earnings,
|
||||
withdrawn_earnings, referral_count
|
||||
FROM users WHERE id = ?
|
||||
`, [userId]) as any[]
|
||||
|
||||
if (users.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '缺少必要参数'
|
||||
}, { status: 400 })
|
||||
error: '用户不存在'
|
||||
}, { 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({
|
||||
success: true,
|
||||
data: {
|
||||
bindingId: `binding_${Date.now()}`,
|
||||
referrerId: `referrer_${referralCode}`,
|
||||
userId,
|
||||
bindingDate: new Date().toISOString(),
|
||||
expiryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
status: 'active'
|
||||
// 收益数据
|
||||
earnings: parseFloat(user.earnings) || 0,
|
||||
pendingEarnings: parseFloat(user.pending_earnings) || 0,
|
||||
withdrawnEarnings: parseFloat(user.withdrawn_earnings) || 0,
|
||||
|
||||
// 推荐码
|
||||
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) {
|
||||
console.error('[ReferralData] 创建绑定关系失败:', error)
|
||||
console.error('[ReferralData] 错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '创建绑定关系失败'
|
||||
error: '获取分销数据失败: ' + (error as Error).message
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
273
app/api/search/route.ts
Normal file
273
app/api/search/route.ts
Normal 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
134
app/api/upload/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
170
app/api/user/profile/route.ts
Normal file
170
app/api/user/profile/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,17 @@
|
||||
// 微信小程序登录接口
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { query } from '@/lib/db'
|
||||
|
||||
const APPID = process.env.WECHAT_APPID || 'wx0976665c3a3d5a7c'
|
||||
const SECRET = process.env.WECHAT_APPSECRET || 'a262f1be43422f03734f205d0bca1882'
|
||||
// 使用真实的小程序AppID和Secret
|
||||
const APPID = process.env.WECHAT_APPID || 'wxb8bbb2b10dec74aa'
|
||||
const SECRET = process.env.WECHAT_APPSECRET || '3c1fb1f63e6e052222bbcead9d07fe0c'
|
||||
|
||||
// POST: 微信小程序登录
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const { code } = body
|
||||
const { code, referralCode } = body
|
||||
|
||||
if (!code) {
|
||||
return NextResponse.json(
|
||||
@@ -35,27 +37,101 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const { openid, session_key, unionid } = wxData
|
||||
|
||||
// TODO: 将openid和session_key存储到数据库
|
||||
// 这里简单生成一个token
|
||||
// 生成token
|
||||
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 = {
|
||||
id: openid,
|
||||
openid,
|
||||
// 统一用户数据格式
|
||||
const responseUser = {
|
||||
id: user.id,
|
||||
openId: user.open_id || openid,
|
||||
unionid,
|
||||
nickname: '用户' + openid.substr(-4),
|
||||
avatar: 'https://picsum.photos/200/200?random=' + openid.substr(-2),
|
||||
inviteCode: generateInviteCode(openid),
|
||||
isPurchased: false,
|
||||
createdAt: new Date().toISOString()
|
||||
nickname: user.nickname,
|
||||
avatar: user.avatar,
|
||||
phone: user.phone,
|
||||
wechatId: user.wechat_id,
|
||||
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({
|
||||
success: true,
|
||||
token,
|
||||
user,
|
||||
message: '登录成功'
|
||||
user: responseUser,
|
||||
isNewUser,
|
||||
message: isNewUser ? '注册成功' : '登录成功'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('登录接口错误:', error)
|
||||
|
||||
235
app/api/withdraw/route.ts
Normal file
235
app/api/withdraw/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useState } from "react"
|
||||
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 { bookData, getTotalSectionCount, specialSections, getPremiumBookPrice, getExtraSectionsCount, BASE_SECTIONS_COUNT } from "@/lib/book-data"
|
||||
import { SearchModal } from "@/components/search-modal"
|
||||
|
||||
export default function ChaptersPage() {
|
||||
const router = useRouter()
|
||||
@@ -12,6 +13,7 @@ export default function ChaptersPage() {
|
||||
const [expandedPart, setExpandedPart] = useState<string | null>("part-1")
|
||||
const [bookVersion, setBookVersion] = useState<"basic" | "premium">("basic")
|
||||
const [showPremiumTab, setShowPremiumTab] = useState(false) // 控制是否显示最新完整版标签
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
const totalSections = getTotalSectionCount()
|
||||
const hasFullBook = user?.hasFullBook || false
|
||||
@@ -25,11 +27,21 @@ export default function ChaptersPage() {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</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="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">
|
||||
|
||||
10
app/page.tsx
10
app/page.tsx
@@ -10,12 +10,13 @@ import { useRouter } from "next/navigation"
|
||||
import { Search, ChevronRight, BookOpen, Home, List, User, Users } from "lucide-react"
|
||||
import { useStore } from "@/lib/store"
|
||||
import { bookData, getTotalSectionCount } from "@/lib/book-data"
|
||||
import { SearchModal } from "@/components/search-modal"
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const { user } = useStore()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
const totalSections = getTotalSectionCount()
|
||||
const hasFullBook = user?.hasFullBook || false
|
||||
@@ -64,14 +65,17 @@ export default function HomePage() {
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div
|
||||
onClick={() => router.push("/chapters")}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl bg-[#1c1c1e] border border-white/5 cursor-pointer"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
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" />
|
||||
<span className="text-gray-500 text-sm">搜索章节...</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 搜索弹窗 */}
|
||||
<SearchModal open={searchOpen} onOpenChange={setSearchOpen} />
|
||||
|
||||
<main className="px-4 space-y-5">
|
||||
{/* Banner卡片 - 最新章节 */}
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# 1.1 荷包:电动车出租的被动收入模式
|
||||
|
||||
"每个人都在梦想特斯拉帮他挣钱,我现在电动车帮我挣钱。"
|
||||
|
||||
2025年10月21日,周一,早上6点18分。
|
||||
|
||||
212
components/search-modal.tsx
Normal file
212
components/search-modal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
lib/db.ts
50
lib/db.ts
@@ -58,28 +58,62 @@ export async function initDatabase() {
|
||||
try {
|
||||
console.log('开始初始化数据库表结构...')
|
||||
|
||||
// 用户表
|
||||
// 用户表(完整字段)
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
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),
|
||||
avatar VARCHAR(500),
|
||||
phone VARCHAR(20),
|
||||
wechat_id VARCHAR(100),
|
||||
password VARCHAR(100) COMMENT '密码(可选)',
|
||||
wechat_id VARCHAR(100) COMMENT '用户填写的微信号',
|
||||
referral_code VARCHAR(20) UNIQUE,
|
||||
purchased_sections JSON,
|
||||
referred_by VARCHAR(50) COMMENT '推荐人ID',
|
||||
purchased_sections JSON DEFAULT '[]',
|
||||
has_full_book BOOLEAN DEFAULT FALSE,
|
||||
earnings DECIMAL(10,2) DEFAULT 0,
|
||||
pending_earnings DECIMAL(10,2) DEFAULT 0,
|
||||
referral_count INT DEFAULT 0,
|
||||
is_admin BOOLEAN DEFAULT FALSE COMMENT '是否管理员',
|
||||
earnings DECIMAL(10,2) DEFAULT 0 COMMENT '已提现收益',
|
||||
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,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
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
|
||||
`)
|
||||
|
||||
// 尝试添加可能缺失的字段(用于升级已有数据库)
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
|
||||
@@ -562,9 +562,6 @@ export const useStore = create<StoreState>()(
|
||||
updatedUser.purchasedSections = [...updatedUser.purchasedSections, purchaseData.sectionId]
|
||||
}
|
||||
|
||||
// 更新 users 数组
|
||||
const updatedUsers = state.users?.map((u) => (u.id === updatedUser.id ? updatedUser : u)) || []
|
||||
|
||||
return {
|
||||
purchases: [...state.purchases, newPurchase],
|
||||
user: updatedUser,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 小程序入口
|
||||
* Soul创业派对 - 小程序入口
|
||||
* 开发: 卡若
|
||||
*/
|
||||
|
||||
@@ -27,6 +27,9 @@ App({
|
||||
purchasedSections: [],
|
||||
hasFullBook: false,
|
||||
|
||||
// 推荐绑定
|
||||
pendingReferralCode: null, // 待绑定的推荐码
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
brandColor: '#00CED1',
|
||||
@@ -45,7 +48,7 @@ App({
|
||||
currentTab: 0
|
||||
},
|
||||
|
||||
onLaunch() {
|
||||
onLaunch(options) {
|
||||
// 获取系统信息
|
||||
this.getSystemInfo()
|
||||
|
||||
@@ -57,6 +60,75 @@ App({
|
||||
|
||||
// 检查更新
|
||||
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() {
|
||||
const mockUser = {
|
||||
id: 'user_' + Date.now(),
|
||||
nickname: '卡若',
|
||||
phone: '15880802661',
|
||||
nickname: '访客用户',
|
||||
phone: '',
|
||||
avatar: '',
|
||||
referralCode: 'SOUL' + Date.now().toString(36).toUpperCase().slice(-6),
|
||||
purchasedSections: [],
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
"pages/about/about",
|
||||
"pages/referral/referral",
|
||||
"pages/purchases/purchases",
|
||||
"pages/settings/settings"
|
||||
"pages/settings/settings",
|
||||
"pages/search/search"
|
||||
],
|
||||
"window": {
|
||||
"backgroundTextStyle": "light",
|
||||
"navigationBarBackgroundColor": "#000000",
|
||||
"navigationBarTitleText": "Soul创业实验",
|
||||
"navigationBarTitleText": "Soul创业派对",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#000000",
|
||||
"navigationStyle": "custom"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 关于作者页
|
||||
* Soul创业派对 - 关于作者页
|
||||
* 开发: 卡若
|
||||
*/
|
||||
const app = getApp()
|
||||
@@ -17,10 +17,8 @@ Page({
|
||||
{ label: '连续直播', value: '365天' },
|
||||
{ label: '派对分享', value: '1000+' }
|
||||
],
|
||||
contact: {
|
||||
wechat: '28533368',
|
||||
phone: '15880802661'
|
||||
},
|
||||
// 联系方式已移至后台配置
|
||||
contact: null,
|
||||
highlights: [
|
||||
'5年私域运营经验',
|
||||
'帮助100+品牌从0到1增长',
|
||||
@@ -67,19 +65,13 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 复制微信号
|
||||
// 联系方式功能已禁用
|
||||
copyWechat() {
|
||||
wx.setClipboardData({
|
||||
data: this.data.author.contact.wechat,
|
||||
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
|
||||
})
|
||||
wx.showToast({ title: '请在派对房联系作者', icon: 'none' })
|
||||
},
|
||||
|
||||
// 拨打电话
|
||||
callPhone() {
|
||||
wx.makePhoneCall({
|
||||
phoneNumber: this.data.author.contact.phone
|
||||
})
|
||||
wx.showToast({ title: '请在派对房联系作者', icon: 'none' })
|
||||
},
|
||||
|
||||
// 返回
|
||||
|
||||
@@ -57,24 +57,18 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 联系方式 -->
|
||||
<!-- 联系方式 - 引导到Soul派对房 -->
|
||||
<view class="contact-card">
|
||||
<text class="card-title">联系作者</text>
|
||||
<view class="contact-item" bindtap="copyWechat">
|
||||
<text class="contact-icon">💬</text>
|
||||
<view class="contact-item">
|
||||
<text class="contact-icon">🎉</text>
|
||||
<view class="contact-info">
|
||||
<text class="contact-label">微信</text>
|
||||
<text class="contact-value">{{author.contact.wechat}}</text>
|
||||
<text class="contact-label">Soul派对房</text>
|
||||
<text class="contact-value">每天早上6-9点开播</text>
|
||||
</view>
|
||||
<text class="contact-btn">复制</text>
|
||||
</view>
|
||||
<view class="contact-item" bindtap="callPhone">
|
||||
<text class="contact-icon">📱</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 class="contact-tip">
|
||||
<text>在Soul App搜索"创业实验"或"卡若",加入派对房直接交流</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 目录页
|
||||
* Soul创业派对 - 目录页
|
||||
* 开发: 卡若
|
||||
* 技术支持: 存客宝
|
||||
* 数据: 完整真实文章标题
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 首页
|
||||
* Soul创业派对 - 首页
|
||||
* 开发: 卡若
|
||||
* 技术支持: 存客宝
|
||||
*/
|
||||
@@ -28,12 +28,9 @@ Page({
|
||||
{ id: '8.1', title: '流量杠杆:抖音、Soul、飞书', tag: '推荐', tagClass: 'tag-purple', part: '真实的赚钱' }
|
||||
],
|
||||
|
||||
// 最新章节
|
||||
latestSection: {
|
||||
id: '9.14',
|
||||
title: '大健康私域:一个月150万的70后',
|
||||
part: '真实的赚钱'
|
||||
},
|
||||
// 最新章节(动态计算)
|
||||
latestSection: null,
|
||||
latestLabel: '最新更新',
|
||||
|
||||
// 内容概览
|
||||
partsList: [
|
||||
@@ -48,13 +45,19 @@ Page({
|
||||
loading: true
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
onLoad(options) {
|
||||
// 获取系统信息
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight,
|
||||
navBarHeight: app.globalData.navBarHeight
|
||||
})
|
||||
|
||||
// 处理分享参数(推荐码绑定)
|
||||
if (options && options.ref) {
|
||||
console.log('[Index] 检测到推荐码:', options.ref)
|
||||
app.handleReferralCode({ query: options })
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
this.initData()
|
||||
},
|
||||
@@ -76,6 +79,8 @@ Page({
|
||||
try {
|
||||
// 获取书籍数据
|
||||
await this.loadBookData()
|
||||
// 计算推荐章节
|
||||
this.computeLatestSection()
|
||||
} catch (e) {
|
||||
console.error('初始化失败:', e)
|
||||
} 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() {
|
||||
try {
|
||||
@@ -114,6 +163,11 @@ Page({
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
},
|
||||
|
||||
// 跳转到搜索页
|
||||
goToSearch() {
|
||||
wx.navigateTo({ url: '/pages/search/search' })
|
||||
},
|
||||
|
||||
// 跳转到阅读页
|
||||
goToRead(e) {
|
||||
const id = e.currentTarget.dataset.id
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--pages/index/index.wxml-->
|
||||
<!--Soul创业实验 - 首页 1:1还原Web版本-->
|
||||
<!--Soul创业派对 - 首页 1:1还原Web版本-->
|
||||
<view class="page page-transition">
|
||||
<!-- 自定义导航栏占位 -->
|
||||
<view class="nav-placeholder" style="height: {{statusBarHeight + 44}}px;"></view>
|
||||
@@ -14,7 +14,7 @@
|
||||
<view class="logo-info">
|
||||
<view class="logo-title">
|
||||
<text class="text-white">Soul</text>
|
||||
<text class="brand-color">创业实验</text>
|
||||
<text class="brand-color">创业派对</text>
|
||||
</view>
|
||||
<text class="logo-subtitle">来自派对房的真实故事</text>
|
||||
</view>
|
||||
@@ -25,12 +25,12 @@
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-bar" bindtap="goToChapters">
|
||||
<view class="search-bar" bindtap="goToSearch">
|
||||
<view class="search-icon">
|
||||
<view class="search-circle"></view>
|
||||
<view class="search-handle"></view>
|
||||
</view>
|
||||
<text class="search-placeholder">搜索章节...</text>
|
||||
<text class="search-placeholder">搜索章节标题或内容...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 找伙伴页
|
||||
* Soul创业派对 - 找伙伴页
|
||||
* 按H5网页端完全重构
|
||||
* 开发: 卡若
|
||||
*/
|
||||
@@ -55,7 +55,11 @@ Page({
|
||||
needBindFirst: false,
|
||||
|
||||
// 解锁弹窗
|
||||
showUnlockModal: false
|
||||
showUnlockModal: false,
|
||||
|
||||
// 匹配价格(可配置)
|
||||
matchPrice: 1,
|
||||
extraMatches: 0
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -86,15 +90,18 @@ Page({
|
||||
// 更新全局配置
|
||||
MATCH_TYPES = res.data.matchTypes || MATCH_TYPES
|
||||
FREE_MATCH_LIMIT = res.data.freeMatchLimit || FREE_MATCH_LIMIT
|
||||
const matchPrice = res.data.matchPrice || 1
|
||||
|
||||
this.setData({
|
||||
matchTypes: MATCH_TYPES,
|
||||
totalMatchesAllowed: FREE_MATCH_LIMIT
|
||||
totalMatchesAllowed: FREE_MATCH_LIMIT,
|
||||
matchPrice: matchPrice
|
||||
})
|
||||
|
||||
console.log('[Match] 加载匹配配置成功:', {
|
||||
types: MATCH_TYPES.length,
|
||||
freeLimit: FREE_MATCH_LIMIT
|
||||
freeLimit: FREE_MATCH_LIMIT,
|
||||
price: matchPrice
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -240,7 +247,7 @@ Page({
|
||||
showPurchaseTip() {
|
||||
wx.showModal({
|
||||
title: '需要购买书籍',
|
||||
content: '购买《一场Soul的创业实验》后即可使用匹配功能,仅需9.9元',
|
||||
content: '购买《Soul创业派对》后即可使用匹配功能,仅需9.9元',
|
||||
confirmText: '去购买',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
@@ -334,7 +341,7 @@ Page({
|
||||
concept: concepts[index % concepts.length],
|
||||
wechat: wechats[index % wechats.length],
|
||||
commonInterests: [
|
||||
{ icon: '📚', text: '都在读《创业实验》' },
|
||||
{ icon: '📚', text: '都在读《创业派对》' },
|
||||
{ icon: '💼', text: '对私域运营感兴趣' },
|
||||
{ icon: '🎯', text: '相似的创业方向' }
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--pages/match/match.wxml-->
|
||||
<!--Soul创业实验 - 找伙伴页 按H5网页端完全重构-->
|
||||
<!--Soul创业派对 - 找伙伴页 按H5网页端完全重构-->
|
||||
<view class="page">
|
||||
<!-- 自定义导航栏 -->
|
||||
<view class="nav-bar" style="padding-top: {{statusBarHeight}}px;">
|
||||
@@ -11,6 +11,9 @@
|
||||
</view>
|
||||
</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}}">
|
||||
@@ -73,21 +76,38 @@
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 匹配中状态 -->
|
||||
<!-- 匹配中状态 - 美化特效 -->
|
||||
<block wx:if="{{isMatching}}">
|
||||
<view class="matching-state">
|
||||
<view class="matching-animation">
|
||||
<view class="matching-ring"></view>
|
||||
<view class="matching-center">
|
||||
<text class="matching-icon">👥</text>
|
||||
<view class="matching-animation-v2">
|
||||
<!-- 外层旋转光环 -->
|
||||
<view class="matching-outer-ring"></view>
|
||||
<!-- 中层脉冲环 -->
|
||||
<view class="matching-pulse-ring"></view>
|
||||
<!-- 内层球体 -->
|
||||
<view class="matching-core">
|
||||
<view class="matching-core-inner">
|
||||
<text class="matching-icon-v2">🔍</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ripple ripple-1"></view>
|
||||
<view class="ripple ripple-2"></view>
|
||||
<view class="ripple ripple-3"></view>
|
||||
<!-- 粒子效果 -->
|
||||
<view class="particle particle-1">✨</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>
|
||||
<text class="matching-title">正在匹配{{currentTypeLabel}}...</text>
|
||||
<text class="matching-count">已匹配 {{matchAttempts}} 次</text>
|
||||
<view class="cancel-btn" bindtap="cancelMatch">取消匹配</view>
|
||||
<text class="matching-title-v2">正在匹配{{currentTypeLabel}}...</text>
|
||||
<text class="matching-subtitle-v2">正在从 {{matchAttempts * 127 + 89}} 位创业者中为你寻找</text>
|
||||
<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>
|
||||
</block>
|
||||
|
||||
@@ -241,7 +261,7 @@
|
||||
<view class="unlock-info">
|
||||
<view class="info-row">
|
||||
<text class="info-label">单价</text>
|
||||
<text class="info-value text-brand">¥1 / 次</text>
|
||||
<text class="info-value text-brand">¥{{matchPrice || 1}} / 次</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">已购买</text>
|
||||
@@ -250,7 +270,7 @@
|
||||
</view>
|
||||
|
||||
<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>
|
||||
</view>
|
||||
|
||||
@@ -983,3 +983,195 @@
|
||||
.bottom-space {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 我的页面
|
||||
* Soul创业派对 - 我的页面
|
||||
* 开发: 卡若
|
||||
* 技术支持: 存客宝
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 阅读页
|
||||
* Soul创业派对 - 阅读页
|
||||
* 开发: 卡若
|
||||
* 技术支持: 存客宝
|
||||
*/
|
||||
@@ -62,9 +62,11 @@ Page({
|
||||
sectionId: id
|
||||
})
|
||||
|
||||
// 保存推荐码
|
||||
// 处理推荐码绑定
|
||||
if (ref) {
|
||||
console.log('[Read] 检测到推荐码:', ref)
|
||||
wx.setStorageSync('referral_code', ref)
|
||||
app.handleReferralCode({ query: { ref } })
|
||||
}
|
||||
|
||||
this.initSection(id)
|
||||
@@ -281,15 +283,33 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 分享到微信
|
||||
// 分享到微信 - 自动带分享人ID
|
||||
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 userInfo = app.globalData.userInfo
|
||||
const referralCode = userInfo?.referralCode || ''
|
||||
|
||||
return {
|
||||
title: `📚 ${section?.title || '推荐阅读'}`,
|
||||
path: `/pages/read/read?id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`
|
||||
title: `${section?.title || 'Soul创业派对'} - 来自派对房的真实故事`,
|
||||
query: `id=${sectionId}${referralCode ? '&ref=' + referralCode : ''}`
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--pages/read/read.wxml-->
|
||||
<!--Soul创业实验 - 阅读页 1:1还原Web版本-->
|
||||
<!--Soul创业派对 - 阅读页-->
|
||||
<view class="page">
|
||||
<!-- 阅读进度条 -->
|
||||
<view class="progress-bar-fixed" style="top: {{statusBarHeight}}px;">
|
||||
@@ -138,6 +138,36 @@
|
||||
|
||||
<text class="paywall-tip">邀请好友加入,享90%推广收益</text>
|
||||
</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>
|
||||
|
||||
@@ -177,7 +207,7 @@
|
||||
<view class="modal-content login-modal" catchtap="stopPropagation">
|
||||
<view class="modal-close" bindtap="closeLoginModal">✕</view>
|
||||
<view class="login-icon">🔐</view>
|
||||
<text class="login-title">登录 Soul创业实验</text>
|
||||
<text class="login-title">登录 Soul创业派对</text>
|
||||
<text class="login-desc">登录后可购买章节、参与匹配、赚取佣金</text>
|
||||
|
||||
<button class="btn-wechat" bindtap="handleWechatLogin">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 分销中心页
|
||||
* Soul创业派对 - 分销中心页
|
||||
* 1:1还原Web版本
|
||||
*/
|
||||
const app = getApp()
|
||||
@@ -140,7 +140,7 @@ Page({
|
||||
|
||||
// 分享到朋友圈
|
||||
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({
|
||||
data: shareText,
|
||||
@@ -155,14 +155,78 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 提现
|
||||
handleWithdraw() {
|
||||
const earnings = parseFloat(this.data.earnings)
|
||||
if (earnings < 10) {
|
||||
// 提现 - 直接到微信零钱
|
||||
async handleWithdraw() {
|
||||
const pendingEarnings = parseFloat(this.data.pendingEarnings) || 0
|
||||
|
||||
if (pendingEarnings < 10) {
|
||||
wx.showToast({ title: '满10元可提现', icon: 'none' })
|
||||
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() {
|
||||
return {
|
||||
title: '📚 一场SOUL的创业实验场 - 来自派对房的真实商业故事',
|
||||
path: `/pages/index/index?ref=${this.data.referralCode}`
|
||||
title: '📚 Soul创业派对 - 来自派对房的真实商业故事',
|
||||
path: `/pages/index/index?ref=${this.data.referralCode}`,
|
||||
imageUrl: '/assets/share-cover.png'
|
||||
}
|
||||
},
|
||||
|
||||
// 分享到朋友圈
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: `Soul创业派对 - 62个真实商业案例`,
|
||||
query: `ref=${this.data.referralCode}`
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
87
miniprogram/pages/search/search.js
Normal file
87
miniprogram/pages/search/search.js
Normal 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()
|
||||
}
|
||||
})
|
||||
5
miniprogram/pages/search/search.json
Normal file
5
miniprogram/pages/search/search.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {},
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "搜索"
|
||||
}
|
||||
92
miniprogram/pages/search/search.wxml
Normal file
92
miniprogram/pages/search/search.wxml
Normal 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>
|
||||
264
miniprogram/pages/search/search.wxss
Normal file
264
miniprogram/pages/search/search.wxss
Normal 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);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Soul创业实验 - 设置页
|
||||
* Soul创业派对 - 设置页
|
||||
* 账号绑定功能
|
||||
*/
|
||||
const app = getApp()
|
||||
@@ -115,7 +115,7 @@ Page({
|
||||
return
|
||||
}
|
||||
|
||||
// 保存绑定信息
|
||||
// 保存绑定信息到本地
|
||||
if (bindType === 'phone') {
|
||||
wx.setStorageSync('user_phone', bindValue)
|
||||
this.setData({ phoneNumber: bindValue })
|
||||
@@ -127,9 +127,122 @@ Page({
|
||||
this.setData({ alipayAccount: bindValue })
|
||||
}
|
||||
|
||||
// 同步到服务器
|
||||
this.syncProfileToServer()
|
||||
|
||||
this.setData({ showBindModal: false })
|
||||
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() {
|
||||
@@ -177,12 +290,9 @@ Page({
|
||||
})
|
||||
},
|
||||
|
||||
// 联系客服
|
||||
// 联系客服 - 跳转到Soul派对房
|
||||
contactService() {
|
||||
wx.setClipboardData({
|
||||
data: '28533368',
|
||||
success: () => wx.showToast({ title: '客服微信已复制', icon: 'success' })
|
||||
})
|
||||
wx.showToast({ title: '请在Soul派对房联系客服', icon: 'none' })
|
||||
},
|
||||
|
||||
// 阻止冒泡
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"compileType": "miniprogram",
|
||||
"miniprogramRoot": "",
|
||||
"projectname": "soul-startup",
|
||||
"description": "一场SOUL的创业实验场 - 来自Soul派对房的真实商业故事",
|
||||
"description": "Soul创业派对 - 来自派对房的真实商业故事",
|
||||
"appid": "wxb8bbb2b10dec74aa",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
216
开发文档/TDD_创业派对项目方案_v1.0.md
Normal file
216
开发文档/TDD_创业派对项目方案_v1.0.md
Normal 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 | 初版,基于智能追问生成 |
|
||||
|
||||
---
|
||||
|
||||
> "好问题比好答案更有价值。" — 卡若
|
||||
Reference in New Issue
Block a user