更新管理端迁移Mycontent-temp的菜单与布局规范,确保主导航收敛并优化隐藏页面入口。新增相关会议记录与文档,反映团队讨论的最新决策与实施建议。

This commit is contained in:
Alex-larget
2026-03-10 18:06:10 +08:00
parent e23eba5d3e
commit aebb533507
82 changed files with 2376 additions and 1126 deletions

View File

@@ -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 />} />

View File

@@ -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;

View File

@@ -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])

View File

@@ -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)
}

View File

@@ -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"
>

View File

@@ -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}

View File

@@ -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"

View File

@@ -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 = ''

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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>
)

View File

@@ -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('操作失败')
}
}

View File

@@ -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)
}

View File

@@ -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 })

View File

@@ -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)
}

View File

@@ -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 })

View File

@@ -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('删除失败')
}
}

View File

@@ -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 = ['订单号', '用户', '手机号', '商品', '金额', '支付方式', '状态', '退款原因', '分销佣金', '下单时间']

View File

@@ -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)
}

View File

@@ -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 (

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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}

View File

@@ -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('删除失败')
}
}

View File

@@ -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)
}

View 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