删除 miniprogram2 目录及其所有文件,包括项目配置、样式、图标和自定义组件,简化项目结构,专注于 miniprogram 目录的开发和维护。

This commit is contained in:
Alex-larget
2026-02-25 16:26:13 +08:00
parent 44f995a5a3
commit 04abcb2a87
171 changed files with 3703 additions and 21333 deletions

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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