更新小程序开发文档,新增2026-03-03的最佳实践记录,优化个人中心类页面的卡片区边距规范,确保一致性与可用性。调整相关页面以反映最新设计稿,提升用户体验与功能一致性。
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
413
soul-admin/src/pages/admin-users/AdminUsersPage.tsx
Normal file
413
soul-admin/src/pages/admin-users/AdminUsersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
360
soul-admin/src/pages/author-settings/AuthorSettingsPage.tsx
Normal file
360
soul-admin/src/pages/author-settings/AuthorSettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
534
soul-admin/src/pages/content/ChapterTree.tsx
Normal file
534
soul-admin/src/pages/content/ChapterTree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ const DEFAULT_CONFIG: MatchConfig = {
|
||||
{
|
||||
id: 'mentor',
|
||||
label: '导师顾问',
|
||||
matchLabel: '商业顾问',
|
||||
matchLabel: '导师顾问',
|
||||
icon: '❤️',
|
||||
matchFromDB: false,
|
||||
showJoinAfterMatch: true,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user