Files
soul/app/admin/content/page.tsx
卡若 b60edb3d47 feat: 完整重构小程序匹配功能 + 修复UI对齐 + 文章数据API
主要更新:
1. 按H5网页端完全重构匹配功能(match页面)
   - 4种匹配类型: 创业合伙/资源对接/导师顾问/团队招募
   - 资源对接等类型弹出手机号/微信号输入框
   - 去掉重新匹配按钮,改为返回按钮

2. 修复所有卡片对齐和宽度问题
   - 目录页附录卡片居中
   - 首页阅读进度卡片满宽度
   - 我的页面菜单卡片对齐
   - 推广中心分享卡片统一宽度

3. 修复目录页图标和文字对齐
   - section-icon固定40rpx宽高
   - section-title与图标垂直居中

4. 更新真实完整文章标题(62篇)
   - 从book目录读取真实markdown文件名
   - 替换之前的简化标题

5. 新增文章数据API
   - /api/db/chapters - 获取完整书籍结构
   - 支持按ID获取单篇文章内容
2026-01-21 15:49:12 +08:00

728 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useRef } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { bookData } from "@/lib/book-data"
import {
FileText,
BookOpen,
Settings2,
ChevronRight,
CheckCircle,
Edit3,
Save,
X,
RefreshCw,
Link2,
Download,
Upload,
Eye,
Database,
} from "lucide-react"
interface EditingSection {
id: string
title: string
price: number
content?: string
filePath?: string
}
export default function ContentPage() {
const [expandedParts, setExpandedParts] = useState<string[]>(["part-1"])
const [editingSection, setEditingSection] = useState<EditingSection | null>(null)
const [isSyncing, setIsSyncing] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [isInitializing, setIsInitializing] = useState(false)
const [feishuDocUrl, setFeishuDocUrl] = useState("")
const [showFeishuModal, setShowFeishuModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
const [importData, setImportData] = useState("")
const [isLoadingContent, setIsLoadingContent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const togglePart = (partId: string) => {
setExpandedParts((prev) => (prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId]))
}
const totalSections = bookData.reduce(
(sum, part) => sum + part.chapters.reduce((cSum, ch) => cSum + ch.sections.length, 0),
0,
)
// 读取章节内容
const handleReadSection = async (section: { id: string; title: string; price: number; filePath: string }) => {
setIsLoadingContent(true)
try {
const res = await fetch(`/api/db/book?action=read&id=${section.id}`)
const data = await res.json()
if (data.success) {
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: data.section.content || "",
filePath: section.filePath,
})
} else {
// 如果API失败设置空内容
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: "",
filePath: section.filePath,
})
alert("无法读取文件内容: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Read section error:", error)
setEditingSection({
id: section.id,
title: section.title,
price: section.price,
content: "",
filePath: section.filePath,
})
} finally {
setIsLoadingContent(false)
}
}
// 保存章节
const handleSaveSection = async () => {
if (!editingSection) return
try {
const res = await fetch('/api/db/book', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: editingSection.id,
title: editingSection.title,
price: editingSection.price,
content: editingSection.content,
saveToFile: true, // 同时保存到文件系统
})
})
const data = await res.json()
if (data.success) {
alert(`已保存章节: ${editingSection.title}`)
setEditingSection(null)
} else {
alert("保存失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Save section error:", error)
alert("保存失败")
}
}
// 同步到数据库
const handleSyncToDatabase = async () => {
setIsSyncing(true)
try {
const res = await fetch('/api/db/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'sync' })
})
const data = await res.json()
if (data.success) {
alert(data.message)
} else {
alert("同步失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Sync error:", error)
alert("同步失败")
} finally {
setIsSyncing(false)
}
}
// 导出所有章节
const handleExport = async () => {
setIsExporting(true)
try {
const res = await fetch('/api/db/book?action=export')
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `book_sections_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
alert("导出成功")
} catch (error) {
console.error("Export error:", error)
alert("导出失败")
} finally {
setIsExporting(false)
}
}
// 导入章节
const handleImport = async () => {
if (!importData) {
alert("请输入或上传JSON数据")
return
}
setIsImporting(true)
try {
const data = JSON.parse(importData)
const res = await fetch('/api/db/book', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'import', data })
})
const result = await res.json()
if (result.success) {
alert(result.message)
setShowImportModal(false)
setImportData("")
} else {
alert("导入失败: " + (result.error || "未知错误"))
}
} catch (error) {
console.error("Import error:", error)
alert("导入失败: JSON格式错误")
} finally {
setIsImporting(false)
}
}
// 文件上传
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result as string
const fileName = file.name.toLowerCase()
// 根据文件类型处理
if (fileName.endsWith('.json')) {
// JSON文件直接使用
setImportData(content)
} else if (fileName.endsWith('.txt') || fileName.endsWith('.md') || fileName.endsWith('.markdown')) {
// TXT/MD文件自动解析为JSON格式
const parsedData = parseTxtToJson(content, file.name)
setImportData(JSON.stringify(parsedData, null, 2))
} else {
setImportData(content)
}
}
reader.readAsText(file)
}
// 解析TXT/MD文件为JSON格式
const parseTxtToJson = (content: string, fileName: string) => {
const lines = content.split('\n')
const sections: any[] = []
let currentSection: any = null
let currentContent: string[] = []
let sectionIndex = 1
for (const line of lines) {
// 检测标题行(以#开头或数字+点开头)
const titleMatch = line.match(/^#+\s+(.+)$/) || line.match(/^(\d+[\.\、]\s*.+)$/)
if (titleMatch) {
// 保存前一个章节
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
// 开始新章节
currentSection = {
id: `import-${sectionIndex}`,
title: titleMatch[1].replace(/^#+\s*/, '').trim(),
price: 1,
is_free: sectionIndex <= 3, // 前3章免费
}
currentContent = []
sectionIndex++
} else if (currentSection) {
currentContent.push(line)
} else if (line.trim()) {
// 没有标题但有内容,创建默认章节
currentSection = {
id: `import-${sectionIndex}`,
title: fileName.replace(/\.(txt|md|markdown)$/i, ''),
price: 1,
is_free: true,
}
currentContent.push(line)
sectionIndex++
}
}
// 保存最后一个章节
if (currentSection) {
currentSection.content = currentContent.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
return sections
}
// 初始化数据库
const handleInitDatabase = async () => {
if (!confirm("确定要初始化数据库吗?这将创建所有必需的表结构。")) return
setIsInitializing(true)
try {
const res = await fetch('/api/db/init', { method: 'POST' })
const data = await res.json()
if (data.success) {
alert(data.message)
} else {
alert("初始化失败: " + (data.error || "未知错误"))
}
} catch (error) {
console.error("Init database error:", error)
alert("初始化失败")
} finally {
setIsInitializing(false)
}
}
const handleSyncFeishu = async () => {
if (!feishuDocUrl) {
alert("请输入飞书文档链接")
return
}
setIsSyncing(true)
await new Promise((resolve) => setTimeout(resolve, 2000))
setIsSyncing(false)
setShowFeishuModal(false)
alert("飞书文档同步成功!")
}
return (
<div className="p-8 max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1">
{bookData.length} · {totalSections}
</p>
</div>
<div className="flex gap-2">
<Button
onClick={handleInitDatabase}
disabled={isInitializing}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Database className="w-4 h-4 mr-2" />
{isInitializing ? "初始化中..." : "初始化数据库"}
</Button>
<Button
onClick={handleSyncToDatabase}
disabled={isSyncing}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`} />
</Button>
<Button
onClick={() => setShowImportModal(true)}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleExport}
disabled={isExporting}
variant="outline"
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Download className="w-4 h-4 mr-2" />
{isExporting ? "导出中..." : "导出"}
</Button>
<Button onClick={() => setShowFeishuModal(true)} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<FileText className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 导入弹窗 */}
<Dialog open={showImportModal} onOpenChange={setShowImportModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Upload className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"> ( JSON / TXT / MD)</Label>
<input
ref={fileInputRef}
type="file"
accept=".json,.txt,.md,.markdown"
onChange={handleFileUpload}
className="hidden"
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="w-full border-dashed border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Upload className="w-4 h-4 mr-2" />
(JSON/TXT/MD)
</Button>
<p className="text-xs text-gray-500">
JSON格式: 直接导入章节数据<br/>
TXT/MD格式: 自动解析为章节内容
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[200px] font-mono text-sm placeholder:text-gray-500"
placeholder='JSON格式: [{"id": "1-1", "title": "章节标题", "content": "内容..."}]&#10;&#10;或直接粘贴TXT/MD内容系统将自动解析'
value={importData}
onChange={(e) => setImportData(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowImportModal(false)
setImportData("")
}}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleImport}
disabled={isImporting || !importData}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isImporting ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 飞书同步弹窗 */}
<Dialog open={showFeishuModal} onOpenChange={setShowFeishuModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Link2 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
placeholder="https://xxx.feishu.cn/docx/..."
value={feishuDocUrl}
onChange={(e) => setFeishuDocUrl(e.target.value)}
/>
<p className="text-xs text-gray-500">访</p>
</div>
<div className="bg-[#38bdac]/10 border border-[#38bdac]/30 rounded-lg p-3">
<p className="text-[#38bdac] text-sm">
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowFeishuModal(false)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
</Button>
<Button
onClick={handleSyncFeishu}
disabled={isSyncing}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{isSyncing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<RefreshCw 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">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
</DialogHeader>
{editingSection && (
<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"
value={editingSection.id}
disabled
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white text-sm"
value={editingSection.filePath || ""}
disabled
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
value={editingSection.title}
onChange={(e) => setEditingSection({ ...editingSection, title: 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 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>
{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" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[400px] font-mono text-sm placeholder:text-gray-500"
placeholder="此处输入章节内容支持Markdown格式..."
value={editingSection.content}
onChange={(e) => setEditingSection({ ...editingSection, content: e.target.value })}
/>
)}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditingSection(null)}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
<Tabs defaultValue="chapters" className="space-y-6">
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1">
<TabsTrigger
value="chapters"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400"
>
<BookOpen 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"
>
<Settings2 className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
<TabsContent value="chapters" className="space-y-4">
{bookData.map((part, partIndex) => (
<Card key={part.id} className="bg-[#0f2137] border-gray-700/50 shadow-xl overflow-hidden">
<CardHeader
className="cursor-pointer hover:bg-[#162840] transition-colors"
onClick={() => togglePart(part.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-[#38bdac] font-mono text-sm">0{partIndex + 1}</span>
<CardTitle className="text-white">{part.title}</CardTitle>
<Badge variant="outline" className="text-gray-400 border-gray-600">
{part.chapters.reduce((sum, ch) => sum + ch.sections.length, 0)}
</Badge>
</div>
<ChevronRight
className={`w-5 h-5 text-gray-400 transition-transform ${
expandedParts.includes(part.id) ? "rotate-90" : ""
}`}
/>
</div>
</CardHeader>
{expandedParts.includes(part.id) && (
<CardContent className="pt-0 pb-4">
<div className="space-y-3 pl-8 border-l-2 border-gray-700">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="space-y-2">
<h4 className="font-medium text-gray-300">{chapter.title}</h4>
<div className="space-y-1">
{chapter.sections.map((section) => (
<div
key={section.id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[#162840] text-sm group transition-colors"
>
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-[#38bdac]" />
<span className="text-gray-400">{section.title}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[#38bdac] font-medium">
{section.price === 0 ? "免费" : `¥${section.price}`}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleReadSection(section)}
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReadSection(section)}
className="text-gray-500 hover:text-[#38bdac] hover:bg-[#38bdac]/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Edit3 className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
))}
</TabsContent>
<TabsContent value="hooks" 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="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="hook-chapter" className="text-gray-300">
</Label>
<Select defaultValue="3">
<SelectTrigger id="hook-chapter" className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue placeholder="选择章节" />
</SelectTrigger>
<SelectContent className="bg-[#0f2137] border-gray-700">
<SelectItem value="1" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
</SelectItem>
<SelectItem value="2" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
</SelectItem>
<SelectItem value="3" className="text-white hover:bg-[#38bdac]/20 focus:bg-[#38bdac]/20">
()
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid w-full gap-1.5">
<Label htmlFor="message" className="text-gray-300">
</Label>
<Textarea
placeholder="输入引导用户加群的文案..."
id="message"
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
defaultValue="阅读更多精彩内容请加入Soul创业实验派对群..."
/>
</div>
<Button className="bg-[#38bdac] hover:bg-[#2da396] text-white"></Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}