删除 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

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

460
soul-admin/dist/assets/index-gaoGu1RS.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

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

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>

View File

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