feat: 定合并的稳定版本
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
443
soul-admin/src/pages/api-docs/ApiDocsPage.tsx
Normal file
443
soul-admin/src/pages/api-docs/ApiDocsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
endpoint、bucket、accessKey 等,用于图片/文件上传
|
||||
</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}>
|
||||
|
||||
Reference in New Issue
Block a user