feat: 定合并的稳定版本

This commit is contained in:
Alex-larget
2026-03-17 13:17:49 +08:00
parent fcc05b6420
commit 2f35520670
44 changed files with 2754 additions and 859 deletions

View File

@@ -19,6 +19,7 @@ import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { FindPartnerPage } from './pages/find-partner/FindPartnerPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { ApiDocsPage } from './pages/api-docs/ApiDocsPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
function App() {
@@ -47,6 +48,7 @@ function App() {
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />
<Route path="api-docs" element={<ApiDocsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>

View File

@@ -123,6 +123,12 @@ export function UserDetailModal({
const [vipForm, setVipForm] = useState({ isVip: false, vipExpireDate: '', vipRole: '', vipName: '', vipProject: '', vipContact: '', vipBio: '' })
const [vipRoles, setVipRoles] = useState<{ id: number; name: string }[]>([])
// 调整余额
const [adjustBalanceOpen, setAdjustBalanceOpen] = useState(false)
const [adjustAmount, setAdjustAmount] = useState('')
const [adjustRemark, setAdjustRemark] = useState('')
const [adjustLoading, setAdjustLoading] = useState(false)
// 用户资料完善(神射手)
const [sssLoading, setSssLoading] = useState(false)
const [sssData, setSssData] = useState<ShensheShouData | null>(null)
@@ -287,6 +293,29 @@ export function UserDetailModal({
} catch { toast.error('修改失败') } finally { setPasswordSaving(false) }
}
async function handleAdjustBalance() {
if (!user) return
const amt = parseFloat(adjustAmount)
if (Number.isNaN(amt) || amt === 0) { toast.error('请输入有效金额(正数增加、负数扣减)'); return }
setAdjustLoading(true)
try {
const res = await post<{ success?: boolean; error?: string }>(`/api/admin/users/${user.id}/balance/adjust`, {
amount: amt,
remark: adjustRemark || undefined,
})
if (res?.success) {
toast.success('余额已调整')
setAdjustBalanceOpen(false)
setAdjustAmount('')
setAdjustRemark('')
loadUserDetail()
onUserUpdated?.()
} else {
toast.error('调整失败: ' + (res?.error || ''))
}
} catch { toast.error('调整失败') } finally { setAdjustLoading(false) }
}
// 用户资料完善查询(支持多维度)
async function handleSSSQuery() {
if (!sssQueryPhone && !sssQueryOpenId && !sssQueryWechatId) {
@@ -382,6 +411,7 @@ export function UserDetailModal({
if (!open) return null
return (
<>
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
@@ -468,7 +498,11 @@ export function UserDetailModal({
placeholder="输入手机号"
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
disabled={!!user?.phone}
/>
{user?.phone && (
<p className="text-xs text-gray-500"></p>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
@@ -521,11 +555,21 @@ export function UserDetailModal({
¥{(user.pendingEarnings ?? 0).toFixed(2)}
</p>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-[#38bdac]">
¥{(balanceData?.balance ?? 0).toFixed(2)}
</p>
<div className="p-4 bg-[#0a1628] rounded-lg flex flex-col justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-[#38bdac]">
¥{(balanceData?.balance ?? 0).toFixed(2)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="mt-2 border-[#38bdac]/50 text-[#38bdac] hover:bg-[#38bdac]/10 text-xs"
onClick={() => { setAdjustAmount(''); setAdjustRemark(''); setAdjustBalanceOpen(true) }}
>
</Button>
</div>
<div className="p-4 bg-[#0a1628] rounded-lg">
<p className="text-gray-400 text-sm"></p>
@@ -1084,5 +1128,44 @@ export function UserDetailModal({
)}
</DialogContent>
</Dialog>
<Dialog open={adjustBalanceOpen} onOpenChange={setAdjustBalanceOpen}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white" showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-gray-300 text-sm"></Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="正数增加,负数扣减,如 10 或 -5"
value={adjustAmount}
onChange={(e) => setAdjustAmount(e.target.value)}
/>
</div>
<div>
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="如:活动补偿"
value={adjustRemark}
onChange={(e) => setAdjustRemark(e.target.value)}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAdjustBalanceOpen(false)} className="border-gray-600 text-gray-300">
</Button>
<Button onClick={handleAdjustBalance} disabled={adjustLoading} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
{adjustLoading ? '提交中...' : '确认调整'}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -10,7 +10,7 @@ import {
GitMerge,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken } from '@/api/auth'
import { clearAdminToken, getAdminToken } from '@/api/auth'
import { RechargeAlert } from '@/components/RechargeAlert'
// 主菜单5 项平铺,按 Mycontent-temp 新规范)
@@ -36,22 +36,31 @@ export function AdminLayout() {
if (!mounted) return
setAuthChecked(false)
let cancelled = false
// 鉴权优化:先检查 token无 token 直接跳登录,避免无效请求
if (!getAdminToken()) {
navigate('/login', { replace: true })
return
}
get<{ success?: boolean }>('/api/admin')
.then((data) => {
if (cancelled) return
if (data && (data as { success?: boolean }).success !== false) {
setAuthChecked(true)
} else {
navigate('/login', { replace: true })
clearAdminToken()
navigate('/login', { replace: true, state: { from: location.pathname } })
}
})
.catch(() => {
if (!cancelled) navigate('/login', { replace: true })
if (!cancelled) {
clearAdminToken()
navigate('/login', { replace: true, state: { from: location.pathname } })
}
})
return () => {
cancelled = true
}
}, [mounted, navigate])
}, [location.pathname, mounted, navigate])
const handleLogout = async () => {
clearAdminToken()

View File

@@ -0,0 +1,443 @@
/**
* API 接口完整文档页 - 内容管理相关接口
* 深色主题,与 Admin 整体风格一致
* 来源new-soul/soul-admin
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BookOpen, User, Tag, Search, Trophy, Smartphone, Key } from 'lucide-react'
interface EndpointBlockProps {
method: string
url: string
desc?: string
headers?: string[]
body?: string
response?: string
}
function EndpointBlock({ method, url, desc, headers, body, response }: EndpointBlockProps) {
const methodColor =
method === 'GET'
? 'text-emerald-400'
: method === 'POST'
? 'text-amber-400'
: method === 'PUT'
? 'text-blue-400'
: method === 'DELETE'
? 'text-rose-400'
: 'text-gray-400'
return (
<div className="rounded-lg bg-[#0a1628]/60 border border-gray-700/50 p-4 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<span className={`font-mono font-semibold ${methodColor}`}>{method}</span>
<code className="text-sm text-[#38bdac] break-all">{url}</code>
</div>
{desc && <p className="text-gray-400 text-sm">{desc}</p>}
{headers && headers.length > 0 && (
<div>
<p className="text-gray-500 text-xs mb-1">Headers</p>
<pre className="text-xs text-gray-300 font-mono overflow-x-auto p-2 rounded bg-black/30">
{headers.join('\n')}
</pre>
</div>
)}
{body && (
<div>
<p className="text-gray-500 text-xs mb-1">Request Body (JSON)</p>
<pre className="text-xs text-green-400/90 font-mono overflow-x-auto p-2 rounded bg-black/30 whitespace-pre-wrap">
{body}
</pre>
</div>
)}
{response && (
<div>
<p className="text-gray-500 text-xs mb-1">Response Example</p>
<pre className="text-xs text-amber-200/80 font-mono overflow-x-auto p-2 rounded bg-black/30 whitespace-pre-wrap">
{response}
</pre>
</div>
)}
</div>
)
}
export function ApiDocsPage() {
const baseHeaders = ['Authorization: Bearer {token}', 'Content-Type: application/json']
return (
<div className="p-8 w-full bg-[#0a1628] text-white">
<div className="mb-8">
<h1 className="text-2xl font-bold text-white">API </h1>
<p className="text-gray-400 mt-1">
· RESTful · /api · Bearer Token
</p>
</div>
{/* 1. Authentication */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Key className="w-5 h-5 text-[#38bdac]" />
1. Authentication
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="POST"
url="/api/admin"
desc="登录,返回 JWT token"
headers={['Content-Type: application/json']}
body={`{
"username": "admin",
"password": "your_password"
}`}
response={`{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_at": "2026-03-16T12:00:00Z"
}`}
/>
</CardContent>
</Card>
{/* 2. Chapters */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<BookOpen className="w-5 h-5 text-[#38bdac]" />
2. (Chapters)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/book?action=chapters"
desc="获取章节树"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "id": "part-1", "title": "第一篇", "children": [...] },
{ "id": "section-1", "title": "第1节", "price": 1.0, "isFree": false }
]
}`}
/>
<EndpointBlock
method="GET"
url="/api/db/book?action=section&id={id}"
desc="获取单篇内容"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"id": "section-1",
"title": "标题",
"content": "正文...",
"price": 1.0,
"isFree": false,
"partId": "part-1",
"chapterId": "ch-1"
}
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="新建章节 (action=create-section)"
headers={baseHeaders}
body={`{
"action": "create-section",
"title": "新章节标题",
"content": "正文内容",
"price": 0,
"isFree": true,
"partId": "part-1",
"chapterId": "ch-1",
"partTitle": "第一篇",
"chapterTitle": "第1章"
}`}
response={`{
"success": true,
"data": { "id": "section-new-id", "title": "新章节标题", ... }
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="更新章节内容 (action=update-section)"
headers={baseHeaders}
body={`{
"action": "update-section",
"id": "section-1",
"title": "更新后的标题",
"content": "更新后的正文",
"price": 1.0,
"isFree": false
}`}
response={`{
"success": true,
"data": { "id": "section-1", "title": "更新后的标题", ... }
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/book"
desc="删除章节 (action=delete-section)"
headers={baseHeaders}
body={`{
"action": "delete-section",
"id": "section-1"
}`}
response={`{
"success": true,
"message": "已删除"
}`}
/>
<EndpointBlock
method="POST"
url="/api/admin/content/upload"
desc="上传图片(管理端)"
headers={baseHeaders}
body={`FormData: file (binary)`}
response={`{
"success": true,
"url": "/uploads/images/xxx.jpg",
"data": { "url", "fileName", "size", "type" }
}`}
/>
</CardContent>
</Card>
{/* 3. Persons */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<User className="w-5 h-5 text-[#38bdac]" />
3. (@Mentions)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/persons"
desc="人物列表"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "personId": "p1", "label": "张三", "aliases": ["老张"], ... }
]
}`}
/>
<EndpointBlock
method="GET"
url="/api/db/person?personId={id}"
desc="人物详情"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"personId": "p1",
"label": "张三",
"aliases": ["老张"],
"description": "..."
}
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/persons"
desc="新增/更新人物(含 aliases 字段)"
headers={baseHeaders}
body={`{
"personId": "p1",
"label": "张三",
"aliases": ["老张", "张三丰"],
"description": "可选描述"
}`}
response={`{
"success": true,
"data": { "personId": "p1", "label": "张三", ... }
}`}
/>
<EndpointBlock
method="DELETE"
url="/api/db/persons?personId={id}"
desc="删除人物"
headers={baseHeaders}
response={`{
"success": true,
"message": "已删除"
}`}
/>
</CardContent>
</Card>
{/* 4. LinkTags */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Tag className="w-5 h-5 text-[#38bdac]" />
4. (#LinkTags)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/link-tags"
desc="标签列表"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "tagId": "t1", "label": "官网", "aliases": [], "type": "url", "url": "https://..." }
]
}`}
/>
<EndpointBlock
method="POST"
url="/api/db/link-tags"
desc="新增/更新标签(含 aliases, type: url/miniprogram/ckb"
headers={baseHeaders}
body={`{
"tagId": "t1",
"label": "官网",
"aliases": ["官方网站"],
"type": "url",
"url": "https://example.com"
}
// type 可选: url | miniprogram | ckb`}
response={`{
"success": true,
"data": { "tagId": "t1", "label": "官网", "type": "url", ... }
}`}
/>
<EndpointBlock
method="DELETE"
url="/api/db/link-tags?tagId={id}"
desc="删除标签"
headers={baseHeaders}
response={`{
"success": true,
"message": "已删除"
}`}
/>
</CardContent>
</Card>
{/* 5. Search */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Search className="w-5 h-5 text-[#38bdac]" />
5.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/search?q={keyword}"
desc="搜索(标题优先 3 条 + 内容匹配)"
headers={baseHeaders}
response={`{
"success": true,
"data": {
"titleMatches": [{ "id": "s1", "title": "...", "snippet": "..." }],
"contentMatches": [{ "id": "s2", "title": "...", "snippet": "..." }]
}
}`}
/>
</CardContent>
</Card>
{/* 6. Ranking */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Trophy className="w-5 h-5 text-[#38bdac]" />
6.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/db/book?action=ranking"
desc="排行榜数据"
headers={baseHeaders}
response={`{
"success": true,
"data": [
{ "id": "s1", "title": "...", "clickCount": 100, "payCount": 50, "hotScore": 120, "hotRank": 1 }
]
}`}
/>
</CardContent>
</Card>
{/* 7. Miniprogram */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-white flex items-center gap-2">
<Smartphone className="w-5 h-5 text-[#38bdac]" />
7.
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EndpointBlock
method="GET"
url="/api/miniprogram/book/all-chapters"
desc="全部章节(小程序用)"
headers={['Content-Type: application/json']}
response={`{
"success": true,
"data": [ { "id": "s1", "title": "...", "price": 1.0, "isFree": false }, ... ]
}`}
/>
<EndpointBlock
method="GET"
url="/api/miniprogram/balance?userId={id}"
desc="查余额"
headers={['Content-Type: application/json']}
response={`{
"success": true,
"data": { "balance": 100.50, "userId": "xxx" }
}`}
/>
<EndpointBlock
method="POST"
url="/api/miniprogram/balance/gift"
desc="代付"
headers={['Content-Type: application/json']}
body={`{
"userId": "xxx",
"amount": 10.00,
"remark": "可选备注"
}`}
response={`{
"success": true,
"data": { "balance": 110.50 }
}`}
/>
<EndpointBlock
method="POST"
url="/api/miniprogram/balance/gift/redeem"
desc="领取代付"
headers={['Content-Type: application/json']}
body={`{
"code": "GIFT_XXXX"
}`}
response={`{
"success": true,
"data": { "amount": 10.00, "balance": 120.50 }
}`}
/>
</CardContent>
</Card>
<p className="text-gray-500 text-xs mt-6">
使 /api/admin/*/api/db/*使 /api/miniprogram/* soul-api
</p>
</div>
)
}

View File

@@ -5,6 +5,7 @@ import {
TrendingUp,
Clock,
Wallet,
Gift,
Search,
RefreshCw,
CheckCircle,
@@ -108,6 +109,7 @@ interface Order {
bookName?: string
chapterTitle?: string
sectionTitle?: string
description?: string
amount: number
status: string
paymentMethod?: string
@@ -122,7 +124,7 @@ interface Order {
}
export function DistributionPage() {
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'settings'>(
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals' | 'giftPay' | 'settings'>(
'overview',
)
const [orders, setOrders] = useState<Order[]>([])
@@ -144,6 +146,25 @@ export function DistributionPage() {
const [rejectWithdrawalId, setRejectWithdrawalId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [rejectLoading, setRejectLoading] = useState(false)
const [giftPayRequests, setGiftPayRequests] = useState<Array<{
id: string
requestSn: string
initiatorUserId: string
initiatorNick?: string
productType: string
productId: string
amount: number
description: string
status: string
payerUserId?: string
payerNick?: string
orderId?: string
expireAt: string
createdAt: string
}>>([])
const [giftPayPage, setGiftPayPage] = useState(1)
const [giftPayTotal, setGiftPayTotal] = useState(0)
const [giftPayStatusFilter, setGiftPayStatusFilter] = useState('')
useEffect(() => {
loadInitialData()
@@ -161,7 +182,10 @@ export function DistributionPage() {
if (['orders', 'bindings', 'withdrawals'].includes(activeTab)) {
loadTabData(activeTab, true)
}
}, [page, pageSize, statusFilter, searchTerm])
if (activeTab === 'giftPay') {
loadTabData('giftPay', true)
}
}, [page, pageSize, statusFilter, searchTerm, giftPayPage, giftPayStatusFilter])
async function loadInitialData() {
setError(null)
@@ -284,6 +308,30 @@ export function DistributionPage() {
}
break
}
case 'giftPay': {
try {
const params = new URLSearchParams({
page: String(giftPayPage),
pageSize: '20',
...(giftPayStatusFilter && { status: giftPayStatusFilter }),
})
const res = await get<{ success?: boolean; data?: typeof giftPayRequests; total?: number }>(
`/api/admin/gift-pay-requests?${params}`,
)
if (res?.success && res.data) {
setGiftPayRequests(res.data)
setGiftPayTotal(res.total ?? res.data.length)
} else {
setGiftPayRequests([])
setGiftPayTotal(0)
}
} catch (e) {
console.error(e)
setError('加载代付请求失败')
setGiftPayRequests([])
}
break
}
}
setLoadedTabs((prev) => new Set(prev).add(tab))
} catch (e) {
@@ -470,6 +518,7 @@ export function DistributionPage() {
{ key: 'orders', label: '订单管理', icon: DollarSign },
{ key: 'bindings', label: '绑定管理', icon: Link2 },
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
{ key: 'giftPay', label: '代付请求', icon: Gift },
{ key: 'settings', label: '推广设置', icon: Settings },
].map((tab) => (
<button
@@ -479,6 +528,10 @@ export function DistributionPage() {
setActiveTab(tab.key as typeof activeTab)
setStatusFilter('all')
setSearchTerm('')
if (tab.key === 'giftPay') {
setGiftPayStatusFilter('')
setGiftPayPage(1)
}
}}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === tab.key
@@ -1213,6 +1266,97 @@ export function DistributionPage() {
</Card>
</div>
)}
{activeTab === 'giftPay' && (
<div className="space-y-4">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-4">
<CardTitle className="text-white"></CardTitle>
<div className="flex gap-2 items-center">
<select
className="bg-[#0a1628] border border-gray-700 text-white rounded px-3 py-1.5 text-sm"
value={giftPayStatusFilter}
onChange={(e) => { setGiftPayStatusFilter(e.target.value); setGiftPayPage(1) }}
>
<option value=""></option>
<option value="pending"></option>
<option value="paid"></option>
<option value="cancelled"></option>
<option value="expired"></option>
</select>
<Button size="sm" variant="outline" onClick={() => loadTabData('giftPay', true)} className="border-gray-600 text-gray-300">
<RefreshCw className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-700/50">
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400">/</th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
<th className="p-4 text-left font-medium text-gray-400"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{giftPayRequests.map((r) => (
<tr key={r.id} className="hover:bg-[#0a1628]">
<td className="p-4 font-mono text-xs text-gray-400">{r.requestSn}</td>
<td className="p-4">
<p className="text-white text-sm">{r.initiatorNick || r.initiatorUserId}</p>
</td>
<td className="p-4">
<p className="text-white">{r.productType} · ¥{r.amount.toFixed(2)}</p>
{r.description && <p className="text-gray-500 text-xs">{r.description}</p>}
</td>
<td className="p-4 text-gray-400">{r.payerNick || (r.payerUserId ? r.payerUserId : '-')}</td>
<td className="p-4">
<Badge
className={
r.status === 'paid'
? 'bg-green-500/20 text-green-400 border-0'
: r.status === 'pending'
? 'bg-amber-500/20 text-amber-400 border-0'
: 'bg-gray-500/20 text-gray-400 border-0'
}
>
{r.status === 'paid' ? '已支付' : r.status === 'pending' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
</Badge>
</td>
<td className="p-4 text-gray-400 text-sm">
{r.createdAt ? new Date(r.createdAt).toLocaleString('zh-CN') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{giftPayRequests.length === 0 && !loading && (
<p className="text-center py-8 text-gray-500"></p>
)}
{giftPayTotal > 20 && (
<div className="mt-4 flex justify-center">
<Pagination
page={giftPayPage}
totalPages={Math.ceil(giftPayTotal / 20)}
total={giftPayTotal}
pageSize={20}
onPageChange={setGiftPayPage}
onPageSizeChange={() => {}}
/>
</div>
)}
</CardContent>
</Card>
</div>
)}
</>
)}

View File

@@ -34,10 +34,13 @@ import {
Smartphone,
ShieldCheck,
Link2,
FileText,
Cloud,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
import { ApiDocsPage } from '@/pages/api-docs/ApiDocsPage'
interface AuthorInfo {
name?: string
@@ -60,7 +63,6 @@ interface FeatureConfig {
matchEnabled: boolean
referralEnabled: boolean
searchEnabled: boolean
aboutEnabled: boolean
}
interface MpConfig {
@@ -70,6 +72,14 @@ interface MpConfig {
minWithdraw?: number
}
interface OssConfig {
endpoint?: string
bucket?: string
region?: string
accessKeyId?: string
accessKeySecret?: string
}
const defaultMpConfig: MpConfig = {
appId: 'wxb8bbb2b10dec74aa',
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
@@ -98,10 +108,9 @@ const defaultFeatures: FeatureConfig = {
matchEnabled: true,
referralEnabled: true,
searchEnabled: true,
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin'] as const
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
export function SettingsPage() {
@@ -112,6 +121,7 @@ export function SettingsPage() {
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
@@ -135,12 +145,15 @@ export function SettingsPage() {
featureConfig?: Partial<FeatureConfig>
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo; ckbLeadApiKey?: string }
mpConfig?: Partial<MpConfig>
ossConfig?: Partial<OssConfig>
}>('/api/admin/settings')
if (!res || (res as { success?: boolean }).success === false) return
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.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
if (res.siteSettings && typeof res.siteSettings === 'object') {
const s = res.siteSettings
setLocalSettings((prev) => ({
@@ -211,6 +224,15 @@ export function SettingsPage() {
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
},
ossConfig: Object.keys(ossConfig).length
? {
endpoint: ossConfig.endpoint ?? '',
bucket: ossConfig.bucket ?? '',
region: ossConfig.region ?? '',
accessKeyId: ossConfig.accessKeyId ?? '',
accessKeySecret: ossConfig.accessKeySecret ?? '',
}
: undefined,
})
if (!res || (res as { success?: boolean }).success === false) {
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
@@ -273,6 +295,13 @@ export function SettingsPage() {
<ShieldCheck className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="api-docs"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<FileText className="w-4 h-4 mr-2" />
API
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-0">
@@ -595,7 +624,7 @@ export function SettingsPage() {
</Label>
</div>
<p className="text-xs text-gray-400 ml-6"></p>
<p className="text-xs text-gray-400 ml-6"></p>
</div>
<Switch
id="search-enabled"
@@ -604,23 +633,6 @@ export function SettingsPage() {
onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">访</p>
</div>
<Switch
id="about-enabled"
checked={featureConfig.aboutEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-xs text-blue-300">
@@ -629,6 +641,78 @@ export function SettingsPage() {
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Cloud className="w-5 h-5 text-[#38bdac]" />
OSS
</CardTitle>
<CardDescription className="text-gray-400">
endpointbucketaccessKey /
</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">Endpoint</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou.aliyuncs.com"
value={ossConfig.endpoint ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, endpoint: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Bucket</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="bucket 名称"
value={ossConfig.bucket ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Region</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou"
value={ossConfig.region ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, region: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="AccessKey ID"
value={ossConfig.accessKeyId ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey Secret</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="AccessKey Secret"
value={ossConfig.accessKeySecret ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
}
/>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
@@ -639,6 +723,10 @@ export function SettingsPage() {
<TabsContent value="admin" className="mt-0">
<AdminUsersPage />
</TabsContent>
<TabsContent value="api-docs" className="mt-0">
<ApiDocsPage />
</TabsContent>
</Tabs>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>