221 lines
10 KiB
TypeScript
221 lines
10 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import Link from "next/link"
|
||
import { Search, RefreshCw, CreditCard, ExternalLink, Settings, Package, ChevronRight, Download } from "lucide-react"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Badge } from "@/components/ui/badge"
|
||
|
||
type PointCard = {
|
||
id: string
|
||
game: string
|
||
name: string
|
||
faceValue?: number
|
||
price?: number
|
||
stock?: number
|
||
sales?: number
|
||
deliveryType?: string
|
||
status?: string
|
||
}
|
||
|
||
export default function AdminPointsClient() {
|
||
const [cards, setCards] = useState<PointCard[]>([])
|
||
const [ordersCount, setOrdersCount] = useState(0)
|
||
const [searchTerm, setSearchTerm] = useState("")
|
||
const [loading, setLoading] = useState(true)
|
||
const [syncing, setSyncing] = useState(false)
|
||
const [syncMsg, setSyncMsg] = useState("")
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
}, [])
|
||
|
||
const loadData = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const [cardsRes, ordersRes] = await Promise.all([
|
||
fetch("/api/admin/points/cards"),
|
||
fetch("/api/admin/points/orders"),
|
||
])
|
||
const cardsJson = await cardsRes.json()
|
||
const ordersJson = await ordersRes.json()
|
||
if (cardsJson.success && Array.isArray(cardsJson.data)) setCards(cardsJson.data)
|
||
else setCards([])
|
||
if (ordersJson.success && Array.isArray(ordersJson.data)) setOrdersCount(ordersJson.data.length)
|
||
else setOrdersCount(0)
|
||
} catch (e) {
|
||
console.error(e)
|
||
setCards([])
|
||
setOrdersCount(0)
|
||
}
|
||
setLoading(false)
|
||
}
|
||
|
||
const syncFulu = async () => {
|
||
setSyncing(true)
|
||
setSyncMsg("")
|
||
try {
|
||
const res = await fetch("/api/admin/points/sync-fulu", { method: "POST" })
|
||
const json = await res.json()
|
||
setSyncMsg(json.message ?? json.error ?? (json.success ? "已请求同步" : "失败"))
|
||
if (json.success) loadData()
|
||
} catch (e) {
|
||
setSyncMsg("请求失败")
|
||
}
|
||
setSyncing(false)
|
||
}
|
||
|
||
const filtered = cards.filter(
|
||
(x) =>
|
||
(x.game ?? "").toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
(x.name ?? "").toLowerCase().includes(searchTerm.toLowerCase()),
|
||
)
|
||
|
||
return (
|
||
<div className="p-4 lg:p-6 max-w-[1600px] mx-auto">
|
||
<h1 className="text-xl lg:text-2xl font-bold text-slate-800 mb-6">游戏点卡</h1>
|
||
<p className="text-slate-500 text-sm mb-4">数据来自 MongoDB gamePointCards / pointCardOrders,福禄配置后可从渠道同步</p>
|
||
|
||
<Card className="admin-glass rounded-2xl overflow-hidden mb-6 border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4">
|
||
<div className="flex flex-wrap gap-4 items-center">
|
||
<div className="relative flex-1 min-w-[200px]">
|
||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||
<Input
|
||
placeholder="搜索游戏、商品名..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="bg-white/90 border-slate-200 text-slate-800 pl-10"
|
||
/>
|
||
</div>
|
||
<Button variant="outline" onClick={loadData} className="border-slate-200 text-slate-600 hover:bg-slate-50">
|
||
<RefreshCw className="w-4 h-4 mr-2" />
|
||
刷新
|
||
</Button>
|
||
<Button variant="outline" onClick={syncFulu} disabled={syncing} className="border-amber-200 text-amber-600 hover:bg-amber-50">
|
||
<Download className="w-4 h-4 mr-2" />
|
||
{syncing ? "同步中..." : "从福禄同步"}
|
||
</Button>
|
||
<Link href="/admin/points/channels">
|
||
<Button variant="outline" className="border-slate-200 text-slate-600 hover:bg-slate-50">
|
||
<Settings className="w-4 h-4 mr-2" />
|
||
渠道配置(福禄)
|
||
</Button>
|
||
</Link>
|
||
<Link href="/mall/points" target="_blank" rel="noopener noreferrer">
|
||
<Button variant="outline" className="border-indigo-200 text-indigo-600 hover:bg-indigo-50">
|
||
<ExternalLink className="w-4 h-4 mr-2" />
|
||
前台点卡页
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
{syncMsg && <p className="text-sm text-slate-600 mt-2">{syncMsg}</p>}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-slate-800">{cards.length}</p>
|
||
<p className="text-slate-500 text-sm">点卡商品数</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-sky-600">{ordersCount}</p>
|
||
<p className="text-slate-500 text-sm">点卡订单数</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-emerald-600">{cards.filter((c) => c.status === "active").length}</p>
|
||
<p className="text-slate-500 text-sm">在售</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardContent className="p-4 text-center">
|
||
<p className="text-2xl font-bold text-amber-600">
|
||
{cards.reduce((s, c) => s + (c.sales ?? 0), 0).toLocaleString()}
|
||
</p>
|
||
<p className="text-slate-500 text-sm">总销量</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg">
|
||
<CardHeader>
|
||
<CardTitle className="text-slate-800">点卡商品列表 ({filtered.length})</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{loading ? (
|
||
<div className="text-center py-8 text-slate-500">加载中...</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="text-center py-8 text-slate-500">暂无点卡商品,请执行种子或从福禄同步(配置 FULU_APP_KEY / FULU_APP_SECRET)</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-slate-200">
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">游戏/商品</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">面值</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">售价</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">库存/销量</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">发货</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4">状态</th>
|
||
<th className="text-left text-slate-600 text-sm font-medium py-3 px-4 w-20">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map((c) => (
|
||
<tr key={c.id} className="border-b border-slate-100 hover:bg-slate-50/80">
|
||
<td className="py-3 px-4">
|
||
<div className="flex items-center gap-2">
|
||
<CreditCard className="w-5 h-5 text-slate-500" />
|
||
<div>
|
||
<Link href={`/admin/points/${c.id}`} className="text-slate-800 font-medium hover:text-indigo-600">{c.name}</Link>
|
||
<p className="text-slate-500 text-xs">{c.game}</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="py-3 px-4 text-sky-600">{c.faceValue ?? 0} 面值</td>
|
||
<td className="py-3 px-4 text-amber-600">¥{c.price ?? 0}</td>
|
||
<td className="py-3 px-4 text-slate-600">{c.stock ?? 0} / 销量 {(c.sales ?? 0).toLocaleString()}</td>
|
||
<td className="py-3 px-4">
|
||
<Badge variant="outline" className="border-slate-200 text-slate-600">
|
||
{c.deliveryType === "instant" ? "自动发货" : "人工"}
|
||
</Badge>
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<Badge className={c.status === "active" ? "bg-emerald-100 text-emerald-700" : c.status === "soldout" ? "bg-amber-100 text-amber-700" : "bg-slate-100 text-slate-600"}>
|
||
{c.status === "active" ? "在售" : c.status === "soldout" ? "售罄" : "下架"}
|
||
</Badge>
|
||
</td>
|
||
<td className="py-3 px-4">
|
||
<Link href={`/admin/points/${c.id}`}>
|
||
<Button variant="ghost" size="sm" className="text-slate-500 hover:text-slate-800"><ChevronRight className="w-4 h-4" /></Button>
|
||
</Link>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="admin-glass rounded-2xl overflow-hidden border border-slate-200/80 shadow-lg mt-6">
|
||
<CardHeader>
|
||
<CardTitle className="text-slate-800 text-base">充值渠道说明</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="text-slate-600 text-sm space-y-2">
|
||
<p>· <strong>福禄开放平台</strong>:推荐供货/直充渠道,支持游戏点卡、话费、Q 币等,API 文档 https://docs.open.fulu.com ,配置 FULU_APP_KEY / FULU_APP_SECRET 后可使用「从福禄同步」拉取商品。</p>
|
||
<p>· 点卡订单在「订单管理」中按类型筛选查看;用户下单写入 pointCardOrders。</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|