更新小程序开发文档,新增2026-03-03的最佳实践记录,优化个人中心类页面的卡片区边距规范,确保一致性与可用性。调整相关页面以反映最新设计稿,提升用户体验与功能一致性。

This commit is contained in:
Alex-larget
2026-03-04 19:06:06 +08:00
parent 7064f82126
commit 5a5f0087d2
66 changed files with 2555 additions and 1059 deletions

View File

@@ -7,8 +7,8 @@ import { UsersPage } from './pages/users/UsersPage'
import { DistributionPage } from './pages/distribution/DistributionPage'
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
import { ContentPage } from './pages/content/ContentPage'
import { ChaptersPage } from './pages/chapters/ChaptersPage'
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
import { AuthorSettingsPage } from './pages/author-settings/AuthorSettingsPage'
import { SettingsPage } from './pages/settings/SettingsPage'
import { PaymentPage } from './pages/payment/PaymentPage'
import { SitePage } from './pages/site/SitePage'
@@ -18,6 +18,7 @@ import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { AdminUsersPage } from './pages/admin-users/AdminUsersPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
@@ -33,11 +34,12 @@ function App() {
<Route path="distribution" element={<DistributionPage />} />
<Route path="withdrawals" element={<WithdrawalsPage />} />
<Route path="content" element={<ContentPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="referral-settings" element={<ReferralSettingsPage />} />
<Route path="author-settings" element={<AuthorSettingsPage />} />
<Route path="vip-roles" element={<VipRolesPage />} />
<Route path="mentors" element={<MentorsPage />} />
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
<Route path="admin-users" element={<AdminUsersPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} />

View File

@@ -31,6 +31,7 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
aria-describedby={undefined}
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
className,

View File

@@ -12,21 +12,30 @@ import {
Crown,
GraduationCap,
Calendar,
User,
ShieldCheck,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
const menuItems = [
// 主菜单(折叠后可见 4-5 项)
const primaryMenuItems = [
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
{ icon: BookOpen, label: '内容管理', href: '/content' },
{ icon: Users, label: '用户管理', href: '/users' },
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
{ icon: User, label: '作者详情', href: '/author-settings' },
]
// 折叠区「更多」
const moreMenuItems = [
{ icon: ShieldCheck, label: '管理员', href: '/admin-users' },
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
{ icon: Wallet, label: '交易中心', href: '/distribution' },
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
{ icon: Settings, label: '系统设置', href: '/settings' },
]
export function AdminLayout() {
@@ -34,10 +43,15 @@ export function AdminLayout() {
const navigate = useNavigate()
const [mounted, setMounted] = useState(false)
const [authChecked, setAuthChecked] = useState(false)
const [moreExpanded, setMoreExpanded] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
useEffect(() => {
const inMore = moreMenuItems.some((item) => location.pathname === item.href)
if (inMore) setMoreExpanded(true)
}, [location.pathname])
useEffect(() => {
if (!mounted) return
@@ -89,8 +103,8 @@ export function AdminLayout() {
<p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
</div>
<nav className="flex-1 p-4 space-y-1">
{menuItems.map((item) => {
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{primaryMenuItems.map((item) => {
const isActive = location.pathname === item.href
return (
<Link
@@ -102,11 +116,55 @@ export function AdminLayout() {
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<item.icon className="w-5 h-5" />
<item.icon className="w-5 h-5 shrink-0" />
<span className="text-sm">{item.label}</span>
</Link>
)
})}
<button
type="button"
onClick={() => setMoreExpanded(!moreExpanded)}
className="w-full flex items-center justify-between gap-3 px-4 py-3 text-gray-400 hover:bg-gray-700/50 hover:text-white rounded-lg transition-colors"
>
<span className="flex items-center gap-3">
{moreExpanded ? <ChevronUp className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
<span className="text-sm"></span>
</span>
</button>
{moreExpanded && (
<div className="space-y-1 pl-4">
{moreMenuItems.map((item) => {
const isActive = location.pathname === item.href
return (
<Link
key={item.href}
to={item.href}
className={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors ${
isActive
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<item.icon className="w-5 h-5 shrink-0" />
<span className="text-sm">{item.label}</span>
</Link>
)
})}
</div>
)}
<div className="pt-4 mt-4 border-t border-gray-700/50">
<Link
to="/settings"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
location.pathname === '/settings'
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<Settings className="w-5 h-5 shrink-0" />
<span className="text-sm"></span>
</Link>
</div>
</nav>
<div className="p-4 border-t border-gray-700/50 space-y-1">

View File

@@ -0,0 +1,413 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
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 { ShieldCheck, Plus, Edit3, Trash2, X, Save, RefreshCw } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
interface AdminUser {
id: number
username: string
role: string
name: string
status: string
createdAt: string
updatedAt?: string
}
interface ListRes {
success?: boolean
records?: AdminUser[]
total?: number
page?: number
pageSize?: number
totalPages?: number
error?: string
}
export function AdminUsersPage() {
const [records, setRecords] = useState<AdminUser[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [totalPages, setTotalPages] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearch = useDebounce(searchTerm, 300)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [formUsername, setFormUsername] = useState('')
const [formPassword, setFormPassword] = useState('')
const [formName, setFormName] = useState('')
const [formRole, setFormRole] = useState<'super_admin' | 'admin'>('admin')
const [formStatus, setFormStatus] = useState<'active' | 'disabled'>('active')
const [saving, setSaving] = useState(false)
async function loadList() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
})
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim())
const data = await get<ListRes>(`/api/admin/users?${params}`)
if (data?.success) {
setRecords((data as ListRes).records || [])
setTotal((data as ListRes).total ?? 0)
setTotalPages((data as ListRes).totalPages ?? 0)
} else {
setError((data as ListRes).error || '加载失败')
}
} catch (e: unknown) {
const err = e as { status?: number; data?: { error?: string } }
setError(err.status === 403 ? '无权限访问' : err?.data?.error || '加载失败')
setRecords([])
} finally {
setLoading(false)
}
}
useEffect(() => {
loadList()
}, [page, pageSize, debouncedSearch])
const handleAdd = () => {
setEditingUser(null)
setFormUsername('')
setFormPassword('')
setFormName('')
setFormRole('admin')
setFormStatus('active')
setShowModal(true)
}
const handleEdit = (u: AdminUser) => {
setEditingUser(u)
setFormUsername(u.username)
setFormPassword('')
setFormName(u.name || '')
setFormRole((u.role === 'super_admin' ? 'super_admin' : 'admin') as 'super_admin' | 'admin')
setFormStatus((u.status === 'disabled' ? 'disabled' : 'active') as 'active' | 'disabled')
setShowModal(true)
}
const handleSave = async () => {
if (!formUsername.trim()) {
setError('用户名不能为空')
return
}
if (!editingUser && !formPassword) {
setError('新建时密码必填,至少 6 位')
return
}
if (formPassword && formPassword.length < 6) {
setError('密码至少 6 位')
return
}
setError(null)
setSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/admin/users', {
id: editingUser.id,
password: formPassword || undefined,
name: formName.trim(),
role: formRole,
status: formStatus,
})
if (data?.success) {
setShowModal(false)
loadList()
} else {
setError(data?.error || '保存失败')
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/admin/users', {
username: formUsername.trim(),
password: formPassword,
name: formName.trim(),
role: formRole,
})
if (data?.success) {
setShowModal(false)
loadList()
} else {
setError(data?.error || '保存失败')
}
}
} catch (e: unknown) {
const err = e as { data?: { error?: string } }
setError(err?.data?.error || '保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该管理员?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/users?id=${id}`)
if (data?.success) loadList()
else setError(data?.error || '删除失败')
} catch (e: unknown) {
const err = e as { data?: { error?: string } }
setError(err?.data?.error || '删除失败')
}
}
const formatDate = (s: string) => {
if (!s) return '-'
try {
const d = new Date(s)
return isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN')
} catch {
return s
}
}
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1"></p>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="搜索用户名/昵称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-48 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
/>
<Button
variant="outline"
size="sm"
onClick={loadList}
disabled={loading}
className="border-gray-600 text-gray-300"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm flex justify-between items-center">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<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>
{records.map((u) => (
<TableRow key={u.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{u.id}</TableCell>
<TableCell className="text-white font-medium">{u.username}</TableCell>
<TableCell className="text-gray-400">{u.name || '-'}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
u.role === 'super_admin'
? 'border-amber-500/50 text-amber-400'
: 'border-gray-600 text-gray-400'
}
>
{u.role === 'super_admin' ? '超级管理员' : '管理员'}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
u.status === 'active'
? 'border-[#38bdac]/50 text-[#38bdac]'
: 'border-gray-500 text-gray-500'
}
>
{u.status === 'active' ? '正常' : '已禁用'}
</Badge>
</TableCell>
<TableCell className="text-gray-500 text-sm">{formatDate(u.createdAt)}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(u)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(u.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{records.length === 0 && !loading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
{error === '无权限访问' ? '仅超级管理员可查看' : '暂无管理员'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="p-4 border-t border-gray-700/50">
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="text-white">
{editingUser ? '编辑管理员' : '新增管理员'}
</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="登录用户名"
value={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
disabled={!!editingUser}
/>
{editingUser && (
<p className="text-xs text-gray-500"></p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300">{editingUser ? '新密码(留空不改)' : '密码'}</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder={editingUser ? '留空表示不修改' : '至少 6 位'}
value={formPassword}
onChange={(e) => setFormPassword(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={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<select
value={formRole}
onChange={(e) => setFormRole(e.target.value as 'super_admin' | 'admin')}
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
>
<option value="admin"></option>
<option value="super_admin"></option>
</select>
</div>
{editingUser && (
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<select
value={formStatus}
onChange={(e) => setFormStatus(e.target.value as 'active' | 'disabled')}
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
>
<option value="active"></option>
<option value="disabled"></option>
</select>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<X className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,360 @@
import { useState, useEffect, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Save, User, Image, Plus, X, Upload } from 'lucide-react'
import { get, post, apiUrl } from '@/api/client'
import { getAdminToken } from '@/api/auth'
interface StatItem {
label: string
value: string
}
interface AuthorConfig {
name: string
avatar: string
avatarImg: string
title: string
bio: string
stats: StatItem[]
highlights: string[]
}
const DEFAULT: AuthorConfig = {
name: '卡若',
avatar: 'K',
avatarImg: '',
title: 'Soul派对房主理人 · 私域运营专家',
bio: '每天早上6点到9点在Soul派对房分享真实的创业故事。专注私域运营与项目变现用"云阿米巴"模式帮助创业者构建可持续的商业体系。',
stats: [
{ label: '商业案例', value: '62' },
{ label: '连续直播', value: '365天' },
{ label: '派对分享', value: '1000+' },
],
highlights: [
'5年私域运营经验',
'帮助100+品牌从0到1增长',
'连续创业者,擅长商业模式设计',
],
}
function parseStats(v: unknown): StatItem[] {
if (!Array.isArray(v)) return DEFAULT.stats
return v.map((x) => {
if (x && typeof x === 'object' && 'label' in x && 'value' in x) {
return { label: String(x.label), value: String(x.value) }
}
return { label: '', value: '' }
}).filter((s) => s.label || s.value)
}
function parseHighlights(v: unknown): string[] {
if (!Array.isArray(v)) return DEFAULT.highlights
return v.map((x) => (typeof x === 'string' ? x : String(x ?? ''))).filter(Boolean)
}
export function AuthorSettingsPage() {
const [config, setConfig] = useState<AuthorConfig>(DEFAULT)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
get<{ success?: boolean; data?: Record<string, unknown> }>('/api/admin/author-settings')
.then((res) => {
const d = (res as { data?: Record<string, unknown> })?.data
if (d && typeof d === 'object') {
setConfig({
name: String(d.name ?? DEFAULT.name),
avatar: String(d.avatar ?? DEFAULT.avatar),
avatarImg: String(d.avatarImg ?? ''),
title: String(d.title ?? DEFAULT.title),
bio: String(d.bio ?? DEFAULT.bio),
stats: parseStats(d.stats).length ? parseStats(d.stats) : DEFAULT.stats,
highlights: parseHighlights(d.highlights).length ? parseHighlights(d.highlights) : DEFAULT.highlights,
})
}
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const handleSave = async () => {
setSaving(true)
try {
const body = {
name: config.name,
avatar: config.avatar || 'K',
avatarImg: config.avatarImg,
title: config.title,
bio: config.bio,
stats: config.stats.filter((s) => s.label || s.value),
highlights: config.highlights.filter(Boolean),
}
const res = await post<{ success?: boolean; error?: string }>('/api/admin/author-settings', body)
if (!res || (res as { success?: boolean }).success === false) {
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
return
}
setSaving(false)
// 轻量反馈,不阻塞
const msg = document.createElement('div')
msg.className = 'fixed top-4 right-4 z-50 px-4 py-2 rounded-lg bg-[#38bdac] text-white text-sm shadow-lg'
msg.textContent = '作者设置已保存'
document.body.appendChild(msg)
setTimeout(() => msg.remove(), 2000)
} catch (e) {
console.error(e)
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingAvatar(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'avatars')
const token = getAdminToken()
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(apiUrl('/api/upload'), {
method: 'POST',
body: formData,
credentials: 'include',
headers,
})
const data = await res.json()
if (data?.success && data?.url) {
setConfig((prev) => ({ ...prev, avatarImg: data.url }))
} else {
alert('上传失败: ' + (data?.error || '未知错误'))
}
} catch (err) {
console.error(err)
alert('上传失败')
} finally {
setUploadingAvatar(false)
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
}
const addStat = () => setConfig((prev) => ({ ...prev, stats: [...prev.stats, { label: '', value: '' }] }))
const removeStat = (i: number) =>
setConfig((prev) => ({ ...prev, stats: prev.stats.filter((_, idx) => idx !== i) }))
const updateStat = (i: number, field: 'label' | 'value', val: string) =>
setConfig((prev) => ({
...prev,
stats: prev.stats.map((s, idx) => (idx === i ? { ...s, [field]: val } : s)),
}))
const addHighlight = () => setConfig((prev) => ({ ...prev, highlights: [...prev.highlights, ''] }))
const removeHighlight = (i: number) =>
setConfig((prev) => ({ ...prev, highlights: prev.highlights.filter((_, idx) => idx !== i) }))
const updateHighlight = (i: number, val: string) =>
setConfig((prev) => ({
...prev,
highlights: prev.highlights.map((h, idx) => (idx === i ? val : h)),
}))
if (loading) return <div className="p-8 text-gray-500">...</div>
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<User className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
</p>
</div>
<Button
onClick={handleSave}
disabled={saving || loading}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</div>
<div className="space-y-6">
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<User className="w-4 h-4 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<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"
value={config.name}
onChange={(e) => setConfig((prev) => ({ ...prev, name: e.target.value }))}
placeholder="卡若"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white w-20"
value={config.avatar}
onChange={(e) => setConfig((prev) => ({ ...prev, avatar: e.target.value.slice(0, 1) || 'K' }))}
placeholder="K"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300 flex items-center gap-2">
<Image className="w-3 h-3 text-[#38bdac]" />
</Label>
<div className="flex gap-3 items-center">
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={config.avatarImg}
onChange={(e) => setConfig((prev) => ({ ...prev, avatarImg: e.target.value }))}
placeholder="上传或粘贴 URL如 /uploads/avatars/xxx.png"
/>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-400 shrink-0"
disabled={uploadingAvatar}
onClick={() => avatarInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
{uploadingAvatar ? '上传中...' : '上传'}
</Button>
</div>
{config.avatarImg && (
<div className="mt-2">
<img
src={config.avatarImg.startsWith('http') ? config.avatarImg : apiUrl(config.avatarImg)}
alt="头像预览"
className="w-20 h-20 rounded-full object-cover border border-gray-600"
/>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
value={config.title}
onChange={(e) => setConfig((prev) => ({ ...prev, title: e.target.value }))}
placeholder="Soul派对房主理人 · 私域运营专家"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[120px]"
value={config.bio}
onChange={(e) => setConfig((prev) => ({ ...prev, bio: e.target.value }))}
placeholder="每天早上6点到9点..."
/>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white"></CardTitle>
<CardDescription className="text-gray-400">
62 365
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{config.stats.map((s, i) => (
<div key={i} className="flex gap-3 items-center">
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={s.label}
onChange={(e) => updateStat(i, 'label', e.target.value)}
placeholder="标签"
/>
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={s.value}
onChange={(e) => updateStat(i, 'value', e.target.value)}
placeholder="数值"
/>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-400"
onClick={() => removeStat(i)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addStat} className="border-gray-600 text-gray-400">
<Plus className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white"></CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{config.highlights.map((h, i) => (
<div key={i} className="flex gap-3 items-center">
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={h}
onChange={(e) => updateHighlight(i, e.target.value)}
placeholder="5年私域运营经验"
/>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-400"
onClick={() => removeHighlight(i)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addHighlight} className="border-gray-600 text-gray-400">
<Plus className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,337 +0,0 @@
import { useState, useEffect } from 'react'
import { get, post } from '@/api/client'
interface Section {
id: string
title: string
price: number
isFree: boolean
status: string
}
interface Chapter {
id: string
title: string
sections?: Section[]
price?: number
isFree?: boolean
status?: string
}
interface Part {
id: string
title: string
type: string
chapters: Chapter[]
}
interface Stats {
totalSections: number
freeSections: number
paidSections: number
totalParts: number
}
export function ChaptersPage() {
const [structure, setStructure] = useState<Part[]>([])
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedParts, setExpandedParts] = useState<string[]>([])
const [editingSection, setEditingSection] = useState<string | null>(null)
const [editPrice, setEditPrice] = useState<number>(1)
async function loadChapters() {
setLoading(true)
setError(null)
try {
const data = await get<{ success?: boolean; data?: { structure?: Part[]; stats?: Stats } }>(
'/api/admin/chapters',
)
if (data?.success && data.data) {
setStructure(data.data.structure ?? [])
setStats(data.data.stats ?? null)
} else {
setError('加载章节失败')
}
} catch (e) {
console.error('加载章节失败:', e)
setError('加载失败,请检查网络后重试')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadChapters()
}, [])
const togglePart = (partId: string) => {
setExpandedParts((prev) =>
prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId],
)
}
const handleUpdatePrice = async (sectionId: string) => {
try {
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
action: 'updatePrice',
chapterId: sectionId,
data: { price: editPrice },
})
if (result?.success) {
alert('价格更新成功')
setEditingSection(null)
loadChapters()
}
} catch (e) {
console.error('更新价格失败:', e)
}
}
const handleToggleFree = async (sectionId: string, currentFree: boolean) => {
try {
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
action: 'toggleFree',
chapterId: sectionId,
data: { isFree: !currentFree },
})
if (result?.success) {
alert('状态更新成功')
loadChapters()
}
} catch (e) {
console.error('更新状态失败:', e)
}
}
if (loading) {
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="text-xl text-gray-400">...</div>
</div>
)
}
return (
<div className="min-h-screen bg-black text-white">
{/* 导航栏 */}
<div className="sticky top-0 bg-black/90 backdrop-blur border-b border-white/10 z-50">
<div className="w-full min-w-[1024px] px-4 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold"></h1>
<div className="flex items-center gap-4">
<button
type="button"
onClick={loadChapters}
disabled={loading}
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => setExpandedParts(structure.map((p) => p.id))}
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
>
</button>
<button
type="button"
onClick={() => setExpandedParts([])}
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
>
</button>
</div>
</div>
</div>
<div className="w-full min-w-[1024px] px-4 py-8">
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
×
</button>
</div>
)}
{/* 统计卡片 */}
{stats && (
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="bg-gradient-to-br from-cyan-500/20 to-cyan-500/5 border border-cyan-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-cyan-400">{stats.totalSections}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
<div className="bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-green-400">{stats.freeSections}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-500/5 border border-yellow-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-yellow-400">{stats.paidSections}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
<div className="bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/30 rounded-xl p-4">
<div className="text-3xl font-bold text-purple-400">{stats.totalParts}</div>
<div className="text-white/60 text-sm mt-1"></div>
</div>
</div>
)}
{/* 章节列表 */}
<div className="space-y-4">
{structure.map((part) => (
<div
key={part.id}
className="bg-white/5 border border-white/10 rounded-xl overflow-hidden"
>
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5"
onClick={() => togglePart(part.id)}
onKeyDown={(e) => e.key === 'Enter' && togglePart(part.id)}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-3">
<span className="text-2xl">
{part.type === 'preface'
? '📖'
: part.type === 'epilogue'
? '🎬'
: part.type === 'appendix'
? '📎'
: '📚'}
</span>
<span className="font-semibold text-white">{part.title}</span>
<span className="text-white/40 text-sm">
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
</span>
</div>
<span className="text-white/40">{expandedParts.includes(part.id) ? '▲' : '▼'}</span>
</div>
{expandedParts.includes(part.id) && (
<div className="border-t border-white/10">
{part.chapters.map((chapter) => (
<div
key={chapter.id}
className="border-b border-white/5 last:border-b-0"
>
{chapter.sections ? (
<>
<div className="px-6 py-3 bg-white/5 text-white/70 font-medium">
{chapter.title}
</div>
<div className="divide-y divide-white/5">
{chapter.sections.map((section) => (
<div
key={section.id}
className="flex items-center justify-between px-6 py-3 hover:bg-white/5"
>
<div className="flex items-center gap-3">
<span
className={
section.isFree ? 'text-green-400' : 'text-yellow-400'
}
>
{section.isFree ? '🔓' : '🔒'}
</span>
<span className="text-white/80">{section.id}</span>
<span className="text-white/60">{section.title}</span>
</div>
<div className="flex items-center gap-3">
{editingSection === section.id ? (
<div className="flex items-center gap-2">
<input
type="number"
value={editPrice}
onChange={(e) => setEditPrice(Number(e.target.value))}
className="w-20 px-2 py-1 bg-white/10 border border-white/20 rounded text-white"
min={0}
step={0.1}
/>
<button
type="button"
onClick={() => handleUpdatePrice(section.id)}
className="px-3 py-1 bg-cyan-500 text-black rounded text-sm"
>
</button>
<button
type="button"
onClick={() => setEditingSection(null)}
className="px-3 py-1 bg-white/20 rounded text-sm text-white"
>
</button>
</div>
) : (
<>
<span
className={`px-2 py-1 rounded text-xs ${
section.isFree
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}
>
{section.isFree ? '免费' : `¥${section.price}`}
</span>
<button
type="button"
onClick={() => {
setEditingSection(section.id)
setEditPrice(section.price)
}}
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
>
</button>
<button
type="button"
onClick={() =>
handleToggleFree(section.id, section.isFree)
}
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
>
{section.isFree ? '设为付费' : '设为免费'}
</button>
</>
)}
</div>
</div>
))}
</div>
</>
) : (
<div className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
<div className="flex items-center gap-3">
<span
className={
chapter.isFree ? 'text-green-400' : 'text-yellow-400'
}
>
{chapter.isFree ? '🔓' : '🔒'}
</span>
<span className="text-white/80">{chapter.title}</span>
</div>
<span
className={`px-2 py-1 rounded text-xs ${
chapter.isFree
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}
>
{chapter.isFree ? '免费' : `¥${chapter.price ?? 1}`}
</span>
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,534 @@
/**
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
* 整行可拖拽;节和章可跨篇
*/
import { useCallback } from 'react'
import { ChevronRight, ChevronDown, BookOpen, Eye, Edit3, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
const PART_LABELS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
// 固定模块:序言首位、附录/尾声末位,不可拖拽
const FIXED_PART_KEYS = ['序言', '尾声', '附录']
function isFixedPart(title: string): boolean {
if (!title) return false
return FIXED_PART_KEYS.some((k) => title.includes(k))
}
export interface SectionItem {
id: string
title: string
price: number
isFree?: boolean
isNew?: boolean
}
export interface ChapterItem {
id: string
title: string
sections: SectionItem[]
}
export interface PartItem {
id: string
title: string
chapters: ChapterItem[]
}
type DragType = 'part' | 'chapter' | 'section'
function parseDragData(data: string): { type: DragType; id: string } | null {
if (data.startsWith('part:')) return { type: 'part', id: data.slice(5) }
if (data.startsWith('chapter:')) return { type: 'chapter', id: data.slice(8) }
if (data.startsWith('section:')) return { type: 'section', id: data.slice(8) }
return null
}
interface ChapterTreeProps {
parts: PartItem[]
expandedParts: string[]
onTogglePart: (partId: string) => void
onReorder: (items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]) => Promise<void>
onReadSection: (s: SectionItem) => void
onDeleteSection: (s: SectionItem) => void
}
export function ChapterTree({
parts,
expandedParts,
onTogglePart,
onReorder,
onReadSection,
onDeleteSection,
}: ChapterTreeProps) {
const buildSectionsList = useCallback(
(): { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] => {
const list: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] = []
for (const part of parts) {
for (const ch of part.chapters) {
for (const s of ch.sections) {
list.push({
id: s.id,
partId: part.id,
partTitle: part.title,
chapterId: ch.id,
chapterTitle: ch.title,
})
}
}
}
return list
},
[parts],
)
const handleDrop = useCallback(
async (e: React.DragEvent, toType: DragType, toId: string, toContext?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => {
e.preventDefault()
e.stopPropagation()
const data = e.dataTransfer.getData('text/plain')
const from = parseDragData(data)
if (!from) return
if (from.type === toType && from.id === toId) return
const sections = buildSectionsList()
const sectionMap = new Map(sections.map((x) => [x.id, x]))
// 固定模块(序言、附录、尾声)不可拖拽,也不可作为落点
if (from.type === 'section') {
const sec = sectionMap.get(from.id)
if (sec && isFixedPart(sec.partTitle)) return
} else {
const part = from.type === 'part' ? parts.find((p) => p.id === from.id) : parts.find((p) => p.chapters.some((c) => c.id === from.id))
if (part && isFixedPart(part.title)) return
}
if (toContext && isFixedPart(toContext.partTitle)) return
if (toType === 'part') {
const toPart = parts.find((p) => p.id === toId)
if (toPart && isFixedPart(toPart.title)) return
}
if (from.type === 'part' && toType === 'part') {
const partOrder = parts.map((p) => p.id)
const fromIdx = partOrder.indexOf(from.id)
const toIdx = partOrder.indexOf(toId)
if (fromIdx === -1 || toIdx === -1) return
const next = [...partOrder]
next.splice(fromIdx, 1)
next.splice(fromIdx < toIdx ? toIdx - 1 : toIdx, 0, from.id)
const newList: typeof sections = []
for (const pid of next) {
const p = parts.find((x) => x.id === pid)
if (!p) continue
for (const ch of p.chapters) {
for (const s of ch.sections) {
const ctx = sectionMap.get(s.id)
if (ctx) newList.push(ctx)
}
}
}
await onReorder(newList)
return
}
if (from.type === 'chapter' && (toType === 'chapter' || toType === 'section' || toType === 'part')) {
const srcPart = parts.find((p) => p.chapters.some((c) => c.id === from.id))
const srcCh = srcPart?.chapters.find((c) => c.id === from.id)
if (!srcPart || !srcCh) return
let targetPartId: string
let targetPartTitle: string
let insertAfterId: string | null = null
if (toType === 'section') {
const ctx = sectionMap.get(toId)
if (!ctx) return
targetPartId = ctx.partId
targetPartTitle = ctx.partTitle
insertAfterId = toId
} else if (toType === 'chapter') {
const part = parts.find((p) => p.chapters.some((c) => c.id === toId))
const ch = part?.chapters.find((c) => c.id === toId)
if (!part || !ch) return
targetPartId = part.id
targetPartTitle = part.title
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
insertAfterId = lastInCh?.id ?? null
} else {
const part = parts.find((p) => p.id === toId)
if (!part || !part.chapters[0]) return
targetPartId = part.id
targetPartTitle = part.title
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
insertAfterId = firstChSections[firstChSections.length - 1]?.id ?? null
}
const movingIds = srcCh.sections.map((s) => s.id)
const rest = sections.filter((s) => !movingIds.includes(s.id))
let targetIdx = rest.length
if (insertAfterId) {
const idx = rest.findIndex((s) => s.id === insertAfterId)
if (idx >= 0) targetIdx = idx + 1
}
const moving = movingIds.map((id) => {
const s = sectionMap.get(id)!
return {
...s,
partId: targetPartId,
partTitle: targetPartTitle,
chapterId: srcCh.id,
chapterTitle: srcCh.title,
}
})
await onReorder([...rest.slice(0, targetIdx), ...moving, ...rest.slice(targetIdx)])
return
}
if (from.type === 'section' && (toType === 'section' || toType === 'chapter' || toType === 'part')) {
if (!toContext) return
const { partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle } = toContext
let toIdx: number
if (toType === 'section') {
toIdx = sections.findIndex((s) => s.id === toId)
} else if (toType === 'chapter') {
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
toIdx = lastInCh ? sections.findIndex((s) => s.id === lastInCh.id) + 1 : sections.length
} else {
const part = parts.find((p) => p.id === toId)
if (!part?.chapters[0]) return
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
const last = firstChSections[firstChSections.length - 1]
toIdx = last ? sections.findIndex((s) => s.id === last.id) + 1 : 0
}
const fromIdx = sections.findIndex((s) => s.id === from.id)
if (fromIdx === -1) return
const next = sections.filter((s) => s.id !== from.id)
const insertIdx = fromIdx < toIdx ? toIdx - 1 : toIdx
const moved = sections[fromIdx]
const newItem = { ...moved, partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle }
next.splice(insertIdx, 0, newItem)
await onReorder(next)
}
},
[parts, buildSectionsList, onReorder],
)
const droppableHandlers = (type: DragType, id: string, ctx?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => ({
onDragEnter: (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
},
onDragOver: (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
},
onDrop: (e: React.DragEvent) => {
const from = parseDragData(e.dataTransfer.getData('text/plain'))
if (!from) return
if (type === 'section' && from.type === 'section' && from.id === id) return
if (type === 'part') {
if (from.type === 'part') handleDrop(e, 'part', id)
else {
const part = parts.find((p) => p.id === id)
const firstCh = part?.chapters[0]
if (firstCh && ctx) handleDrop(e, 'part', id, ctx)
}
} else if (type === 'chapter' && ctx) {
if (from.type === 'section' || from.type === 'chapter') handleDrop(e, 'chapter', id, ctx)
} else if (type === 'section' && ctx) {
handleDrop(e, 'section', id, ctx)
}
},
})
const partLabel = (i: number) => PART_LABELS[i] ?? String(i + 1)
return (
<div className="space-y-3">
{parts.map((part, partIndex) => {
const isXuYan = part.title === '序言' || part.title.includes('序言')
const isWeiSheng = part.title === '尾声' || part.title.includes('尾声')
const isFuLu = part.title === '附录' || part.title.includes('附录')
const isExpanded = expandedParts.includes(part.id)
const chapterCount = part.chapters.length
const sectionCount = part.chapters.reduce((s, ch) => s + ch.sections.length, 0)
// 序言:单行卡片样式,固定首位不可拖拽
if (isXuYan && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
const sec = part.chapters[0].sections[0]
return (
<div
key={part.id}
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
<BookOpen className="w-4 h-4 text-gray-400" />
</div>
<span className="font-medium text-gray-200 truncate">
{part.chapters[0].title} | {sec.title}
</span>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{sec.price === 0 || sec.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
</div>
)
}
// 附录:平铺章节列表,固定末位不可拖拽
if (isFuLu) {
return (
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
<h3 className="text-sm font-medium text-gray-400 mb-4"></h3>
<div className="space-y-3">
{part.chapters.map((ch, chIdx) => (
<div
key={ch.id}
className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2"
>
<span className="text-sm text-gray-300">{chIdx + 1} | {ch.title}</span>
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
</div>
))}
</div>
</div>
)
}
// 尾声:固定末位不可拖拽
if (isWeiSheng && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
const sec = part.chapters[0].sections[0]
return (
<div
key={part.id}
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
<BookOpen className="w-4 h-4 text-gray-400" />
</div>
<span className="font-medium text-gray-200 truncate">
{part.chapters[0].title} | {sec.title}
</span>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{sec.price === 0 || sec.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
</div>
)
}
if (isWeiSheng) {
// 尾声多章节:平铺展示,不可拖拽
return (
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
<h3 className="text-sm font-medium text-gray-400 mb-4"></h3>
<div className="space-y-3">
{part.chapters.map((ch) =>
ch.sections.map((sec) => (
<div key={sec.id} className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2">
<span className="text-sm text-gray-300">{ch.title} | {sec.title}</span>
<div className="flex gap-1 shrink-0">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Eye className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)),
)}
</div>
</div>
)
}
// 普通篇:卡片 + 章/节
return (
<div
key={part.id}
className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] overflow-hidden"
{...droppableHandlers('part', part.id, {
partId: part.id,
partTitle: part.title,
chapterId: part.chapters[0]?.id ?? '',
chapterTitle: part.chapters[0]?.title ?? '',
})}
>
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'part:' + part.id)
e.dataTransfer.effectAllowed = 'move'
}}
className="flex items-center justify-between p-4 cursor-grab active:cursor-grabbing select-none hover:bg-[#162840]/50 transition-colors"
onClick={() => onTogglePart(part.id)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-[#38bdac] flex items-center justify-center text-white font-bold shadow-lg shadow-[#38bdac]/30 shrink-0">
{partLabel(partIndex)}
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-gray-500">{chapterCount}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
</div>
</div>
{isExpanded && (
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter, chIndex) => (
<div key={chapter.id} className="space-y-2">
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'chapter:' + chapter.id)
e.dataTransfer.effectAllowed = 'move'
}}
className="py-2 px-2 rounded cursor-grab active:cursor-grabbing select-none hover:bg-[#162840]/30 -mx-2"
onDragEnter={(e) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}}
onDrop={(e) => {
const from = parseDragData(e.dataTransfer.getData('text/plain'))
if (!from) return
const ctx = { partId: part.id, partTitle: part.title, chapterId: chapter.id, chapterTitle: chapter.title }
if (from.type === 'section') handleDrop(e, 'chapter', chapter.id, ctx)
else if (from.type === 'chapter') handleDrop(e, 'chapter', chapter.id, ctx)
}}
>
<p className="text-xs text-gray-500 pb-1">{chIndex + 1} | {chapter.title}</p>
</div>
<div className="space-y-1 pl-2">
{chapter.sections.map((section) => (
<div
key={section.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + section.id)
e.dataTransfer.effectAllowed = 'move'
}}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-[#162840]/50 group cursor-grab active:cursor-grabbing select-none min-h-[40px]"
{...droppableHandlers('section', section.id, {
partId: part.id,
partTitle: part.title,
chapterId: chapter.id,
chapterTitle: chapter.title,
})}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`w-2 h-2 rounded-full shrink-0 ${section.price === 0 || section.isFree ? 'border-2 border-[#38bdac] bg-transparent' : 'bg-gray-500'}`}
/>
<span className="text-sm text-gray-200 truncate">
{section.id} {section.title}
</span>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{section.isNew && (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded">NEW</span>
)}
{section.price === 0 || section.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{section.price}</span>
)}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Eye className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<ChevronRight className="w-4 h-4 text-gray-500" />
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import {
Card,
CardContent,
@@ -28,8 +28,6 @@ import {
import {
BookOpen,
Settings2,
ChevronRight,
CheckCircle,
Edit3,
Save,
X,
@@ -39,9 +37,9 @@ import {
Plus,
Image as ImageIcon,
Search,
Trash2,
} from 'lucide-react'
import { get, put, del } from '@/api/client'
import { ChapterTree } from './ChapterTree'
import { apiUrl } from '@/api/client'
interface SectionListItem {
@@ -114,10 +112,23 @@ function buildTree(sections: SectionListItem[]): Part[] {
isNew: s.isNew,
})
}
return Array.from(partMap.values()).map((p) => ({
const parts = Array.from(partMap.values()).map((p) => ({
...p,
chapters: Array.from(p.chapters.values()),
}))
// 固定顺序:序言首位,附录/尾声末位
const orderKey = (t: string) => {
if (t.includes('序言')) return 0
if (t.includes('附录')) return 2
if (t.includes('尾声')) return 3
return 1
}
return parts.sort((a, b) => {
const ka = orderKey(a.title)
const kb = orderKey(b.title)
if (ka !== kb) return ka - kb
return 0
})
}
export function ContentPage() {
@@ -153,9 +164,6 @@ export function ContentPage() {
'/api/db/book?action=list',
)
setSectionsList(Array.isArray(data?.sections) ? data.sections : [])
if (expandedParts.length === 0 && tree.length > 0) {
setExpandedParts([tree[0].id])
}
} catch (e) {
console.error(e)
setSectionsList([])
@@ -168,11 +176,6 @@ export function ContentPage() {
loadList()
}, [])
useEffect(() => {
if (!loading && tree.length > 0 && expandedParts.length === 0) {
setExpandedParts([tree[0].id])
}
}, [loading, tree.length, expandedParts.length])
const togglePart = (partId: string) => {
setExpandedParts((prev) =>
@@ -180,6 +183,32 @@ export function ContentPage() {
)
}
const handleReorderTree = useCallback(
(items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]): Promise<void> => {
const prev = sectionsList
const newList: SectionListItem[] = items.flatMap((it) => {
const s = prev.find((x) => x.id === it.id)
if (!s) return []
return [{ ...s, partId: it.partId, partTitle: it.partTitle, chapterId: it.chapterId, chapterTitle: it.chapterTitle }]
})
setSectionsList(newList)
put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
.then((res) => {
if (res && (res as { success?: boolean }).success === false) {
setSectionsList(prev)
alert('排序失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : '未知错误'))
}
})
.catch((e) => {
setSectionsList(prev)
console.error('排序失败:', e)
alert('排序失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
})
return Promise.resolve()
},
[sectionsList],
)
const handleDeleteSection = async (section: Section & { filePath?: string }) => {
if (!confirm(`确定要删除章节「${section.title}」吗?此操作不可恢复。`)) return
try {
@@ -546,7 +575,7 @@ export function ContentPage() {
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Label className="text-gray-300"> is_free price=0 </Label>
<div className="flex items-center h-10">
<label className="flex items-center cursor-pointer">
<input
@@ -685,6 +714,23 @@ export function ContentPage() {
</TabsList>
<TabsContent value="chapters" className="space-y-4">
{/* 书籍信息卡片 */}
<div className="rounded-2xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between shadow-sm">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-[#38bdac] flex items-center justify-center text-white shadow-lg shadow-[#38bdac]/20 shrink-0">
<BookOpen className="w-6 h-6" />
</div>
<div>
<h2 className="font-bold text-base text-white leading-tight mb-1">SOUL的创业实验场</h2>
<p className="text-xs text-gray-500">Soul派对房的真实商业故事</p>
</div>
</div>
<div className="text-center shrink-0">
<span className="block text-2xl font-bold text-[#38bdac]">{totalSections}</span>
<span className="text-xs text-gray-500"></span>
</div>
</div>
<Button
onClick={() => setShowNewSectionModal(true)}
className="w-full bg-[#38bdac]/10 hover:bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30"
@@ -699,85 +745,14 @@ export function ContentPage() {
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
tree.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>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteSection(section)}
className="text-gray-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
))
<ChapterTree
parts={tree}
expandedParts={expandedParts}
onTogglePart={togglePart}
onReorder={handleReorderTree}
onReadSection={handleReadSection}
onDeleteSection={handleDeleteSection}
/>
)}
</TabsContent>

View File

@@ -85,7 +85,7 @@ const DEFAULT_CONFIG: MatchConfig = {
{
id: 'mentor',
label: '导师顾问',
matchLabel: '商业顾问',
matchLabel: '导师顾问',
icon: '❤️',
matchFromDB: false,
showJoinAfterMatch: true,

View File

@@ -29,8 +29,6 @@ import {
MapPin,
BookOpen,
Gift,
X,
Plus,
Smartphone,
} from 'lucide-react'
import { get, post } from '@/api/client'
@@ -97,15 +95,6 @@ const defaultFeatures: FeatureConfig = {
export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [freeChapters, setFreeChapters] = useState<string[]>([
'preface',
'epilogue',
'1.1',
'appendix-1',
'appendix-2',
'appendix-3',
])
const [newFreeChapter, setNewFreeChapter] = useState('')
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [isSaving, setIsSaving] = useState(false)
@@ -128,13 +117,11 @@ export function SettingsPage() {
try {
const res = await get<{
success?: boolean
freeChapters?: string[]
featureConfig?: Partial<FeatureConfig>
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo }
mpConfig?: Partial<MpConfig>
}>('/api/admin/settings')
if (!res || (res as { success?: boolean }).success === false) return
if (Array.isArray(res.freeChapters) && res.freeChapters.length) setFreeChapters(res.freeChapters)
if (res.featureConfig && Object.keys(res.featureConfig).length)
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object')
@@ -193,7 +180,6 @@ export function SettingsPage() {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
freeChapters,
featureConfig,
siteSettings: {
sectionPrice: localSettings.sectionPrice,
@@ -222,17 +208,6 @@ export function SettingsPage() {
}
}
const addFreeChapter = () => {
if (newFreeChapter && !freeChapters.includes(newFreeChapter)) {
setFreeChapters([...freeChapters, newFreeChapter])
setNewFreeChapter('')
}
}
const removeFreeChapter = (chapter: string) => {
setFreeChapters(freeChapters.filter((c) => c !== chapter))
}
if (loading) return <div className="p-8 text-gray-500">...</div>
return (
@@ -498,53 +473,6 @@ export function SettingsPage() {
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Gift className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{freeChapters.map((chapter) => (
<span
key={chapter}
className="inline-flex items-center gap-1 bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30 px-3 py-1 rounded-md text-sm"
>
{chapter}
<button
type="button"
onClick={() => removeFreeChapter(chapter)}
className="ml-1 hover:text-red-400"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<Input
className="bg-[#0a1628] border-gray-700 text-white flex-1"
placeholder="输入章节ID如 1.2、2.1、preface"
value={newFreeChapter}
onChange={(e) => setNewFreeChapter(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addFreeChapter()}
/>
<Button onClick={addFreeChapter} className="bg-[#38bdac] hover:bg-[#2da396]">
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<p className="text-xs text-gray-500">
常用ID: preface(), epilogue(), appendix-1/2/3(), 1.1/1.2()
</p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/api-doc/apidocpage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"errors":true,"version":"5.6.3"}