删除 miniprogram2 目录及其所有文件,包括项目配置、样式、图标和自定义组件,简化项目结构,专注于 miniprogram 目录的开发和维护。
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 对接后端 base URL(不改 API 路径,仅改此处即可切换 Next → Gin)
|
||||
# 宝塔部署:若 API 站点开启了强制 HTTPS,这里必须用 https,否则预检请求会被重定向导致 CORS 报错
|
||||
# VITE_API_BASE_URL=http://localhost:3006
|
||||
# VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_API_BASE_URL=https://souldev.quwanzhi.com
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
# VITE_API_BASE_URL=https://souldev.quwanzhi.com
|
||||
|
||||
|
||||
1
soul-admin/dist/assets/index-2chBMZjx.css
vendored
1
soul-admin/dist/assets/index-2chBMZjx.css
vendored
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-BwYBUNyp.css
vendored
Normal file
1
soul-admin/dist/assets/index-BwYBUNyp.css
vendored
Normal file
File diff suppressed because one or more lines are too long
454
soul-admin/dist/assets/index-ZDapFc7w.js
vendored
454
soul-admin/dist/assets/index-ZDapFc7w.js
vendored
File diff suppressed because one or more lines are too long
460
soul-admin/dist/assets/index-gaoGu1RS.js
vendored
Normal file
460
soul-admin/dist/assets/index-gaoGu1RS.js
vendored
Normal file
File diff suppressed because one or more lines are too long
26
soul-admin/dist/index.html
vendored
26
soul-admin/dist/index.html
vendored
@@ -1,13 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-ZDapFc7w.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-2chBMZjx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-gaoGu1RS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BwYBUNyp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PaymentPage } from './pages/payment/PaymentPage'
|
||||
import { SitePage } from './pages/site/SitePage'
|
||||
import { QRCodesPage } from './pages/qrcodes/QRCodesPage'
|
||||
import { MatchPage } from './pages/match/MatchPage'
|
||||
import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
|
||||
import { NotFoundPage } from './pages/not-found/NotFoundPage'
|
||||
|
||||
function App() {
|
||||
@@ -35,6 +36,7 @@ function App() {
|
||||
<Route path="site" element={<SitePage />} />
|
||||
<Route path="qrcodes" element={<QRCodesPage />} />
|
||||
<Route path="match" element={<MatchPage />} />
|
||||
<Route path="match-records" element={<MatchRecordsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
81
soul-admin/src/components/ui/Pagination.tsx
Normal file
81
soul-admin/src/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange?: (pageSize: number) => void
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50, 100],
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1 && !onPageSizeChange) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-4 px-5 border-t border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span>共 {total} 条</span>
|
||||
{onPageSizeChange && (
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="bg-[#0f2137] border border-gray-600 rounded px-2 py-1 text-gray-300 text-sm"
|
||||
>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} 条/页
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-3 py-1 text-gray-400 text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
soul-admin/src/hooks/useDebounce.ts
Normal file
17
soul-admin/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 防抖 hook,用于搜索等输入场景
|
||||
* @param value 原始值
|
||||
* @param delay 延迟毫秒
|
||||
*/
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LogOut,
|
||||
Wallet,
|
||||
BookOpen,
|
||||
GitMerge,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { clearAdminToken } from '@/api/auth'
|
||||
@@ -17,6 +18,7 @@ const menuItems = [
|
||||
{ icon: BookOpen, label: '内容管理', href: '/content' },
|
||||
{ icon: Users, label: '用户管理', href: '/users' },
|
||||
{ icon: Wallet, label: '交易中心', href: '/distribution' },
|
||||
{ icon: GitMerge, label: '匹配记录', href: '/match-records' },
|
||||
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
|
||||
{ icon: Settings, label: '系统设置', href: '/settings' },
|
||||
]
|
||||
@@ -110,18 +112,6 @@ export function AdminLayout() {
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="text-sm">退出登录</span>
|
||||
</button>
|
||||
<a
|
||||
href={
|
||||
typeof import.meta.env.VITE_VIEW_BASE_URL === 'string' && import.meta.env.VITE_VIEW_BASE_URL
|
||||
? `${import.meta.env.VITE_VIEW_BASE_URL}/view`
|
||||
: `${typeof window !== 'undefined' ? window.location.origin : ''}/view`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 px-4 py-3 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<span className="text-sm">返回前台</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -36,12 +36,14 @@ export function ChaptersPage() {
|
||||
const [structure, setStructure] = useState<Part[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedParts, setExpandedParts] = useState<string[]>([])
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null)
|
||||
const [editPrice, setEditPrice] = useState<number>(1)
|
||||
|
||||
async function loadChapters() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: { structure?: Part[]; stats?: Stats } }>(
|
||||
'/api/admin/chapters',
|
||||
@@ -49,9 +51,12 @@ export function ChaptersPage() {
|
||||
if (data?.success && data.data) {
|
||||
setStructure(data.data.structure ?? [])
|
||||
setStats(data.data.stats ?? null)
|
||||
} else {
|
||||
setError('加载章节失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载章节失败:', e)
|
||||
setError('加载失败,请检查网络后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -115,6 +120,14 @@ export function ChaptersPage() {
|
||||
<div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">章节管理</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadChapters}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white disabled:opacity-50"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedParts(structure.map((p) => p.id))}
|
||||
@@ -134,6 +147,14 @@ export function ChaptersPage() {
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
|
||||
@@ -40,11 +40,12 @@ interface OrdersRes {
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [users, setUsers] = useState<UserRow[]>([])
|
||||
const [purchases, setPurchases] = useState<OrderRow[]>([])
|
||||
|
||||
async function loadData() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [usersData, ordersData] = await Promise.all([
|
||||
get<UsersRes>('/api/db/users'),
|
||||
@@ -54,34 +55,22 @@ export function DashboardPage() {
|
||||
if (ordersData?.success && ordersData.orders) setPurchases(ordersData.orders)
|
||||
} catch (e) {
|
||||
console.error('加载数据失败', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-8 text-white">数据概览</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i} className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="h-4 w-20 bg-gray-700 rounded animate-pulse" />
|
||||
<div className="w-8 h-8 bg-gray-700 rounded-lg animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-16 bg-gray-700 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
<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>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Link2,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -109,19 +110,34 @@ export function DistributionPage() {
|
||||
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [activeTab, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData(activeTab)
|
||||
}, [activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (['orders', 'bindings', 'withdrawals'].includes(activeTab)) {
|
||||
loadTabData(activeTab, true)
|
||||
}
|
||||
}, [page, pageSize, statusFilter, searchTerm])
|
||||
|
||||
async function loadInitialData() {
|
||||
setError(null)
|
||||
try {
|
||||
const overviewData = await get<{ success?: boolean; overview?: DistributionOverview }>(
|
||||
'/api/admin/distribution/overview',
|
||||
@@ -129,6 +145,7 @@ export function DistributionPage() {
|
||||
if (overviewData?.success && overviewData.overview) setOverview(overviewData.overview)
|
||||
} catch (e) {
|
||||
console.error('[Admin] 概览接口异常:', e)
|
||||
setError('加载概览失败')
|
||||
}
|
||||
try {
|
||||
const usersData = await get<{ success?: boolean; users?: User[] }>('/api/db/users')
|
||||
@@ -148,7 +165,13 @@ export function DistributionPage() {
|
||||
break
|
||||
case 'orders': {
|
||||
try {
|
||||
const ordersData = await get<{ success?: boolean; orders?: Order[] }>('/api/orders')
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
...(statusFilter !== 'all' && { status: statusFilter }),
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
})
|
||||
const ordersData = await get<{ success?: boolean; orders?: Order[]; total?: number }>(`/api/orders?${params}`)
|
||||
if (ordersData?.success && ordersData.orders) {
|
||||
const enriched = ordersData.orders.map((order) => {
|
||||
const user = usersArr.find((u) => u.id === order.userId)
|
||||
@@ -166,30 +189,51 @@ export function DistributionPage() {
|
||||
}
|
||||
})
|
||||
setOrders(enriched)
|
||||
} else setOrders([])
|
||||
} catch {
|
||||
setTotal(ordersData.total ?? enriched.length)
|
||||
} else {
|
||||
setOrders([])
|
||||
setTotal(0)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError('加载订单失败')
|
||||
setOrders([])
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'bindings': {
|
||||
try {
|
||||
const bindingsData = await get<{ success?: boolean; bindings?: Binding[] }>(
|
||||
'/api/db/distribution',
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
...(statusFilter !== 'all' && { status: statusFilter }),
|
||||
})
|
||||
const bindingsData = await get<{ success?: boolean; bindings?: Binding[]; total?: number }>(
|
||||
`/api/db/distribution?${params}`,
|
||||
)
|
||||
setBindings(bindingsData?.bindings || [])
|
||||
} catch {
|
||||
setTotal(bindingsData?.total ?? bindingsData?.bindings?.length ?? 0)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setError('加载绑定数据失败')
|
||||
setBindings([])
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'withdrawals': {
|
||||
try {
|
||||
const statusParam = statusFilter === 'completed' ? 'success' : statusFilter === 'rejected' ? 'failed' : statusFilter
|
||||
const params = new URLSearchParams({
|
||||
...(statusParam && statusParam !== 'all' && { status: statusParam }),
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
})
|
||||
const withdrawalsData = await get<{
|
||||
success?: boolean
|
||||
withdrawals?: Withdrawal[]
|
||||
total?: number
|
||||
error?: string
|
||||
}>('/api/admin/withdrawals')
|
||||
}>(`/api/admin/withdrawals?${params}`)
|
||||
if (withdrawalsData?.success && withdrawalsData.withdrawals) {
|
||||
const formatted = withdrawalsData.withdrawals.map((w) => ({
|
||||
...w,
|
||||
@@ -198,16 +242,15 @@ export function DistributionPage() {
|
||||
w.status === 'success' ? 'completed' : w.status === 'failed' ? 'rejected' : w.status,
|
||||
}))
|
||||
setWithdrawals(formatted)
|
||||
setTotal(withdrawalsData?.total ?? formatted.length)
|
||||
} else {
|
||||
if (!withdrawalsData?.success)
|
||||
alert(
|
||||
`获取提现记录失败: ${(withdrawalsData as { error?: string })?.error || '未知错误'}`,
|
||||
)
|
||||
setError(`获取提现记录失败: ${(withdrawalsData as { error?: string })?.error || '未知错误'}`)
|
||||
setWithdrawals([])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('加载提现数据失败')
|
||||
setError('加载提现数据失败')
|
||||
setWithdrawals([])
|
||||
}
|
||||
break
|
||||
@@ -222,6 +265,7 @@ export function DistributionPage() {
|
||||
}
|
||||
|
||||
async function refreshCurrentTab() {
|
||||
setError(null)
|
||||
setLoadedTabs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(activeTab)
|
||||
@@ -299,51 +343,36 @@ export function DistributionPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const filteredBindings = bindings.filter((b) => {
|
||||
if (statusFilter !== 'all' && b.status !== statusFilter) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
b.refereeNickname?.toLowerCase().includes(term) ||
|
||||
b.refereePhone?.includes(term) ||
|
||||
b.referrerName?.toLowerCase().includes(term) ||
|
||||
b.referrerCode?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
return true
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
const displayOrders = orders
|
||||
const displayBindings = bindings.filter((b) => {
|
||||
if (!searchTerm) return true
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
b.refereeNickname?.toLowerCase().includes(term) ||
|
||||
b.refereePhone?.includes(term) ||
|
||||
b.referrerName?.toLowerCase().includes(term) ||
|
||||
b.referrerCode?.toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
|
||||
const filteredWithdrawals = withdrawals.filter((w) => {
|
||||
if (statusFilter !== 'all' && w.status !== statusFilter) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
w.userName?.toLowerCase().includes(term) || w.account?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const filteredOrders = orders.filter((order) => {
|
||||
if (statusFilter !== 'all' && order.status !== statusFilter) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
order.id?.toLowerCase().includes(term) ||
|
||||
order.userNickname?.toLowerCase().includes(term) ||
|
||||
order.userPhone?.includes(term) ||
|
||||
order.sectionTitle?.toLowerCase().includes(term) ||
|
||||
order.chapterTitle?.toLowerCase().includes(term) ||
|
||||
order.bookName?.toLowerCase().includes(term) ||
|
||||
(order.referrerCode && order.referrerCode.toLowerCase().includes(term)) ||
|
||||
(order.referrerNickname && order.referrerNickname.toLowerCase().includes(term))
|
||||
)
|
||||
}
|
||||
return true
|
||||
const displayWithdrawals = withdrawals.filter((w) => {
|
||||
if (!searchTerm) return true
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
w.userName?.toLowerCase().includes(term) || (w.account && w.account.toLowerCase().includes(term))
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">交易中心</h1>
|
||||
@@ -641,7 +670,7 @@ export function DistributionPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredOrders.map((order) => (
|
||||
{displayOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4 font-mono text-xs text-gray-400">
|
||||
{order.id?.slice(0, 12)}...
|
||||
@@ -735,6 +764,19 @@ export function DistributionPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'orders' && (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -765,7 +807,7 @@ export function DistributionPage() {
|
||||
</div>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{filteredBindings.length === 0 ? (
|
||||
{displayBindings.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无绑定数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -781,7 +823,7 @@ export function DistributionPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredBindings.map((binding) => (
|
||||
{displayBindings.map((binding) => (
|
||||
<tr key={binding.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4">
|
||||
<div>
|
||||
@@ -825,6 +867,19 @@ export function DistributionPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'bindings' && (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -855,7 +910,7 @@ export function DistributionPage() {
|
||||
</div>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{filteredWithdrawals.length === 0 ? (
|
||||
{displayWithdrawals.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无提现记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -872,7 +927,7 @@ export function DistributionPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredWithdrawals.map((withdrawal) => (
|
||||
{displayWithdrawals.map((withdrawal) => (
|
||||
<tr key={withdrawal.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -951,6 +1006,19 @@ export function DistributionPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'withdrawals' && (
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
234
soul-admin/src/pages/match-records/MatchRecordsPage.tsx
Normal file
234
soul-admin/src/pages/match-records/MatchRecordsPage.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface MatchRecord {
|
||||
id: string
|
||||
userId: string
|
||||
matchedUserId: string
|
||||
matchType: string
|
||||
phone?: string
|
||||
wechatId?: string
|
||||
userNickname?: string
|
||||
matchedNickname?: string
|
||||
userAvatar?: string
|
||||
matchedUserAvatar?: string
|
||||
matchScore?: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const matchTypeLabels: Record<string, string> = {
|
||||
partner: '找伙伴',
|
||||
investor: '资源对接',
|
||||
mentor: '导师顾问',
|
||||
team: '团队招募',
|
||||
}
|
||||
|
||||
export function MatchRecordsPage() {
|
||||
const [records, setRecords] = useState<MatchRecord[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [matchTypeFilter, setMatchTypeFilter] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function loadRecords() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||
if (matchTypeFilter) params.set('matchType', matchTypeFilter)
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
records?: MatchRecord[]
|
||||
total?: number
|
||||
}>(`/api/db/match-records?${params}`)
|
||||
if (data?.success) {
|
||||
setRecords(data.records || [])
|
||||
setTotal(data.total ?? 0)
|
||||
} else {
|
||||
setError('加载匹配记录失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载匹配记录失败', e)
|
||||
setError('加载失败,请检查网络后重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRecords()
|
||||
}, [page, matchTypeFilter])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">匹配记录</h2>
|
||||
<p className="text-gray-400 mt-1">找伙伴匹配统计,共 {total} 条记录</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={matchTypeFilter}
|
||||
onChange={(e) => {
|
||||
setMatchTypeFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(matchTypeLabels).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadRecords}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">发起人</TableHead>
|
||||
<TableHead className="text-gray-400">匹配到</TableHead>
|
||||
<TableHead className="text-gray-400">类型</TableHead>
|
||||
<TableHead className="text-gray-400">联系方式</TableHead>
|
||||
<TableHead className="text-gray-400">匹配时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((r) => (
|
||||
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
|
||||
{r.userAvatar ? (
|
||||
<img
|
||||
src={r.userAvatar}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement
|
||||
if (fallback) fallback.classList.remove('hidden')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={r.userAvatar ? 'hidden' : ''}>
|
||||
{(r.userNickname || r.userId || '?').charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white">{r.userNickname || r.userId}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{r.userId.slice(0, 16)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 overflow-hidden">
|
||||
{r.matchedUserAvatar ? (
|
||||
<img
|
||||
src={r.matchedUserAvatar}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
const fallback = e.currentTarget.nextElementSibling as HTMLElement
|
||||
if (fallback) fallback.classList.remove('hidden')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={r.matchedUserAvatar ? 'hidden' : ''}>
|
||||
{(r.matchedNickname || r.matchedUserId || '?').charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white">{r.matchedNickname || r.matchedUserId}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{r.matchedUserId.slice(0, 16)}...</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">
|
||||
{matchTypeLabels[r.matchType] || r.matchType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400 text-sm">
|
||||
{r.phone && <div>📱 {r.phone}</div>}
|
||||
{r.wechatId && <div>💬 {r.wechatId}</div>}
|
||||
{!r.phone && !r.wechatId && '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">
|
||||
{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{records.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
|
||||
暂无匹配记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, RefreshCw, Download, Filter } from 'lucide-react'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface Purchase {
|
||||
id: string
|
||||
userId: string
|
||||
type?: 'section' | 'fullbook' | 'match'
|
||||
type?: 'section' | 'fullbook' | 'match' | 'vip'
|
||||
sectionId?: string
|
||||
sectionTitle?: string
|
||||
productId?: string
|
||||
@@ -41,29 +43,54 @@ interface UsersItem {
|
||||
export function OrdersPage() {
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([])
|
||||
const [users, setUsers] = useState<UsersItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [totalRevenue, setTotalRevenue] = useState(0)
|
||||
const [todayRevenue, setTodayRevenue] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debouncedSearch = useDebounce(searchTerm, 300)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function loadOrders() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const statusParam = statusFilter === 'all' ? '' : statusFilter === 'completed' ? 'completed' : statusFilter
|
||||
const ordersParams = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
...(statusParam && { status: statusParam }),
|
||||
...(debouncedSearch && { search: debouncedSearch }),
|
||||
})
|
||||
const [ordersData, usersData] = await Promise.all([
|
||||
get<{ success?: boolean; orders?: Purchase[] }>('/api/orders'),
|
||||
get<{ success?: boolean; users?: UsersItem[] }>('/api/db/users'),
|
||||
get<{ success?: boolean; orders?: Purchase[]; total?: number; totalRevenue?: number; todayRevenue?: number }>(`/api/orders?${ordersParams}`),
|
||||
get<{ success?: boolean; users?: UsersItem[] }>('/api/db/users?page=1&pageSize=500'),
|
||||
])
|
||||
if (ordersData?.success && ordersData.orders) setPurchases(ordersData.orders)
|
||||
if (ordersData?.success) {
|
||||
setPurchases(ordersData.orders || [])
|
||||
setTotal(ordersData.total ?? 0)
|
||||
setTotalRevenue(ordersData.totalRevenue ?? 0)
|
||||
setTodayRevenue(ordersData.todayRevenue ?? 0)
|
||||
}
|
||||
if (usersData?.success && usersData.users) setUsers(usersData.users)
|
||||
} catch (e) {
|
||||
console.error('加载订单失败', e)
|
||||
setError('加载订单失败,请检查网络后重试')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [debouncedSearch, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders()
|
||||
}, [])
|
||||
}, [page, pageSize, debouncedSearch, statusFilter])
|
||||
|
||||
const getUserNickname = (order: Purchase) =>
|
||||
order.userNickname || users.find((u) => u.id === order.userId)?.nickname || '匿名用户'
|
||||
@@ -86,6 +113,9 @@ export function OrdersPage() {
|
||||
if (type === 'fullbook' || desc.includes('全书')) {
|
||||
return { name: '《一场Soul的创业实验》', type: '全书购买' }
|
||||
}
|
||||
if (type === 'vip' || desc.includes('VIP')) {
|
||||
return { name: 'VIP年度会员', type: 'VIP' }
|
||||
}
|
||||
if (type === 'match' || desc.includes('伙伴')) {
|
||||
return { name: '找伙伴匹配', type: '功能服务' }
|
||||
}
|
||||
@@ -93,46 +123,68 @@ export function OrdersPage() {
|
||||
}
|
||||
if (type === 'section') return { name: `章节 ${order.productId || order.sectionId || ''}`, type: '单章' }
|
||||
if (type === 'fullbook') return { name: '《一场Soul的创业实验》', type: '全书' }
|
||||
if (type === 'vip') return { name: 'VIP年度会员', type: 'VIP' }
|
||||
if (type === 'match') return { name: '找伙伴匹配', type: '功能' }
|
||||
return { name: '未知商品', type: type || '其他' }
|
||||
}
|
||||
|
||||
const filteredPurchases = purchases.filter((p) => {
|
||||
const product = formatProduct(p)
|
||||
const matchSearch =
|
||||
getUserNickname(p).includes(searchTerm) ||
|
||||
getUserPhone(p.userId).includes(searchTerm) ||
|
||||
product.name.includes(searchTerm) ||
|
||||
(p.orderSn && p.orderSn.includes(searchTerm)) ||
|
||||
(p.id && p.id.includes(searchTerm))
|
||||
const matchStatus =
|
||||
statusFilter === 'all' ||
|
||||
p.status === statusFilter ||
|
||||
(statusFilter === 'completed' && p.status === 'paid')
|
||||
return matchSearch && matchStatus
|
||||
})
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
|
||||
const totalRevenue = purchases
|
||||
.filter((p) => p.status === 'paid' || p.status === 'completed')
|
||||
.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||||
const todayRevenue = purchases
|
||||
.filter((p) => {
|
||||
const today = new Date().toDateString()
|
||||
return (
|
||||
(p.status === 'paid' || p.status === 'completed') &&
|
||||
new Date(p.createdAt).toDateString() === today
|
||||
)
|
||||
function handleExport() {
|
||||
if (purchases.length === 0) {
|
||||
alert('暂无数据可导出')
|
||||
return
|
||||
}
|
||||
const headers = ['订单号', '用户', '手机号', '商品', '金额', '支付方式', '状态', '分销佣金', '下单时间']
|
||||
const rows = purchases.map((p) => {
|
||||
const product = formatProduct(p)
|
||||
return [
|
||||
p.orderSn || p.id || '',
|
||||
getUserNickname(p),
|
||||
getUserPhone(p.userId),
|
||||
product.name,
|
||||
Number(p.amount || 0).toFixed(2),
|
||||
p.paymentMethod === 'wechat' ? '微信支付' : p.paymentMethod === 'alipay' ? '支付宝' : p.paymentMethod || '微信支付',
|
||||
p.status === 'paid' || p.status === 'completed' ? '已完成' : p.status === 'pending' || p.status === 'created' ? '待支付' : '已失败',
|
||||
p.referrerEarnings ? Number(p.referrerEarnings).toFixed(2) : '-',
|
||||
p.createdAt ? new Date(p.createdAt).toLocaleString('zh-CN') : '',
|
||||
].join(',')
|
||||
})
|
||||
.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||||
const csv = '\uFEFF' + [headers.join(','), ...rows].join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `订单列表_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">订单管理</h2>
|
||||
<p className="text-gray-400 mt-1">共 {purchases.length} 笔订单</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadOrders}
|
||||
disabled={isLoading}
|
||||
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' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-gray-400">总收入:</span>
|
||||
<span className="text-[#38bdac] font-bold">¥{totalRevenue.toFixed(2)}</span>
|
||||
@@ -170,10 +222,12 @@ export function OrdersPage() {
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={purchases.length === 0}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出
|
||||
导出 CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -185,6 +239,7 @@ export function OrdersPage() {
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
@@ -199,7 +254,7 @@ export function OrdersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPurchases.map((purchase) => {
|
||||
{purchases.map((purchase) => {
|
||||
const product = formatProduct(purchase)
|
||||
return (
|
||||
<TableRow key={purchase.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
@@ -214,7 +269,14 @@ export function OrdersPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-white text-sm">{product.name}</p>
|
||||
<p className="text-white text-sm flex items-center gap-2">
|
||||
{product.name}
|
||||
{(purchase.productType || purchase.type) === 'vip' && (
|
||||
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0 text-xs">
|
||||
VIP
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">{product.type}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -254,7 +316,7 @@ export function OrdersPage() {
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{filteredPurchases.length === 0 && (
|
||||
{purchases.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
|
||||
暂无订单数据
|
||||
@@ -263,6 +325,18 @@ export function OrdersPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -77,8 +77,11 @@ export function PaymentPage() {
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 保存到后端
|
||||
await post('/api/config', { paymentMethods: localSettings })
|
||||
await post('/api/db/config', {
|
||||
key: 'payment_methods',
|
||||
value: localSettings,
|
||||
description: '支付方式配置',
|
||||
})
|
||||
alert('配置已保存!')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
|
||||
@@ -63,7 +63,11 @@ export function QRCodesPage() {
|
||||
} else {
|
||||
updatedLiveQRCodes.push({ id: 'live-1', name: '微信群活码', urls, clickCount: 0 })
|
||||
}
|
||||
await post('/api/config', { liveQRCodes: updatedLiveQRCodes })
|
||||
await post('/api/db/config', {
|
||||
key: 'live_qr_codes',
|
||||
value: updatedLiveQRCodes,
|
||||
description: '群活码配置',
|
||||
})
|
||||
alert('群活码配置已保存!')
|
||||
await loadConfig()
|
||||
} catch (e) {
|
||||
@@ -74,14 +78,16 @@ export function QRCodesPage() {
|
||||
|
||||
const handleSaveWechatGroup = async () => {
|
||||
try {
|
||||
await post('/api/config', {
|
||||
paymentMethods: {
|
||||
await post('/api/db/config', {
|
||||
key: 'payment_methods',
|
||||
value: {
|
||||
...(config.paymentMethods || {}),
|
||||
wechat: {
|
||||
...(config.paymentMethods?.wechat || {}),
|
||||
groupQrCode: wechatGroupUrl,
|
||||
},
|
||||
},
|
||||
description: '支付方式配置',
|
||||
})
|
||||
alert('微信群链接已保存!用户支付成功后将自动跳转')
|
||||
await loadConfig()
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Gift,
|
||||
X,
|
||||
Plus,
|
||||
Smartphone,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
@@ -57,6 +58,20 @@ interface FeatureConfig {
|
||||
aboutEnabled: boolean
|
||||
}
|
||||
|
||||
interface MpConfig {
|
||||
appId?: string
|
||||
withdrawSubscribeTmplId?: string
|
||||
mchId?: string
|
||||
minWithdraw?: number
|
||||
}
|
||||
|
||||
const defaultMpConfig: MpConfig = {
|
||||
appId: 'wxb8bbb2b10dec74aa',
|
||||
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
|
||||
mchId: '1318592501',
|
||||
minWithdraw: 10,
|
||||
}
|
||||
|
||||
const defaultAuthor: AuthorInfo = {
|
||||
name: '卡若',
|
||||
startDate: '2025年10月15日',
|
||||
@@ -92,6 +107,7 @@ export function SettingsPage() {
|
||||
])
|
||||
const [newFreeChapter, setNewFreeChapter] = useState('')
|
||||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@@ -115,11 +131,14 @@ export function SettingsPage() {
|
||||
freeChapters?: string[]
|
||||
featureConfig?: Partial<FeatureConfig>
|
||||
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo }
|
||||
mpConfig?: Partial<MpConfig>
|
||||
}>('/api/admin/settings')
|
||||
if (!res || (res as { success?: boolean }).success === false) return
|
||||
if (Array.isArray(res.freeChapters) && res.freeChapters.length) setFreeChapters(res.freeChapters)
|
||||
if (res.featureConfig && Object.keys(res.featureConfig).length)
|
||||
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
|
||||
if (res.mpConfig && typeof res.mpConfig === 'object')
|
||||
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
|
||||
if (res.siteSettings && typeof res.siteSettings === 'object') {
|
||||
const s = res.siteSettings
|
||||
setLocalSettings((prev) => ({
|
||||
@@ -182,6 +201,13 @@ export function SettingsPage() {
|
||||
distributorShare: localSettings.distributorShare,
|
||||
authorInfo: localSettings.authorInfo,
|
||||
},
|
||||
mpConfig: {
|
||||
...mpConfig,
|
||||
appId: mpConfig.appId || '',
|
||||
withdrawSubscribeTmplId: mpConfig.withdrawSubscribeTmplId || '',
|
||||
mchId: mpConfig.mchId || '',
|
||||
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
|
||||
},
|
||||
})
|
||||
if (!res || (res as { success?: boolean }).success === false) {
|
||||
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
|
||||
@@ -409,6 +435,69 @@ export function SettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5 text-[#38bdac]" />
|
||||
小程序配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
订阅消息模板、支付商户号等,小程序从 /api/miniprogram/config 读取(API 地址由 app.js baseUrl 控制)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">小程序 AppID</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="wxb8bbb2b10dec74aa"
|
||||
value={mpConfig.appId ?? ''}
|
||||
onChange={(e) =>
|
||||
setMpConfig((prev) => ({ ...prev, appId: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">提现订阅模板 ID</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="用户申请提现时需授权"
|
||||
value={mpConfig.withdrawSubscribeTmplId ?? ''}
|
||||
onChange={(e) =>
|
||||
setMpConfig((prev) => ({ ...prev, withdrawSubscribeTmplId: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">微信支付商户号</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="1318592501"
|
||||
value={mpConfig.mchId ?? ''}
|
||||
onChange={(e) =>
|
||||
setMpConfig((prev) => ({ ...prev, mchId: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">最低提现金额 (元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={mpConfig.minWithdraw ?? 10}
|
||||
onChange={(e) =>
|
||||
setMpConfig((prev) => ({
|
||||
...prev,
|
||||
minWithdraw: Number.parseFloat(e.target.value) || 10,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Save, Globe, Palette, Menu, FileText } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const defaultSiteConfig = {
|
||||
siteName: '卡若日记',
|
||||
@@ -45,6 +45,7 @@ export function SitePage() {
|
||||
pageConfig: { ...defaultPageConfig },
|
||||
})
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
get<{
|
||||
@@ -72,10 +73,33 @@ export function SitePage() {
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
alert('配置已保存(当前为前端状态,后端可对接 /api/db/config 持久化)')
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await post('/api/db/config', {
|
||||
key: 'site_config',
|
||||
value: localSettings.siteConfig,
|
||||
description: '网站基础配置',
|
||||
})
|
||||
await post('/api/db/config', {
|
||||
key: 'menu_config',
|
||||
value: localSettings.menuConfig,
|
||||
description: '底部菜单配置',
|
||||
})
|
||||
await post('/api/db/config', {
|
||||
key: 'page_config',
|
||||
value: localSettings.pageConfig,
|
||||
description: '页面标题配置',
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
alert('配置已保存')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sc = localSettings.siteConfig
|
||||
@@ -91,10 +115,11 @@ export function SitePage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={`${saved ? 'bg-green-500' : 'bg-[#00CED1]'} hover:bg-[#20B2AA] text-white transition-colors`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saved ? '已保存' : '保存设置'}
|
||||
{saving ? '保存中...' : saved ? '已保存' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import { get, del, post, put } from '@/api/client'
|
||||
|
||||
interface User {
|
||||
@@ -55,9 +57,14 @@ interface User {
|
||||
|
||||
export function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const debouncedSearch = useDebounce(searchTerm, 300)
|
||||
const [vipFilter, setVipFilter] = useState<'all' | 'vip'>('all')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
@@ -85,9 +92,24 @@ export function UsersPage() {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; users?: User[]; error?: string }>('/api/db/users')
|
||||
if (data?.success) setUsers(data.users || [])
|
||||
else setError(data?.error || '加载失败')
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
search: debouncedSearch,
|
||||
...(vipFilter === 'vip' && { vip: 'true' }),
|
||||
})
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
users?: User[]
|
||||
total?: number
|
||||
error?: string
|
||||
}>(`/api/db/users?${params}`)
|
||||
if (data?.success) {
|
||||
setUsers(data.users || [])
|
||||
setTotal(data.total ?? 0)
|
||||
} else {
|
||||
setError(data?.error || '加载失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Load users error:', err)
|
||||
setError('网络错误,请检查连接')
|
||||
@@ -97,14 +119,14 @@ export function UsersPage() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
setPage(1)
|
||||
}, [debouncedSearch, vipFilter])
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
(u.nickname || '').includes(searchTerm) ||
|
||||
(u.phone || '').includes(searchTerm),
|
||||
)
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [page, pageSize, debouncedSearch, vipFilter])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
|
||||
async function handleDelete(userId: string) {
|
||||
if (!confirm('确定要删除这个用户吗?')) return
|
||||
@@ -250,10 +272,21 @@ export function UsersPage() {
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">用户管理</h2>
|
||||
<p className="text-gray-400 mt-1">共 {users.length} 位注册用户</p>
|
||||
<p className="text-gray-400 mt-1">
|
||||
共 {total} 位注册用户
|
||||
{vipFilter === 'vip' && `,当前筛选 VIP`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
@@ -265,6 +298,17 @@ export function UsersPage() {
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
<select
|
||||
value={vipFilter}
|
||||
onChange={(e) => {
|
||||
setVipFilter(e.target.value as 'all' | 'vip')
|
||||
setPage(1)
|
||||
}}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">全部用户</option>
|
||||
<option value="vip">VIP会员</option>
|
||||
</select>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
@@ -532,6 +576,7 @@ export function UsersPage() {
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
@@ -545,7 +590,7 @@ export function UsersPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -605,8 +650,8 @@ export function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.hasFullBook ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||||
全书已购
|
||||
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0">
|
||||
VIP
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-600">
|
||||
@@ -686,7 +731,7 @@ export function UsersPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
|
||||
暂无用户数据
|
||||
@@ -695,6 +740,18 @@ export function UsersPage() {
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Check, X, RefreshCw, Wallet, DollarSign } from 'lucide-react'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import { get, put } from '@/api/client'
|
||||
|
||||
interface Withdrawal {
|
||||
@@ -51,40 +52,61 @@ export function WithdrawalsPage() {
|
||||
failedCount: 0,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'success' | 'failed'>('all')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [processing, setProcessing] = useState<string | null>(null)
|
||||
|
||||
async function loadWithdrawals() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
status: filter,
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
})
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
withdrawals?: Withdrawal[]
|
||||
stats?: Partial<Stats>
|
||||
}>(`/api/admin/withdrawals?status=${filter}`)
|
||||
total?: number
|
||||
}>(`/api/admin/withdrawals?${params}`)
|
||||
if (data?.success) {
|
||||
const list = data.withdrawals || []
|
||||
setWithdrawals(list)
|
||||
setTotal(data.total ?? data.stats?.total ?? list.length)
|
||||
setStats({
|
||||
total: data.stats?.total ?? list.length,
|
||||
total: data.stats?.total ?? data.total ?? list.length,
|
||||
pendingCount: data.stats?.pendingCount ?? 0,
|
||||
pendingAmount: data.stats?.pendingAmount ?? 0,
|
||||
successCount: data.stats?.successCount ?? 0,
|
||||
successAmount: data.stats?.successAmount ?? 0,
|
||||
failedCount: data.stats?.failedCount ?? 0,
|
||||
})
|
||||
} else {
|
||||
setError('加载提现记录失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load withdrawals error:', error)
|
||||
} catch (err) {
|
||||
console.error('Load withdrawals error:', err)
|
||||
setError('加载失败,请检查网络后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadWithdrawals()
|
||||
setPage(1)
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
loadWithdrawals()
|
||||
}, [filter, page, pageSize])
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize) || 1
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
const withdrawal = withdrawals.find((w) => w.id === id)
|
||||
if (
|
||||
@@ -177,6 +199,14 @@ export function WithdrawalsPage() {
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-6xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">分账提现管理</h1>
|
||||
@@ -291,6 +321,7 @@ export function WithdrawalsPage() {
|
||||
<p className="text-gray-500">暂无提现记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
@@ -431,6 +462,18 @@ export function WithdrawalsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(n) => {
|
||||
setPageSize(n)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/auth.ts","./src/api/client.ts","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/select.tsx","./src/components/ui/slider.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/chapters/chapterspage.tsx","./src/pages/content/contentpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/not-found/notfoundpage.tsx","./src/pages/orders/orderspage.tsx","./src/pages/payment/paymentpage.tsx","./src/pages/qrcodes/qrcodespage.tsx","./src/pages/referral-settings/referralsettingspage.tsx","./src/pages/settings/settingspage.tsx","./src/pages/site/sitepage.tsx","./src/pages/users/userspage.tsx","./src/pages/withdrawals/withdrawalspage.tsx"],"version":"5.6.3"}
|
||||
Reference in New Issue
Block a user