更新管理端迁移Mycontent-temp的菜单与布局规范,确保主导航收敛并优化隐藏页面入口。新增相关会议记录与文档,反映团队讨论的最新决策与实施建议。
This commit is contained in:
@@ -8,7 +8,6 @@ import { DistributionPage } from './pages/distribution/DistributionPage'
|
||||
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
|
||||
import { ContentPage } from './pages/content/ContentPage'
|
||||
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,7 +17,6 @@ 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 { FindPartnerPage } from './pages/find-partner/FindPartnerPage'
|
||||
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
|
||||
import { NotFoundPage } from './pages/not-found/NotFoundPage'
|
||||
@@ -36,11 +34,11 @@ function App() {
|
||||
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||
<Route path="content" element={<ContentPage />} />
|
||||
<Route path="referral-settings" element={<ReferralSettingsPage />} />
|
||||
<Route path="author-settings" element={<AuthorSettingsPage />} />
|
||||
<Route path="author-settings" element={<Navigate to="/settings?tab=author" replace />} />
|
||||
<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="admin-users" element={<Navigate to="/settings?tab=admin" replace />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="payment" element={<PaymentPage />} />
|
||||
<Route path="site" element={<SitePage />} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.rich-editor-wrapper {
|
||||
.rich-editor-wrapper {
|
||||
border: 1px solid #374151;
|
||||
border-radius: 0.5rem;
|
||||
background: #0a1628;
|
||||
@@ -142,6 +142,17 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* #linkTag 高亮:与小程序 read.wxss .link-tag 金黄色保持一致 */
|
||||
.link-tag-node {
|
||||
background: rgba(255, 215, 0, 0.12);
|
||||
color: #FFD700;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.mention-popup {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||
import { useEditor, EditorContent, type Editor, Node, mergeAttributes } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Link from '@tiptap/extension-link'
|
||||
@@ -16,6 +16,7 @@ export interface PersonItem {
|
||||
id: string
|
||||
name: string
|
||||
label?: string
|
||||
ckbApiKey?: string // 存客宝密钥,留空则 fallback 全局 Key
|
||||
}
|
||||
|
||||
export interface LinkTagItem {
|
||||
@@ -102,6 +103,50 @@ function markdownToHtml(md: string): string {
|
||||
return result.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* LinkTagExtension — 自定义 TipTap 内联节点,保留所有 data-* 属性
|
||||
* 解决:insertContent(html) 会经过 TipTap schema 导致自定义属性被丢弃的问题
|
||||
*/
|
||||
const LinkTagExtension = Node.create({
|
||||
name: 'linkTag',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
selectable: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
label: { default: '' },
|
||||
url: { default: '' },
|
||||
tagType: { default: 'url', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-type') || 'url' },
|
||||
tagId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-id') || '' },
|
||||
pagePath: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-page-path') || '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-type="linkTag"]', getAttrs: (el: HTMLElement) => ({
|
||||
label: el.textContent?.replace(/^#/, '').trim() || '',
|
||||
url: el.getAttribute('data-url') || '',
|
||||
tagType: el.getAttribute('data-tag-type') || 'url',
|
||||
tagId: el.getAttribute('data-tag-id') || '',
|
||||
pagePath: el.getAttribute('data-page-path')|| '',
|
||||
}) }]
|
||||
},
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: Record<string, any> }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'linkTag',
|
||||
'data-url': node.attrs.url,
|
||||
'data-tag-type': node.attrs.tagType,
|
||||
'data-tag-id': node.attrs.tagId,
|
||||
'data-page-path': node.attrs.pagePath,
|
||||
class: 'link-tag-node',
|
||||
}), `#${node.attrs.label}`]
|
||||
},
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const MentionSuggestion = (persons: PersonItem[]): any => ({
|
||||
items: ({ query }: { query: string }) =>
|
||||
@@ -196,6 +241,7 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
|
||||
HTMLAttributes: { class: 'mention-tag' },
|
||||
suggestion: MentionSuggestion(persons),
|
||||
}),
|
||||
LinkTagExtension,
|
||||
Placeholder.configure({ placeholder }),
|
||||
Table.configure({ resizable: true }),
|
||||
TableRow, TableCell, TableHeader,
|
||||
@@ -242,10 +288,16 @@ const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
|
||||
|
||||
const insertLinkTag = useCallback((tag: LinkTagItem) => {
|
||||
if (!editor) return
|
||||
// 通过自定义扩展节点插入,确保 data-* 属性不被 TipTap schema 丢弃
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'text',
|
||||
marks: [{ type: 'link', attrs: { href: tag.url, target: '_blank' } }],
|
||||
text: `#${tag.label}`,
|
||||
type: 'linkTag',
|
||||
attrs: {
|
||||
label: tag.label,
|
||||
url: tag.url || '',
|
||||
tagType: tag.type || 'url',
|
||||
tagId: tag.id || '',
|
||||
pagePath: tag.pagePath || '',
|
||||
},
|
||||
}).run()
|
||||
}, [editor])
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -106,13 +107,13 @@ export function SetVipModal({
|
||||
async function handleSave() {
|
||||
if (!userId) return
|
||||
if (form.isVip && !form.vipExpireDate.trim()) {
|
||||
alert('开启 VIP 时请填写有效到期日')
|
||||
toast.error('开启 VIP 时请填写有效到期日')
|
||||
return
|
||||
}
|
||||
if (form.isVip && form.vipExpireDate.trim()) {
|
||||
const d = new Date(form.vipExpireDate)
|
||||
if (isNaN(d.getTime())) {
|
||||
alert('到期日格式无效,请使用 YYYY-MM-DD')
|
||||
toast.error('到期日格式无效,请使用 YYYY-MM-DD')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -132,15 +133,15 @@ export function SetVipModal({
|
||||
}
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
|
||||
if (data?.success) {
|
||||
alert('VIP 设置已保存')
|
||||
toast.success('VIP 设置已保存')
|
||||
onSaved?.()
|
||||
onClose()
|
||||
} else {
|
||||
alert('保存失败: ' + (data as { error?: string })?.error)
|
||||
toast.error('保存失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save VIP error:', e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -201,7 +202,7 @@ export function UserDetailModal({
|
||||
}
|
||||
|
||||
async function handleSyncCKB() {
|
||||
if (!user?.phone) { alert('用户未绑定手机号,无法同步'); return }
|
||||
if (!user?.phone) { toast.info('用户未绑定手机号,无法同步'); return }
|
||||
setSyncing(true)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
|
||||
@@ -209,11 +210,11 @@ export function UserDetailModal({
|
||||
phone: user.phone,
|
||||
userId: user.id,
|
||||
})
|
||||
if (data?.success) { alert('同步成功'); loadUserDetail() }
|
||||
else alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
if (data?.success) { toast.success('同步成功'); loadUserDetail() }
|
||||
else toast.error('同步失败: ' + (data as { error?: string })?.error)
|
||||
} catch (e) {
|
||||
console.error('Sync CKB error:', e)
|
||||
alert('同步失败')
|
||||
toast.error('同步失败')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
@@ -231,15 +232,15 @@ export function UserDetailModal({
|
||||
}
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
|
||||
if (data?.success) {
|
||||
alert('保存成功')
|
||||
toast.success('保存成功')
|
||||
loadUserDetail()
|
||||
onUserUpdated?.()
|
||||
} else {
|
||||
alert('保存失败: ' + (data as { error?: string })?.error)
|
||||
toast.error('保存失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save user error:', e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -256,20 +257,20 @@ export function UserDetailModal({
|
||||
|
||||
async function handleSavePassword() {
|
||||
if (!user) return
|
||||
if (!newPassword) { alert('请输入新密码'); return }
|
||||
if (newPassword !== confirmPassword) { alert('两次密码不一致'); return }
|
||||
if (newPassword.length < 6) { alert('密码至少 6 位'); return }
|
||||
if (!newPassword) { toast.error('请输入新密码'); return }
|
||||
if (newPassword !== confirmPassword) { toast.error('两次密码不一致'); return }
|
||||
if (newPassword.length < 6) { toast.error('密码至少 6 位'); return }
|
||||
setPasswordSaving(true)
|
||||
try {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, password: newPassword })
|
||||
if (data?.success) { alert('修改成功'); setNewPassword(''); setConfirmPassword('') }
|
||||
else alert('修改失败: ' + (data?.error || ''))
|
||||
} catch { alert('修改失败') } finally { setPasswordSaving(false) }
|
||||
if (data?.success) { toast.success('修改成功'); setNewPassword(''); setConfirmPassword('') }
|
||||
else toast.error('修改失败: ' + (data?.error || ''))
|
||||
} catch { toast.error('修改失败') } finally { setPasswordSaving(false) }
|
||||
}
|
||||
|
||||
async function handleSaveVip() {
|
||||
if (!user) return
|
||||
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { alert('开启 VIP 请填写有效到期日'); return }
|
||||
if (vipForm.isVip && !vipForm.vipExpireDate.trim()) { toast.error('开启 VIP 请填写有效到期日'); return }
|
||||
setVipSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
@@ -283,9 +284,9 @@ export function UserDetailModal({
|
||||
vipBio: vipForm.vipBio || undefined,
|
||||
}
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
|
||||
if (data?.success) { alert('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
|
||||
else alert('保存失败: ' + (data?.error || ''))
|
||||
} catch { alert('保存失败') } finally { setVipSaving(false) }
|
||||
if (data?.success) { toast.success('VIP 设置已保存'); loadUserDetail(); onUserUpdated?.() }
|
||||
else toast.error('保存失败: ' + (data?.error || ''))
|
||||
} catch { toast.error('保存失败') } finally { setVipSaving(false) }
|
||||
}
|
||||
|
||||
// 用户资料完善查询(支持多维度)
|
||||
@@ -657,9 +658,9 @@ export function UserDetailModal({
|
||||
if (!ckbWechatOwner || !user) return
|
||||
try {
|
||||
await put('/api/db/users', { id: user.id, wechatId: ckbWechatOwner })
|
||||
alert('已保存微信归属')
|
||||
toast.success('已保存微信归属')
|
||||
loadUserDetail()
|
||||
} catch { alert('保存失败') }
|
||||
} catch { toast.error('保存失败') }
|
||||
}}
|
||||
className="bg-purple-500/20 hover:bg-purple-500/30 text-purple-400 border border-purple-500/30 shrink-0"
|
||||
>
|
||||
|
||||
@@ -33,7 +33,7 @@ const DialogContent = React.forwardRef<
|
||||
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',
|
||||
'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',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,41 +3,22 @@ import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
Wallet,
|
||||
BookOpen,
|
||||
GitMerge,
|
||||
Crown,
|
||||
GraduationCap,
|
||||
Calendar,
|
||||
User,
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Handshake,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { clearAdminToken } from '@/api/auth'
|
||||
|
||||
// 主菜单(核心运营 3 项)
|
||||
// 主菜单(5 项平铺,按 Mycontent-temp 新规范)
|
||||
const primaryMenuItems = [
|
||||
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
|
||||
{ icon: BookOpen, label: '内容管理', href: '/content' },
|
||||
{ icon: Users, label: '用户管理', href: '/users' },
|
||||
]
|
||||
// 折叠区「更多」(字典类 + 业务)
|
||||
const moreMenuItems = [
|
||||
{ icon: Crown, label: 'VIP 角色', href: '/vip-roles' },
|
||||
{ icon: User, label: '作者详情', href: '/author-settings' },
|
||||
{ icon: ShieldCheck, label: '管理员', href: '/admin-users' },
|
||||
{ icon: GraduationCap, label: '导师管理', href: '/mentors' },
|
||||
{ icon: Calendar, label: '导师预约', href: '/mentor-consultations' },
|
||||
{ icon: GitMerge, label: '找伙伴', href: '/find-partner' },
|
||||
{ icon: Wallet, label: '推广中心', href: '/distribution' },
|
||||
{ icon: Handshake, label: '找伙伴', href: '/find-partner' },
|
||||
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
|
||||
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
|
||||
]
|
||||
|
||||
export function AdminLayout() {
|
||||
@@ -45,15 +26,10 @@ 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
|
||||
@@ -123,37 +99,6 @@ export function AdminLayout() {
|
||||
</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"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -97,7 +98,7 @@ export function AuthorSettingsPage() {
|
||||
}
|
||||
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 : ''))
|
||||
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
|
||||
return
|
||||
}
|
||||
setSaving(false)
|
||||
@@ -109,7 +110,7 @@ export function AuthorSettingsPage() {
|
||||
setTimeout(() => msg.remove(), 2000)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -136,11 +137,11 @@ export function AuthorSettingsPage() {
|
||||
if (data?.success && data?.url) {
|
||||
setConfig((prev) => ({ ...prev, avatarImg: data.url }))
|
||||
} else {
|
||||
alert('上传失败: ' + (data?.error || '未知错误'))
|
||||
toast.error('上传失败: ' + (data?.error || '未知错误'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('上传失败')
|
||||
toast.error('上传失败')
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface Section {
|
||||
@@ -80,7 +81,7 @@ export function ChaptersPage() {
|
||||
data: { price: editPrice },
|
||||
})
|
||||
if (result?.success) {
|
||||
alert('价格更新成功')
|
||||
toast.success('价格更新成功')
|
||||
setEditingSection(null)
|
||||
loadChapters()
|
||||
}
|
||||
@@ -97,7 +98,7 @@ export function ChaptersPage() {
|
||||
data: { isFree: !currentFree },
|
||||
})
|
||||
if (result?.success) {
|
||||
alert('状态更新成功')
|
||||
toast.success('状态更新成功')
|
||||
loadChapters()
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -9,7 +10,6 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import RichEditor, { type PersonItem, type LinkTagItem, type RichEditorRef } from '@/components/RichEditor'
|
||||
import '@/components/RichEditor.css'
|
||||
import {
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
Star,
|
||||
Hash,
|
||||
ExternalLink,
|
||||
Pencil,
|
||||
Check,
|
||||
} from 'lucide-react'
|
||||
import { get, put, post, del } from '@/api/client'
|
||||
import { ChapterTree } from './ChapterTree'
|
||||
@@ -199,6 +201,10 @@ export function ContentPage() {
|
||||
content: '',
|
||||
editionStandard: true,
|
||||
editionPremium: false,
|
||||
isFree: false,
|
||||
isNew: false,
|
||||
isPinned: false,
|
||||
hotScore: 0,
|
||||
})
|
||||
|
||||
const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null)
|
||||
@@ -227,7 +233,9 @@ export function ContentPage() {
|
||||
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
|
||||
const [persons, setPersons] = useState<PersonItem[]>([])
|
||||
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
|
||||
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '' })
|
||||
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '', ckbApiKey: '' })
|
||||
const [editingPersonKey, setEditingPersonKey] = useState<string | null>(null) // 正在编辑密钥的 personId
|
||||
const [editingPersonKeyValue, setEditingPersonKeyValue] = useState('')
|
||||
const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', type: 'url' as 'url' | 'miniprogram' | 'ckb', appId: '', pagePath: '' })
|
||||
const richEditorRef = useRef<RichEditorRef>(null)
|
||||
|
||||
@@ -280,13 +288,13 @@ export function ContentPage() {
|
||||
.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 : '未知错误'))
|
||||
toast.error('排序失败: ' + ((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 : '网络或服务异常'))
|
||||
toast.error('排序失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
|
||||
})
|
||||
return Promise.resolve()
|
||||
},
|
||||
@@ -300,14 +308,14 @@ export function ContentPage() {
|
||||
`/api/db/book?id=${encodeURIComponent(section.id)}`,
|
||||
)
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert('已删除')
|
||||
toast.success('已删除')
|
||||
loadList()
|
||||
} else {
|
||||
alert('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
toast.error('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('删除失败')
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +349,7 @@ export function ContentPage() {
|
||||
const { readWeight, recencyWeight, payWeight } = rankingWeights
|
||||
const sum = readWeight + recencyWeight + payWeight
|
||||
if (Math.abs(sum - 1) > 0.001) {
|
||||
alert('三个权重之和必须等于 1')
|
||||
toast.error('三个权重之和必须等于 1')
|
||||
return
|
||||
}
|
||||
setRankingWeightsSaving(true)
|
||||
@@ -352,14 +360,14 @@ export function ContentPage() {
|
||||
description: '文章排名算法权重',
|
||||
})
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert('已保存')
|
||||
toast.success('排名权重已保存')
|
||||
loadList()
|
||||
} else {
|
||||
alert('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
|
||||
toast.error('保存失败: ' + ((res && typeof res === 'object' && 'error' in res) ? (res as { error?: string }).error : ''))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setRankingWeightsSaving(false)
|
||||
}
|
||||
@@ -379,8 +387,8 @@ export function ContentPage() {
|
||||
|
||||
const loadPersons = useCallback(async () => {
|
||||
try {
|
||||
const data = await get<{ success?: boolean; persons?: { personId: string; name: string; label?: string }[] }>('/api/db/persons')
|
||||
if (data?.success && data.persons) setPersons(data.persons.map(p => ({ id: p.personId, name: p.name, label: p.label })))
|
||||
const data = await get<{ success?: boolean; persons?: { personId: string; name: string; label?: string; ckbApiKey?: string }[] }>('/api/db/persons')
|
||||
if (data?.success && data.persons) setPersons(data.persons.map(p => ({ id: p.personId, name: p.name, label: p.label, ckbApiKey: p.ckbApiKey })))
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
@@ -418,7 +426,7 @@ export function ContentPage() {
|
||||
}, [])
|
||||
|
||||
const handleSavePreviewPercent = async () => {
|
||||
if (previewPercent < 1 || previewPercent > 100) { alert('预览比例需在 1~100 之间'); return }
|
||||
if (previewPercent < 1 || previewPercent > 100) { toast.error('预览比例需在 1~100 之间'); return }
|
||||
setPreviewPercentSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
|
||||
@@ -426,9 +434,9 @@ export function ContentPage() {
|
||||
value: previewPercent,
|
||||
description: '小程序未付费内容默认预览比例(%)',
|
||||
})
|
||||
if (res && (res as { success?: boolean }).success !== false) alert('已保存')
|
||||
else alert('保存失败: ' + ((res as { error?: string }).error || ''))
|
||||
} catch { alert('保存失败') } finally { setPreviewPercentSaving(false) }
|
||||
if (res && (res as { success?: boolean }).success !== false) toast.success('预览比例已保存')
|
||||
else toast.error('保存失败: ' + ((res as { error?: string }).error || ''))
|
||||
} catch { toast.error('保存失败') } finally { setPreviewPercentSaving(false) }
|
||||
}
|
||||
|
||||
useEffect(() => { loadPinnedSections(); loadPreviewPercent(); loadPersons(); loadLinkTags() }, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags])
|
||||
@@ -489,7 +497,7 @@ export function ContentPage() {
|
||||
editionPremium: false,
|
||||
})
|
||||
if (data && !(data as { success?: boolean }).success) {
|
||||
alert('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
|
||||
toast.error('无法读取文件内容: ' + ((data as { error?: string }).error || '未知错误'))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -540,15 +548,15 @@ export function ContentPage() {
|
||||
await handleTogglePin(effectiveId)
|
||||
}
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert(`已保存章节: ${editingSection.title}`)
|
||||
toast.success(`已保存:${editingSection.title}`)
|
||||
setEditingSection(null)
|
||||
loadList()
|
||||
} else {
|
||||
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -556,33 +564,51 @@ export function ContentPage() {
|
||||
|
||||
const handleCreateSection = async () => {
|
||||
if (!newSection.id || !newSection.title) {
|
||||
alert('请填写章节ID和标题')
|
||||
toast.error('请填写章节ID和标题')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const currentPart = tree.find((p) => p.id === newSection.partId)
|
||||
const currentChapter = currentPart?.chapters.find((c) => c.id === newSection.chapterId)
|
||||
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', {
|
||||
id: newSection.id,
|
||||
title: newSection.title,
|
||||
price: newSection.price,
|
||||
price: newSection.isFree ? 0 : newSection.price,
|
||||
content: newSection.content,
|
||||
partId: newSection.partId,
|
||||
partTitle: currentPart?.title ?? '',
|
||||
chapterId: newSection.chapterId,
|
||||
chapterTitle: currentChapter?.title ?? '',
|
||||
isFree: newSection.isFree,
|
||||
isNew: newSection.isNew,
|
||||
editionStandard: newSection.editionPremium ? false : (newSection.editionStandard ?? true),
|
||||
editionPremium: newSection.editionPremium ?? false,
|
||||
hotScore: newSection.hotScore ?? 0,
|
||||
saveToFile: false,
|
||||
})
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert(`章节创建成功: ${newSection.title}`)
|
||||
if (newSection.isPinned) {
|
||||
const next = [...pinnedSectionIds, newSection.id]
|
||||
setPinnedSectionIds(next)
|
||||
try {
|
||||
await post<{ success?: boolean }>('/api/db/config', {
|
||||
key: 'pinned_section_ids',
|
||||
value: next,
|
||||
description: '强制置顶章节ID列表(精选推荐/首页最新更新)',
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
toast.success(`章节创建成功:${newSection.title}`)
|
||||
setShowNewSectionModal(false)
|
||||
setNewSection({ id: '', title: '', price: 1, partId: 'part-1', chapterId: 'chapter-1', content: '', editionStandard: true, editionPremium: false })
|
||||
setNewSection({ id: '', title: '', price: 1, partId: 'part-1', chapterId: 'chapter-1', content: '', editionStandard: true, editionPremium: false, isFree: false, isNew: false, isPinned: false, hotScore: 0 })
|
||||
loadList()
|
||||
} else {
|
||||
alert('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
toast.error('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('创建失败')
|
||||
toast.error('创建失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -617,14 +643,20 @@ export function ContentPage() {
|
||||
items,
|
||||
})
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
const newTitle = editingPart.title.trim()
|
||||
setSectionsList((prev) =>
|
||||
prev.map((s) =>
|
||||
s.partId === editingPart.id ? { ...s, partTitle: newTitle } : s
|
||||
)
|
||||
)
|
||||
setEditingPart(null)
|
||||
loadList()
|
||||
} else {
|
||||
alert('更新篇名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
toast.error('更新篇名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('更新篇名失败')
|
||||
toast.error('更新篇名失败')
|
||||
} finally {
|
||||
setIsSavingPartTitle(false)
|
||||
}
|
||||
@@ -642,6 +674,10 @@ export function ContentPage() {
|
||||
content: '',
|
||||
editionStandard: true,
|
||||
editionPremium: false,
|
||||
isFree: false,
|
||||
isNew: false,
|
||||
isPinned: false,
|
||||
hotScore: 0,
|
||||
})
|
||||
setShowNewSectionModal(true)
|
||||
}
|
||||
@@ -666,14 +702,24 @@ export function ContentPage() {
|
||||
}))
|
||||
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
const newTitle = editingChapter.title.trim()
|
||||
const partId = editingChapter.part.id
|
||||
const chapterId = editingChapter.chapter.id
|
||||
setSectionsList((prev) =>
|
||||
prev.map((s) =>
|
||||
s.partId === partId && s.chapterId === chapterId
|
||||
? { ...s, chapterTitle: newTitle }
|
||||
: s
|
||||
)
|
||||
)
|
||||
setEditingChapter(null)
|
||||
loadList()
|
||||
} else {
|
||||
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setIsSavingChapterTitle(false)
|
||||
}
|
||||
@@ -682,7 +728,7 @@ export function ContentPage() {
|
||||
const handleDeleteChapter = async (part: Part, chapter: Chapter) => {
|
||||
const sectionIds = chapter.sections.map((s) => s.id)
|
||||
if (sectionIds.length === 0) {
|
||||
alert('该章下无小节,无需删除')
|
||||
toast.info('该章下无小节,无需删除')
|
||||
return
|
||||
}
|
||||
if (!confirm(`确定要删除「第${part.chapters.indexOf(chapter) + 1}章 | ${chapter.title}」吗?将删除共 ${sectionIds.length} 节,此操作不可恢复。`)) return
|
||||
@@ -693,13 +739,13 @@ export function ContentPage() {
|
||||
loadList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('删除失败')
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreatePart = async () => {
|
||||
if (!newPartTitle.trim()) {
|
||||
alert('请输入篇名')
|
||||
toast.error('请输入篇名')
|
||||
return
|
||||
}
|
||||
setIsSavingPart(true)
|
||||
@@ -719,16 +765,16 @@ export function ContentPage() {
|
||||
saveToFile: false,
|
||||
})
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert(`篇「${newPartTitle}」创建成功,请编辑占位节`)
|
||||
toast.success(`篇「${newPartTitle}」创建成功`)
|
||||
setShowNewPartModal(false)
|
||||
setNewPartTitle('')
|
||||
loadList()
|
||||
} else {
|
||||
alert('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
toast.error('创建失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('创建失败')
|
||||
toast.error('创建失败')
|
||||
} finally {
|
||||
setIsSavingPart(false)
|
||||
}
|
||||
@@ -736,13 +782,13 @@ export function ContentPage() {
|
||||
|
||||
const handleBatchMoveToTarget = async () => {
|
||||
if (selectedSectionIds.length === 0) {
|
||||
alert('请先勾选要移动的章节')
|
||||
toast.error('请先勾选要移动的章节')
|
||||
return
|
||||
}
|
||||
const targetPart = tree.find((p) => p.id === batchMoveTargetPartId)
|
||||
const targetChapter = targetPart?.chapters.find((c) => c.id === batchMoveTargetChapterId)
|
||||
if (!targetPart || !targetChapter || !batchMoveTargetPartId || !batchMoveTargetChapterId) {
|
||||
alert('请选择目标篇和章')
|
||||
toast.error('请选择目标篇和章')
|
||||
return
|
||||
}
|
||||
setIsMoving(true)
|
||||
@@ -788,7 +834,7 @@ export function ContentPage() {
|
||||
{ action: 'reorder', items: reorderItems },
|
||||
)
|
||||
if (reorderRes && (reorderRes as { success?: boolean }).success !== false) {
|
||||
alert(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}」`)
|
||||
toast.success(`已移动 ${selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}」`)
|
||||
setShowBatchMoveModal(false)
|
||||
setSelectedSectionIds([])
|
||||
await loadList()
|
||||
@@ -807,7 +853,7 @@ export function ContentPage() {
|
||||
}
|
||||
const res = await put<{ success?: boolean; error?: string; count?: number }>('/api/db/book', payload)
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}」`)
|
||||
toast.success(`已移动 ${(res as { count?: number }).count ?? selectedSectionIds.length} 节到「${targetPart.title}」-「${targetChapter.title}」`)
|
||||
setShowBatchMoveModal(false)
|
||||
setSelectedSectionIds([])
|
||||
await loadList()
|
||||
@@ -817,11 +863,11 @@ export function ContentPage() {
|
||||
const fallbackOk = await tryFallbackReorder()
|
||||
if (fallbackOk) return
|
||||
}
|
||||
alert('移动失败: ' + errorMessage)
|
||||
toast.error('移动失败: ' + errorMessage)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
|
||||
toast.error('移动失败: ' + (e instanceof Error ? e.message : '网络或服务异常'))
|
||||
} finally {
|
||||
setIsMoving(false)
|
||||
}
|
||||
@@ -836,7 +882,7 @@ export function ContentPage() {
|
||||
const handleDeletePart = async (part: Part) => {
|
||||
const sectionIds = sectionsList.filter((s) => s.partId === part.id).map((s) => s.id)
|
||||
if (sectionIds.length === 0) {
|
||||
alert('该篇下暂无小节可删除')
|
||||
toast.info('该篇下暂无小节可删除')
|
||||
return
|
||||
}
|
||||
if (!confirm(`确定要删除「${part.title}」整篇吗?将删除共 ${sectionIds.length} 节内容,此操作不可恢复。`)) return
|
||||
@@ -847,7 +893,7 @@ export function ContentPage() {
|
||||
loadList()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('删除失败')
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,13 +909,13 @@ export function ContentPage() {
|
||||
} else {
|
||||
setSearchResults([])
|
||||
if (data && !(data as { success?: boolean }).success) {
|
||||
alert('搜索失败: ' + (data as { error?: string }).error)
|
||||
toast.error('搜索失败: ' + (data as { error?: string }).error)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setSearchResults([])
|
||||
alert('搜索失败')
|
||||
toast.error('搜索失败')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
@@ -908,17 +954,17 @@ export function ContentPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新建章节弹窗 */}
|
||||
{/* 新建章节弹窗:与编辑章节样式功能一致 */}
|
||||
<Dialog open={showNewSectionModal} onOpenChange={setShowNewSectionModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[90vh] overflow-y-auto" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] flex flex-col p-0 gap-0" showCloseButton>
|
||||
<DialogHeader className="shrink-0 px-6 pt-6 pb-2">
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-[#38bdac]" />
|
||||
新建章节
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex-1 overflow-y-auto min-h-0 px-6 space-y-4 py-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">章节ID *</Label>
|
||||
<Input
|
||||
@@ -933,13 +979,68 @@ export function ContentPage() {
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={newSection.price}
|
||||
onChange={(e) => setNewSection({ ...newSection, price: Number(e.target.value) })}
|
||||
value={newSection.isFree ? 0 : newSection.price}
|
||||
onChange={(e) =>
|
||||
setNewSection({
|
||||
...newSection,
|
||||
price: Number(e.target.value),
|
||||
isFree: Number(e.target.value) === 0,
|
||||
})
|
||||
}
|
||||
disabled={newSection.isFree}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">免费</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newSection.isFree}
|
||||
onChange={(e) =>
|
||||
setNewSection({
|
||||
...newSection,
|
||||
isFree: e.target.checked,
|
||||
price: e.target.checked ? 0 : 1,
|
||||
})
|
||||
}
|
||||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">设为免费</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">最新新增</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newSection.isNew}
|
||||
onChange={(e) => setNewSection({ ...newSection, isNew: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">标记 NEW</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">小程序直推</Label>
|
||||
<div className="flex items-center h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newSection.isPinned}
|
||||
onChange={(e) => setNewSection({ ...newSection, isPinned: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-gray-600 bg-[#0a1628] text-amber-400 focus:ring-amber-400"
|
||||
/>
|
||||
<span className="ml-2 text-gray-400 text-sm">强制置顶到小程序首页</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">文章类型</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 h-10">
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
@@ -962,6 +1063,22 @@ export function ContentPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">热度分</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={newSection.hotScore ?? 0}
|
||||
onChange={(e) =>
|
||||
setNewSection({
|
||||
...newSection,
|
||||
hotScore: Math.max(0, parseFloat(e.target.value) || 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">章节标题 *</Label>
|
||||
@@ -975,7 +1092,17 @@ export function ContentPage() {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">所属篇</Label>
|
||||
<Select value={newSection.partId} onValueChange={(v) => setNewSection({ ...newSection, partId: v, chapterId: 'chapter-1' })}>
|
||||
<Select
|
||||
value={newSection.partId}
|
||||
onValueChange={(v) => {
|
||||
const part = tree.find((p) => p.id === v)
|
||||
setNewSection({
|
||||
...newSection,
|
||||
partId: v,
|
||||
chapterId: part?.chapters[0]?.id ?? 'chapter-1',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -1015,16 +1142,25 @@ export function ContentPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">内容 (Markdown格式)</Label>
|
||||
<Textarea
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[300px] font-mono text-sm placeholder:text-gray-500"
|
||||
placeholder="输入章节内容..."
|
||||
value={newSection.content}
|
||||
onChange={(e) => setNewSection({ ...newSection, content: e.target.value })}
|
||||
<Label className="text-gray-300">内容(富文本编辑器,支持 @链接AI人物 和 #链接标签)</Label>
|
||||
<RichEditor
|
||||
content={newSection.content || ''}
|
||||
onChange={(html) => setNewSection({ ...newSection, content: html })}
|
||||
onImageUpload={async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('folder', 'book-images')
|
||||
const res = await fetch(apiUrl('/api/upload'), { method: 'POST', body: formData, headers: { Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}` } })
|
||||
const data = await res.json()
|
||||
return data?.data?.url || data?.url || ''
|
||||
}}
|
||||
persons={persons}
|
||||
linkTags={linkTags}
|
||||
placeholder="开始编辑内容... 输入 @ 可链接AI人物,工具栏可插入 #链接标签"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="shrink-0 px-6 py-4 border-t border-gray-700/50">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowNewSectionModal(false)}
|
||||
@@ -1960,12 +2096,16 @@ export function ContentPage() {
|
||||
<Label className="text-gray-400 text-xs">标签(身份/角色)</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="如 超级个体" value={newPerson.label} onChange={e => setNewPerson({ ...newPerson, label: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-gray-400 text-xs">存客宝密钥(留空用默认)</Label>
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-48" placeholder="xxxx-xxxx-xxxx-xxxx" value={newPerson.ckbApiKey} onChange={e => setNewPerson({ ...newPerson, ckbApiKey: e.target.value })} />
|
||||
</div>
|
||||
<Button size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white h-8" onClick={async () => {
|
||||
if (!newPerson.name) return alert('名称必填')
|
||||
if (!newPerson.name) { toast.error('名称必填'); return }
|
||||
const payload = { ...newPerson }
|
||||
if (!payload.personId) payload.personId = newPerson.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)
|
||||
await post('/api/db/persons', payload)
|
||||
setNewPerson({ personId: '', name: '', label: '' })
|
||||
setNewPerson({ personId: '', name: '', label: '', ckbApiKey: '' })
|
||||
loadPersons()
|
||||
}}>
|
||||
<Plus className="w-3 h-3 mr-1" />添加
|
||||
@@ -1973,18 +2113,56 @@ export function ContentPage() {
|
||||
</div>
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
{persons.map(p => (
|
||||
<div key={p.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-[#38bdac] font-bold text-base">@{p.name}</span>
|
||||
<span className="text-gray-600 text-xs font-mono">{p.id}</span>
|
||||
{p.label && <Badge variant="secondary" className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">{p.label}</Badge>}
|
||||
<div key={p.id} className="bg-[#0a1628] rounded px-3 py-2 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-[#38bdac] font-bold text-base">@{p.name}</span>
|
||||
<span className="text-gray-600 text-xs font-mono">{p.id}</span>
|
||||
{p.label && <Badge variant="secondary" className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-[10px]">{p.label}</Badge>}
|
||||
{p.ckbApiKey
|
||||
? <Badge variant="secondary" className="bg-green-500/20 text-green-300 border-green-500/30 text-[10px]">密钥 ✓</Badge>
|
||||
: <Badge variant="secondary" className="bg-gray-500/20 text-gray-500 border-gray-500/30 text-[10px]">用默认密钥</Badge>
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-[#38bdac] h-6 px-2" title="编辑密钥" onClick={() => {
|
||||
setEditingPersonKey(p.id)
|
||||
setEditingPersonKeyValue(p.ckbApiKey || '')
|
||||
}}>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
|
||||
await del(`/api/db/persons?personId=${p.id}`)
|
||||
loadPersons()
|
||||
}}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
|
||||
await del(`/api/db/persons?personId=${p.id}`)
|
||||
loadPersons()
|
||||
}}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
{editingPersonKey === p.id && (
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<Input
|
||||
className="bg-[#0d1f35] border-gray-600 text-white h-7 text-xs flex-1"
|
||||
placeholder="输入存客宝密钥,留空则用默认"
|
||||
value={editingPersonKeyValue}
|
||||
onChange={e => setEditingPersonKeyValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') setEditingPersonKey(null)
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white h-7 px-2" onClick={async () => {
|
||||
await post('/api/db/persons', { personId: p.id, name: p.name, label: p.label, ckbApiKey: editingPersonKeyValue })
|
||||
setEditingPersonKey(null)
|
||||
loadPersons()
|
||||
}}>
|
||||
<Check className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="text-gray-400 h-7 px-2" onClick={() => setEditingPersonKey(null)}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{persons.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">暂无AI人物,添加后可在编辑器中 @链接</div>}
|
||||
@@ -2040,7 +2218,7 @@ export function ContentPage() {
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="bg-amber-500 hover:bg-amber-600 text-white h-8" onClick={async () => {
|
||||
if (!newLinkTag.tagId || !newLinkTag.label) return alert('标签ID和显示文字必填')
|
||||
if (!newLinkTag.tagId || !newLinkTag.label) { toast.error('标签ID和显示文字必填'); return }
|
||||
const payload = { ...newLinkTag }
|
||||
if (payload.type === 'miniprogram' && payload.appId) payload.url = `weixin://dl/business/?appid=${payload.appId}&path=${payload.pagePath}`
|
||||
await post('/api/db/link-tags', payload)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
|
||||
@@ -29,6 +29,15 @@ interface UserRow {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface DashboardStatsRes {
|
||||
success?: boolean
|
||||
totalUsers?: number
|
||||
paidOrderCount?: number
|
||||
paidUserCount?: number
|
||||
totalRevenue?: number
|
||||
conversionRate?: number
|
||||
}
|
||||
|
||||
interface DashboardOverviewRes {
|
||||
success?: boolean
|
||||
totalUsers?: number
|
||||
@@ -36,8 +45,6 @@ interface DashboardOverviewRes {
|
||||
paidUserCount?: number
|
||||
totalRevenue?: number
|
||||
conversionRate?: number
|
||||
totalMatches?: number
|
||||
matchRevenue?: number
|
||||
recentOrders?: OrderRow[]
|
||||
newUsers?: UserRow[]
|
||||
}
|
||||
@@ -56,7 +63,9 @@ interface OrdersRes {
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
const [ordersLoading, setOrdersLoading] = useState(true)
|
||||
const [usersLoading, setUsersLoading] = useState(true)
|
||||
const [users, setUsers] = useState<UserRow[]>([])
|
||||
const [purchases, setPurchases] = useState<OrderRow[]>([])
|
||||
const [totalUsersCount, setTotalUsersCount] = useState(0)
|
||||
@@ -67,75 +76,108 @@ export function DashboardPage() {
|
||||
const [detailUserId, setDetailUserId] = useState<string | null>(null)
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
|
||||
async function loadData() {
|
||||
setIsLoading(true)
|
||||
const showError = (err: unknown) => {
|
||||
const e = err as Error & { status?: number; name?: string }
|
||||
if (e?.status === 401) setLoadError('登录已过期,请重新登录')
|
||||
else if (e?.name === 'AbortError') return
|
||||
else setLoadError('加载失败,请检查网络或联系管理员')
|
||||
}
|
||||
|
||||
async function loadAll(signal?: AbortSignal) {
|
||||
const init = signal ? { signal } : undefined
|
||||
|
||||
// 1. 优先加载统计(轻量)
|
||||
setStatsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
try {
|
||||
const data = await get<DashboardOverviewRes>('/api/admin/dashboard/overview')
|
||||
if (data?.success) {
|
||||
setTotalUsersCount(data.totalUsers ?? 0)
|
||||
setPaidOrderCount(data.paidOrderCount ?? 0)
|
||||
setTotalRevenue(data.totalRevenue ?? 0)
|
||||
setConversionRate(data.conversionRate ?? 0)
|
||||
setPurchases(data.recentOrders ?? [])
|
||||
setUsers(data.newUsers ?? [])
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('数据概览接口失败,尝试降级拉取', e)
|
||||
const stats = await get<DashboardStatsRes>('/api/admin/dashboard/stats', init)
|
||||
if (stats?.success) {
|
||||
setTotalUsersCount(stats.totalUsers ?? 0)
|
||||
setPaidOrderCount(stats.paidOrderCount ?? 0)
|
||||
setTotalRevenue(stats.totalRevenue ?? 0)
|
||||
setConversionRate(stats.conversionRate ?? 0)
|
||||
}
|
||||
// 降级:新接口未部署或失败时,用原有接口拉取用户与订单
|
||||
const [usersData, ordersData] = await Promise.all([
|
||||
get<UsersRes>('/api/db/users?page=1&pageSize=10'),
|
||||
get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid'),
|
||||
])
|
||||
const totalUsers = typeof usersData?.total === 'number' ? usersData.total : (usersData?.users?.length ?? 0)
|
||||
const orders = ordersData?.orders ?? []
|
||||
const total = typeof ordersData?.total === 'number' ? ordersData.total : orders.length
|
||||
const paidOrders = orders.filter((p) => p.status === 'paid' || p.status === 'completed' || p.status === 'success')
|
||||
const revenue = paidOrders.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||||
const paidUserIds = new Set(paidOrders.map((p) => p.userId).filter(Boolean))
|
||||
const rate = totalUsers > 0 && paidUserIds.size > 0 ? (paidUserIds.size / totalUsers) * 100 : 0
|
||||
setTotalUsersCount(totalUsers)
|
||||
setPaidOrderCount(total)
|
||||
setTotalRevenue(revenue)
|
||||
setConversionRate(rate)
|
||||
setPurchases(orders.slice(0, 5))
|
||||
setUsers(usersData?.users ?? [])
|
||||
} catch (fallbackErr) {
|
||||
console.error('降级拉取失败', fallbackErr)
|
||||
const err = fallbackErr as Error & { status?: number; name?: string }
|
||||
if (err?.status === 401) {
|
||||
setLoadError('登录已过期,请重新登录')
|
||||
} else if (err?.name === 'AbortError') {
|
||||
setLoadError('请求超时,请检查网络后点击重试')
|
||||
} else {
|
||||
setLoadError('加载失败,请检查网络或联系管理员')
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name !== 'AbortError') {
|
||||
console.error('stats 失败,尝试 overview 降级', e)
|
||||
try {
|
||||
const overview = await get<DashboardOverviewRes>('/api/admin/dashboard/overview', init)
|
||||
if (overview?.success) {
|
||||
setTotalUsersCount(overview.totalUsers ?? 0)
|
||||
setPaidOrderCount(overview.paidOrderCount ?? 0)
|
||||
setTotalRevenue(overview.totalRevenue ?? 0)
|
||||
setConversionRate(overview.conversionRate ?? 0)
|
||||
}
|
||||
} catch (e2) {
|
||||
showError(e2)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setStatsLoading(false)
|
||||
}
|
||||
|
||||
// 2. 并行加载订单和用户
|
||||
setOrdersLoading(true)
|
||||
setUsersLoading(true)
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
const res = await get<{ success?: boolean; recentOrders?: OrderRow[] }>(
|
||||
'/api/admin/dashboard/recent-orders',
|
||||
init
|
||||
)
|
||||
if (res?.success && res.recentOrders) setPurchases(res.recentOrders)
|
||||
else throw new Error('no data')
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name !== 'AbortError') {
|
||||
try {
|
||||
const ordersData = await get<OrdersRes>('/api/orders?page=1&pageSize=20&status=paid', init)
|
||||
const orders = ordersData?.orders ?? []
|
||||
const paid = orders.filter((p) =>
|
||||
['paid', 'completed', 'success'].includes(p.status || '')
|
||||
)
|
||||
setPurchases(paid.slice(0, 5))
|
||||
} catch {
|
||||
setPurchases([])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setOrdersLoading(false)
|
||||
}
|
||||
}
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const res = await get<{ success?: boolean; newUsers?: UserRow[] }>(
|
||||
'/api/admin/dashboard/new-users',
|
||||
init
|
||||
)
|
||||
if (res?.success && res.newUsers) setUsers(res.newUsers)
|
||||
else throw new Error('no data')
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name !== 'AbortError') {
|
||||
try {
|
||||
const usersData = await get<UsersRes>('/api/db/users?page=1&pageSize=10', init)
|
||||
setUsers(usersData?.users ?? [])
|
||||
} catch {
|
||||
setUsers([])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setUsersLoading(false)
|
||||
}
|
||||
}
|
||||
await Promise.all([loadOrders(), loadUsers()])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
const timer = setInterval(loadData, 30000)
|
||||
return () => clearInterval(timer)
|
||||
const ctrl = new AbortController()
|
||||
loadAll(ctrl.signal)
|
||||
const timer = setInterval(() => loadAll(), 30000)
|
||||
return () => {
|
||||
ctrl.abort()
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 w-full">
|
||||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<RefreshCw className="w-12 h-12 text-[#38bdac] animate-spin mb-4" />
|
||||
<span className="text-gray-400">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalUsers = totalUsersCount
|
||||
|
||||
const formatOrderProduct = (p: OrderRow) => {
|
||||
@@ -174,7 +216,7 @@ export function DashboardPage() {
|
||||
const stats = [
|
||||
{
|
||||
title: '总用户数',
|
||||
value: totalUsers,
|
||||
value: statsLoading ? null : totalUsers,
|
||||
icon: Users,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/20',
|
||||
@@ -182,7 +224,7 @@ export function DashboardPage() {
|
||||
},
|
||||
{
|
||||
title: '总收入',
|
||||
value: `¥${(totalRevenue ?? 0).toFixed(2)}`,
|
||||
value: statsLoading ? null : `¥${(totalRevenue ?? 0).toFixed(2)}`,
|
||||
icon: TrendingUp,
|
||||
color: 'text-[#38bdac]',
|
||||
bg: 'bg-[#38bdac]/20',
|
||||
@@ -190,7 +232,7 @@ export function DashboardPage() {
|
||||
},
|
||||
{
|
||||
title: '订单数',
|
||||
value: paidOrderCount,
|
||||
value: statsLoading ? null : paidOrderCount,
|
||||
icon: ShoppingBag,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/20',
|
||||
@@ -198,7 +240,7 @@ export function DashboardPage() {
|
||||
},
|
||||
{
|
||||
title: '转化率',
|
||||
value: `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
|
||||
value: statsLoading ? null : `${typeof conversionRate === 'number' ? conversionRate.toFixed(1) : 0}%`,
|
||||
icon: BookOpen,
|
||||
color: 'text-orange-400',
|
||||
bg: 'bg-orange-500/20',
|
||||
@@ -214,7 +256,7 @@ export function DashboardPage() {
|
||||
<span>{loadError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadData()}
|
||||
onClick={() => loadAll()}
|
||||
className="text-amber-400 hover:text-amber-300 underline"
|
||||
>
|
||||
重试
|
||||
@@ -236,7 +278,16 @@ export function DashboardPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold text-white">{stat.value}</div>
|
||||
<div className="text-2xl font-bold text-white min-h-[2rem] flex items-center">
|
||||
{stat.value != null ? (
|
||||
stat.value
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2 text-gray-500">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
加载中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -250,16 +301,28 @@ export function DashboardPage() {
|
||||
<CardTitle className="text-white">最近订单</CardTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadData()}
|
||||
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1"
|
||||
onClick={() => loadAll()}
|
||||
disabled={ordersLoading || usersLoading}
|
||||
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
{(ordersLoading || usersLoading) ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
刷新(每 30 秒自动更新)
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{ordersLoading && purchases.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
|
||||
<span className="text-sm">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{purchases
|
||||
.slice(0, 5)
|
||||
.map((p) => {
|
||||
@@ -347,12 +410,14 @@ export function DashboardPage() {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{purchases.length === 0 && (
|
||||
{purchases.length === 0 && !ordersLoading && (
|
||||
<div className="text-center py-12">
|
||||
<ShoppingBag className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无订单数据</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -363,6 +428,13 @@ export function DashboardPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{usersLoading && users.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
|
||||
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
|
||||
<span className="text-sm">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{users
|
||||
.slice(0, 5)
|
||||
.map((u) => (
|
||||
@@ -392,9 +464,11 @@ export function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
{users.length === 0 && !usersLoading && (
|
||||
<p className="text-gray-500 text-center py-8">暂无用户数据</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -404,7 +478,7 @@ export function DashboardPage() {
|
||||
open={showDetailModal}
|
||||
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}
|
||||
userId={detailUserId}
|
||||
onUserUpdated={loadData}
|
||||
onUserUpdated={() => loadAll()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Users,
|
||||
TrendingUp,
|
||||
@@ -307,13 +308,13 @@ export function DistributionPage() {
|
||||
)
|
||||
if (!res?.success) {
|
||||
const detail = res?.message || res?.error || '操作失败'
|
||||
alert(detail)
|
||||
toast.error(detail)
|
||||
return
|
||||
}
|
||||
await refreshCurrentTab()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('操作失败')
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,13 +327,13 @@ export function DistributionPage() {
|
||||
{ id, action: 'reject', errorMessage: reason },
|
||||
)
|
||||
if (!res?.success) {
|
||||
alert(res?.error || '操作失败')
|
||||
toast.error(res?.error || '操作失败')
|
||||
return
|
||||
}
|
||||
await refreshCurrentTab()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('操作失败')
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -130,9 +131,9 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
|
||||
value: { routes: routeMap, docNotes, docContent },
|
||||
description: '存客宝接口配置',
|
||||
})
|
||||
alert(res?.success !== false ? '存客宝配置已保存' : `保存失败: ${res?.error || '未知错误'}`)
|
||||
toast.error(res?.success !== false ? '存客宝配置已保存' : `保存失败: ${res?.error || '未知错误'}`)
|
||||
} catch (e) {
|
||||
alert(`保存失败: ${e instanceof Error ? e.message : '网络错误'}`)
|
||||
toast.error(`保存失败: ${e instanceof Error ? e.message : '网络错误'}`)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -144,7 +145,7 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
|
||||
|
||||
const testOne = async (idx: number) => {
|
||||
const t = tests[idx]
|
||||
if (t.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
|
||||
if (t.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { toast.error('请填写测试手机号'); return }
|
||||
const next = [...tests]
|
||||
next[idx] = { ...t, status: 'testing', message: undefined, responseTime: undefined }
|
||||
setTests(next)
|
||||
@@ -169,7 +170,7 @@ export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: Works
|
||||
}
|
||||
|
||||
const testAll = async () => {
|
||||
if (!testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
|
||||
if (!testPhone.trim() && !testWechat.trim()) { toast.error('请填写测试手机号'); return }
|
||||
for (let i = 0; i < tests.length; i++) await testOne(i)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle, CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
@@ -97,8 +98,8 @@ export function MatchPoolTab() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', { key: 'match_config', value: config, description: '匹配功能配置' })
|
||||
alert(res?.success !== false ? '配置保存成功!' : '保存失败: ' + (res?.error || '未知错误'))
|
||||
} catch (e) { console.error(e); alert('保存失败') }
|
||||
toast.error(res?.success !== false ? '配置保存成功!' : '保存失败: ' + (res?.error || '未知错误'))
|
||||
} catch (e) { console.error(e); toast.error('保存失败') }
|
||||
finally { setIsSaving(false) }
|
||||
}
|
||||
|
||||
@@ -113,13 +114,13 @@ export function MatchPoolTab() {
|
||||
setShowTypeModal(true)
|
||||
}
|
||||
const handleSaveType = () => {
|
||||
if (!formData.id || !formData.label) { alert('请填写类型ID和名称'); return }
|
||||
if (!formData.id || !formData.label) { toast.error('请填写类型ID和名称'); return }
|
||||
const newTypes = [...config.matchTypes]
|
||||
if (editingType) {
|
||||
const idx = newTypes.findIndex(t => t.id === editingType.id)
|
||||
if (idx !== -1) newTypes[idx] = { ...formData }
|
||||
} else {
|
||||
if (newTypes.some(t => t.id === formData.id)) { alert('类型ID已存在'); return }
|
||||
if (newTypes.some(t => t.id === formData.id)) { toast.error('类型ID已存在'); return }
|
||||
newTypes.push({ ...formData })
|
||||
}
|
||||
setConfig({ ...config, matchTypes: newTypes })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -40,7 +41,7 @@ export function ResourceDockingTab() {
|
||||
|
||||
const pushToCKB = async (record: MatchRecord) => {
|
||||
if (!record.phone && !record.wechatId) {
|
||||
alert('该记录无联系方式,无法推送到存客宝')
|
||||
toast.info('该记录无联系方式,无法推送到存客宝')
|
||||
return
|
||||
}
|
||||
setPushingId(record.id)
|
||||
@@ -52,9 +53,9 @@ export function ResourceDockingTab() {
|
||||
userId: record.userId,
|
||||
name: record.userNickname || '',
|
||||
})
|
||||
alert(res?.message || (res?.success ? '推送成功' : '推送失败'))
|
||||
toast.error(res?.message || (res?.success ? '推送成功' : '推送失败'))
|
||||
} catch (e) {
|
||||
alert('推送失败: ' + (e instanceof Error ? e.message : '网络错误'))
|
||||
toast.error('推送失败: ' + (e instanceof Error ? e.message : '网络错误'))
|
||||
} finally {
|
||||
setPushingId(null)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
@@ -159,13 +160,13 @@ export function MatchPage() {
|
||||
description: '匹配功能配置',
|
||||
})
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert('配置保存成功!')
|
||||
toast.success('配置保存成功!')
|
||||
} else {
|
||||
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('保存配置失败:', e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -203,7 +204,7 @@ export function MatchPage() {
|
||||
|
||||
const handleSaveType = () => {
|
||||
if (!formData.id || !formData.label) {
|
||||
alert('请填写类型ID和名称')
|
||||
toast.error('请填写类型ID和名称')
|
||||
return
|
||||
}
|
||||
const newTypes = [...config.matchTypes]
|
||||
@@ -212,7 +213,7 @@ export function MatchPage() {
|
||||
if (index !== -1) newTypes[index] = { ...formData }
|
||||
} else {
|
||||
if (newTypes.some((t) => t.id === formData.id)) {
|
||||
alert('类型ID已存在')
|
||||
toast.error('类型ID已存在')
|
||||
return
|
||||
}
|
||||
newTypes.push({ ...formData })
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -88,11 +89,11 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
if (data?.success && data?.url) {
|
||||
setForm((f) => ({ ...f, avatar: data.url }))
|
||||
} else {
|
||||
alert('上传失败: ' + (data?.error || '未知错误'))
|
||||
toast.error('上传失败: ' + (data?.error || '未知错误'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('上传失败')
|
||||
toast.error('上传失败')
|
||||
} finally {
|
||||
setUploadingAvatar(false)
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
@@ -161,7 +162,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
alert('导师姓名不能为空')
|
||||
toast.error('导师姓名不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
@@ -191,7 +192,7 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
setShowModal(false)
|
||||
load()
|
||||
} else {
|
||||
alert('更新失败: ' + (data as { error?: string })?.error)
|
||||
toast.error('更新失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/mentors', payload)
|
||||
@@ -199,12 +200,12 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
setShowModal(false)
|
||||
load()
|
||||
} else {
|
||||
alert('新增失败: ' + (data as { error?: string })?.error)
|
||||
toast.error('新增失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save error:', e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -215,10 +216,10 @@ export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean })
|
||||
try {
|
||||
const data = await del<{ success?: boolean; error?: string }>(`/api/db/mentors?id=${id}`)
|
||||
if (data?.success) load()
|
||||
else alert('删除失败: ' + (data as { error?: string })?.error)
|
||||
else toast.error('删除失败: ' + (data as { error?: string })?.error)
|
||||
} catch (e) {
|
||||
console.error('Delete error:', e)
|
||||
alert('删除失败')
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -167,7 +168,7 @@ export function OrdersPage() {
|
||||
|
||||
function handleExport() {
|
||||
if (purchases.length === 0) {
|
||||
alert('暂无数据可导出')
|
||||
toast.info('暂无数据可导出')
|
||||
return
|
||||
}
|
||||
const headers = ['订单号', '用户', '手机号', '商品', '金额', '支付方式', '状态', '退款原因', '分销佣金', '下单时间']
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -82,10 +83,10 @@ export function PaymentPage() {
|
||||
value: localSettings,
|
||||
description: '支付方式配置',
|
||||
})
|
||||
alert('配置已保存!')
|
||||
toast.success('配置已保存!')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
alert('保存失败: ' + (error instanceof Error ? error.message : String(error)))
|
||||
toast.error('保存失败: ' + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
@@ -68,11 +69,11 @@ export function QRCodesPage() {
|
||||
value: updatedLiveQRCodes,
|
||||
description: '群活码配置',
|
||||
})
|
||||
alert('群活码配置已保存!')
|
||||
toast.success('群活码配置已保存!')
|
||||
await loadConfig()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,17 +90,17 @@ export function QRCodesPage() {
|
||||
},
|
||||
description: '支付方式配置',
|
||||
})
|
||||
alert('微信群链接已保存!用户支付成功后将自动跳转')
|
||||
toast.success('微信群链接已保存!用户支付成功后将自动跳转')
|
||||
await loadConfig()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestJump = () => {
|
||||
if (wechatGroupUrl) window.open(wechatGroupUrl, '_blank')
|
||||
else alert('请先配置微信群链接')
|
||||
else toast.error('请先配置微信群链接')
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -72,15 +73,15 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
|
||||
}
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/admin/referral-settings', body)
|
||||
if (!res || (res as { success?: boolean }).success === false) {
|
||||
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
|
||||
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
|
||||
return
|
||||
}
|
||||
alert(
|
||||
toast.success(
|
||||
'✅ 分销配置已保存成功!\n\n• 小程序与网站的推广规则会一起生效\n• 绑定关系会使用新的天数配置\n• 佣金比例会立即应用到新订单\n\n如有缓存,请刷新前台/小程序页面。',
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
@@ -93,10 +94,10 @@ export function SitePage() {
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
alert('配置已保存')
|
||||
toast.success('配置已保存')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -113,6 +114,7 @@ export function UsersPage() {
|
||||
const initialVipFilter = poolParam === 'vip' ? 'vip' : poolParam === 'complete' ? 'complete' : 'all'
|
||||
const [vipFilter, setVipFilter] = useState<'all' | 'vip' | 'complete'>(initialVipFilter)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isRefreshLoading, setIsRefreshLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [rfmSortMode, setRfmSortMode] = useState(false)
|
||||
const [rfmSortDir, setRfmSortDir] = useState<'desc' | 'asc'>('desc')
|
||||
@@ -154,8 +156,9 @@ export function UsersPage() {
|
||||
const [journeyLoading, setJourneyLoading] = useState(false)
|
||||
|
||||
// ===== 用户列表 =====
|
||||
async function loadUsers() {
|
||||
async function loadUsers(fromRefresh = false) {
|
||||
setIsLoading(true)
|
||||
if (fromRefresh) setIsRefreshLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (rfmSortMode) {
|
||||
@@ -198,6 +201,7 @@ export function UsersPage() {
|
||||
setError('网络错误')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (fromRefresh) setIsRefreshLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,8 +236,8 @@ export function UsersPage() {
|
||||
try {
|
||||
const data = await del<{ success?: boolean; error?: string }>(`/api/db/users?id=${encodeURIComponent(userId)}`)
|
||||
if (data?.success) loadUsers()
|
||||
else alert('删除失败: ' + (data?.error || ''))
|
||||
} catch { alert('删除失败') }
|
||||
else toast.error('删除失败: ' + (data?.error || ''))
|
||||
} catch { toast.error('删除失败') }
|
||||
}
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
@@ -249,19 +253,19 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
async function handleSaveUser() {
|
||||
if (!formData.phone || !formData.nickname) { alert('请填写手机号和昵称'); return }
|
||||
if (!formData.phone || !formData.nickname) { toast.error('请填写手机号和昵称'); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingUser) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser.id, nickname: formData.nickname, isAdmin: formData.isAdmin, hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }) })
|
||||
if (!data?.success) { alert('更新失败: ' + (data?.error || '')); return }
|
||||
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', { phone: formData.phone, nickname: formData.nickname, password: formData.password, isAdmin: formData.isAdmin })
|
||||
if (!data?.success) { alert('创建失败: ' + (data?.error || '')); return }
|
||||
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return }
|
||||
}
|
||||
setShowUserModal(false)
|
||||
loadUsers()
|
||||
} catch { alert('保存失败') } finally { setIsSaving(false) }
|
||||
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
|
||||
}
|
||||
|
||||
async function handleViewReferrals(user: User) {
|
||||
@@ -283,18 +287,18 @@ export function UsersPage() {
|
||||
}, [])
|
||||
|
||||
async function handleSaveRule() {
|
||||
if (!ruleForm.title) { alert('请填写规则标题'); return }
|
||||
if (!ruleForm.title) { toast.error('请填写规则标题'); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingRule) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/user-rules', { id: editingRule.id, ...ruleForm })
|
||||
if (!data?.success) { alert('更新失败: ' + (data?.error || '')); return }
|
||||
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', ruleForm)
|
||||
if (!data?.success) { alert('创建失败: ' + (data?.error || '')); return }
|
||||
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return }
|
||||
}
|
||||
setShowRuleModal(false); loadRules()
|
||||
} catch { alert('保存失败') } finally { setIsSaving(false) }
|
||||
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
|
||||
}
|
||||
|
||||
async function handleDeleteRule(id: number) {
|
||||
@@ -319,18 +323,18 @@ export function UsersPage() {
|
||||
}, [])
|
||||
|
||||
async function handleSaveVipRole() {
|
||||
if (!vipRoleForm.name) { alert('请填写角色名称'); return }
|
||||
if (!vipRoleForm.name) { toast.error('请填写角色名称'); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingVipRole) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', { id: editingVipRole.id, ...vipRoleForm })
|
||||
if (!data?.success) { alert('更新失败'); return }
|
||||
if (!data?.success) { toast.error('更新失败'); return }
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', vipRoleForm)
|
||||
if (!data?.success) { alert('创建失败'); return }
|
||||
if (!data?.success) { toast.error('创建失败'); return }
|
||||
}
|
||||
setShowVipRoleModal(false); loadVipRoles()
|
||||
} catch { alert('保存失败') } finally { setIsSaving(false) }
|
||||
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
|
||||
}
|
||||
|
||||
async function handleDeleteVipRole(id: number) {
|
||||
@@ -387,11 +391,11 @@ export function UsersPage() {
|
||||
<div className="flex items-center gap-3 mb-4 justify-end flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadUsers}
|
||||
disabled={isLoading}
|
||||
onClick={() => loadUsers(true)}
|
||||
disabled={isRefreshLoading}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</Button>
|
||||
<select
|
||||
value={vipFilter}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -70,7 +71,7 @@ export function VipRolesPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) {
|
||||
alert('角色名称不能为空')
|
||||
toast.error('角色名称不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
@@ -85,7 +86,7 @@ export function VipRolesPage() {
|
||||
setShowModal(false)
|
||||
loadRoles()
|
||||
} else {
|
||||
alert('更新失败: ' + (data as { error?: string })?.error)
|
||||
toast.error('更新失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
|
||||
@@ -96,12 +97,12 @@ export function VipRolesPage() {
|
||||
setShowModal(false)
|
||||
loadRoles()
|
||||
} else {
|
||||
alert('新增失败: ' + (data as { error?: string })?.error)
|
||||
toast.error('新增失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save error:', e)
|
||||
alert('保存失败')
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -112,10 +113,10 @@ export function VipRolesPage() {
|
||||
try {
|
||||
const data = await del<{ success?: boolean; error?: string }>(`/api/db/vip-roles?id=${id}`)
|
||||
if (data?.success) loadRoles()
|
||||
else alert('删除失败: ' + (data as { error?: string })?.error)
|
||||
else toast.error('删除失败: ' + (data as { error?: string })?.error)
|
||||
} catch (e) {
|
||||
console.error('Delete error:', e)
|
||||
alert('删除失败')
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
@@ -130,9 +131,9 @@ export function WithdrawalsPage() {
|
||||
{ id, action: 'approve' },
|
||||
)
|
||||
if (data?.success) loadWithdrawals()
|
||||
else alert('操作失败: ' + (data?.error ?? ''))
|
||||
else toast.error('操作失败: ' + (data?.error ?? ''))
|
||||
} catch {
|
||||
alert('操作失败')
|
||||
toast.error('操作失败')
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
@@ -148,9 +149,9 @@ export function WithdrawalsPage() {
|
||||
{ id, action: 'reject', errorMessage: reason },
|
||||
)
|
||||
if (data?.success) loadWithdrawals()
|
||||
else alert('操作失败: ' + (data?.error ?? ''))
|
||||
else toast.error('操作失败: ' + (data?.error ?? ''))
|
||||
} catch {
|
||||
alert('操作失败')
|
||||
toast.error('操作失败')
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
|
||||
93
soul-admin/src/utils/toast.ts
Normal file
93
soul-admin/src/utils/toast.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 轻量 toast 工具,无需第三方库
|
||||
* 用法:toast.success('保存成功') | toast.error('保存失败') | toast.info('...')
|
||||
*/
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info'
|
||||
|
||||
const COLORS: Record<ToastType, { bg: string; border: string; icon: string }> = {
|
||||
success: { bg: '#f0fdf4', border: '#22c55e', icon: '✓' },
|
||||
error: { bg: '#fef2f2', border: '#ef4444', icon: '✕' },
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', icon: 'ℹ' },
|
||||
}
|
||||
|
||||
function show(message: string, type: ToastType = 'info', duration = 3000) {
|
||||
const id = `toast-${Date.now()}`
|
||||
const c = COLORS[type]
|
||||
|
||||
const el = document.createElement('div')
|
||||
el.id = id
|
||||
el.setAttribute('role', 'alert')
|
||||
Object.assign(el.style, {
|
||||
position: 'fixed',
|
||||
top: '24px',
|
||||
right: '24px',
|
||||
zIndex: '9999',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '12px 18px',
|
||||
borderRadius: '10px',
|
||||
background: c.bg,
|
||||
border: `1.5px solid ${c.border}`,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,.12)',
|
||||
fontSize: '14px',
|
||||
color: '#1a1a1a',
|
||||
fontWeight: '500',
|
||||
maxWidth: '380px',
|
||||
lineHeight: '1.5',
|
||||
opacity: '0',
|
||||
transform: 'translateY(-8px)',
|
||||
transition: 'opacity .22s ease, transform .22s ease',
|
||||
pointerEvents:'none',
|
||||
})
|
||||
|
||||
const iconEl = document.createElement('span')
|
||||
Object.assign(iconEl.style, {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
background: c.border,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent:'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
flexShrink: '0',
|
||||
})
|
||||
iconEl.textContent = c.icon
|
||||
|
||||
const textEl = document.createElement('span')
|
||||
textEl.textContent = message
|
||||
|
||||
el.appendChild(iconEl)
|
||||
el.appendChild(textEl)
|
||||
document.body.appendChild(el)
|
||||
|
||||
// 入场动画
|
||||
requestAnimationFrame(() => {
|
||||
el.style.opacity = '1'
|
||||
el.style.transform = 'translateY(0)'
|
||||
})
|
||||
|
||||
// 自动消失
|
||||
const timer = setTimeout(() => dismiss(id), duration)
|
||||
|
||||
function dismiss(elId: string) {
|
||||
clearTimeout(timer)
|
||||
const target = document.getElementById(elId)
|
||||
if (!target) return
|
||||
target.style.opacity = '0'
|
||||
target.style.transform = 'translateY(-8px)'
|
||||
setTimeout(() => target.parentNode?.removeChild(target), 250)
|
||||
}
|
||||
}
|
||||
|
||||
const toast = {
|
||||
success: (msg: string, duration?: number) => show(msg, 'success', duration),
|
||||
error: (msg: string, duration?: number) => show(msg, 'error', duration),
|
||||
info: (msg: string, duration?: number) => show(msg, 'info', duration),
|
||||
}
|
||||
|
||||
export default toast
|
||||
Reference in New Issue
Block a user