更新提现功能,新增私钥文件 URL 配置选项以支持从远程拉取私钥,优化相关错误处理逻辑,确保在转账前正确加载私钥。同时,更新文档以反映新的环境变量配置,提升系统的灵活性和用户体验。
This commit is contained in:
@@ -15,6 +15,8 @@ export interface WechatTransferConfig {
|
||||
apiV3Key: string
|
||||
privateKeyPath?: string
|
||||
privateKeyContent?: string
|
||||
/** 私钥文件 URL,可选;配置后会在首次调用时拉取并缓存 */
|
||||
privateKeyUrl?: string
|
||||
certSerialNo: string
|
||||
}
|
||||
|
||||
@@ -37,9 +39,10 @@ function getConfig(): WechatTransferConfig {
|
||||
const apiV3Key = process.env.WECHAT_API_V3_KEY || process.env.WECHAT_MCH_KEY || ''
|
||||
const keyPath = process.env.WECHAT_KEY_PATH || process.env.WECHAT_MCH_PRIVATE_KEY_PATH || ''
|
||||
const keyContent = process.env.WECHAT_MCH_PRIVATE_KEY || ''
|
||||
const keyUrl = process.env.WECHAT_KEY_URL || ''
|
||||
let certSerialNo = process.env.WECHAT_MCH_CERT_SERIAL_NO || ''
|
||||
const certPath = process.env.WECHAT_CERT_PATH || ''
|
||||
if (!certSerialNo && certPath) {
|
||||
if (!certSerialNo && certPath && !certPath.startsWith('http')) {
|
||||
try {
|
||||
certSerialNo = getCertSerialNoFromPath(certPath)
|
||||
} catch (e) {
|
||||
@@ -52,10 +55,27 @@ function getConfig(): WechatTransferConfig {
|
||||
apiV3Key,
|
||||
privateKeyPath: keyPath,
|
||||
privateKeyContent: keyContent,
|
||||
privateKeyUrl: keyUrl,
|
||||
certSerialNo,
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 WECHAT_KEY_URL 拉取的私钥缓存(仅内存,不落盘) */
|
||||
let privateKeyFromUrlCache: string | null = null
|
||||
|
||||
/** 若配置了 WECHAT_KEY_URL,在发起转账前拉取私钥并缓存 */
|
||||
export async function loadPrivateKeyFromUrlIfNeeded(): Promise<void> {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.privateKeyUrl) return
|
||||
if (cfg.privateKeyContent || (cfg.privateKeyPath && !cfg.privateKeyPath.startsWith('http'))) return
|
||||
if (privateKeyFromUrlCache) return
|
||||
const res = await fetch(cfg.privateKeyUrl)
|
||||
if (!res.ok) throw new Error(`拉取私钥失败: ${res.status} ${cfg.privateKeyUrl}`)
|
||||
const text = await res.text()
|
||||
if (!text.includes('PRIVATE KEY')) throw new Error('WECHAT_KEY_URL 返回内容不是有效的 PEM 私钥')
|
||||
privateKeyFromUrlCache = text
|
||||
}
|
||||
|
||||
function getPrivateKey(): string {
|
||||
const cfg = getConfig()
|
||||
if (cfg.privateKeyContent) {
|
||||
@@ -65,11 +85,19 @@ function getPrivateKey(): string {
|
||||
}
|
||||
return key
|
||||
}
|
||||
if (cfg.privateKeyUrl && privateKeyFromUrlCache) return privateKeyFromUrlCache
|
||||
if (cfg.privateKeyPath) {
|
||||
if (cfg.privateKeyPath.startsWith('http://') || cfg.privateKeyPath.startsWith('https://')) {
|
||||
if (privateKeyFromUrlCache) return privateKeyFromUrlCache
|
||||
throw new Error('私钥来自 URL 尚未加载,请先调用 loadPrivateKeyFromUrlIfNeeded()')
|
||||
}
|
||||
const p = path.isAbsolute(cfg.privateKeyPath) ? cfg.privateKeyPath : path.join(process.cwd(), cfg.privateKeyPath)
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
}
|
||||
throw new Error('微信商户私钥未配置: WECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_PATH')
|
||||
if (cfg.privateKeyUrl) {
|
||||
throw new Error('已配置 WECHAT_KEY_URL 但私钥尚未拉取,请确保在转账前已调用 loadPrivateKeyFromUrlIfNeeded()')
|
||||
}
|
||||
throw new Error('微信商户私钥未配置: WECHAT_MCH_PRIVATE_KEY、WECHAT_KEY_PATH 或 WECHAT_KEY_URL')
|
||||
}
|
||||
|
||||
function generateNonce(length = 32): string {
|
||||
@@ -118,6 +146,7 @@ export interface CreateTransferResult {
|
||||
* 发起商家转账到零钱
|
||||
*/
|
||||
export async function createTransfer(params: CreateTransferParams): Promise<CreateTransferResult> {
|
||||
await loadPrivateKeyFromUrlIfNeeded()
|
||||
const cfg = getConfig()
|
||||
if (!cfg.mchId || !cfg.appId || !cfg.apiV3Key || !cfg.certSerialNo) {
|
||||
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整' }
|
||||
@@ -213,6 +242,7 @@ function getTransferNotifyUrl(): string {
|
||||
export async function createTransferUserConfirm(
|
||||
params: CreateTransferUserConfirmParams
|
||||
): Promise<CreateTransferUserConfirmResult> {
|
||||
await loadPrivateKeyFromUrlIfNeeded()
|
||||
const cfg = getConfig()
|
||||
if (!cfg.mchId || !cfg.appId) {
|
||||
return { success: false, errorCode: 'CONFIG_ERROR', errorMessage: '微信转账配置不完整:缺少商户号或 AppID' }
|
||||
@@ -230,7 +260,7 @@ export async function createTransferUserConfirm(
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'CONFIG_ERROR',
|
||||
errorMessage: `微信转账配置不完整:商户私钥未配置。请在 .env 中配置 WECHAT_KEY_PATH(apiclient_key.pem 路径)或 WECHAT_MCH_PRIVATE_KEY`,
|
||||
errorMessage: `微信转账配置不完整:商户私钥未配置。请在 .env 中配置 WECHAT_KEY_PATH、WECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_URL`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
middleware.ts
Normal file
46
middleware.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
/** 允许的跨域来源(管理端独立项目、本地开发) */
|
||||
const ALLOWED_ORIGINS = [
|
||||
'http://localhost:5174', // soul-admin 开发
|
||||
'http://127.0.0.1:5174',
|
||||
'https://soul.quwanzhi.com', // 若管理端与 API 同域则不需要,预留
|
||||
]
|
||||
|
||||
function getCorsHeaders(origin: string | null) {
|
||||
const allowOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const origin = req.headers.get('origin') || ''
|
||||
const isApi = req.nextUrl.pathname.startsWith('/api/')
|
||||
|
||||
if (!isApi) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const corsHeaders = getCorsHeaders(origin || 'http://localhost:5174')
|
||||
|
||||
// 预检请求:直接返回 200 + CORS 头
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new NextResponse(null, { status: 204, headers: corsHeaders })
|
||||
}
|
||||
|
||||
const res = NextResponse.next()
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => {
|
||||
res.headers.set(key, value)
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: '/api/:path*',
|
||||
}
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
2
soul-admin/.env.development
Normal file
2
soul-admin/.env.development
Normal file
@@ -0,0 +1,2 @@
|
||||
# 开发环境:对接当前 Next 后端(与现网 API 路径完全一致,无缝切换)
|
||||
VITE_API_BASE_URL=http://localhost:3006
|
||||
2
soul-admin/.env.example
Normal file
2
soul-admin/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# 对接后端 base URL(不改 API 路径,仅改此处即可切换 Next → Gin)
|
||||
VITE_API_BASE_URL=http://localhost:3006
|
||||
446
soul-admin/dist/assets/index-DX3SXTVU.js
vendored
Normal file
446
soul-admin/dist/assets/index-DX3SXTVU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-Z7C0sgIG.css
vendored
Normal file
1
soul-admin/dist/assets/index-Z7C0sgIG.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
soul-admin/dist/index.html
vendored
Normal file
13
soul-admin/dist/index.html
vendored
Normal file
@@ -0,0 +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-DX3SXTVU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Z7C0sgIG.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
</body>
|
||||
12
soul-admin/index.html
Normal file
12
soul-admin/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
soul-admin/package.json
Normal file
47
soul-admin/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "soul-admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "2.1.8",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.8",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"lucide-react": "0.562.0",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"zustand": "5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"postcss": "^8.4.49",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
3407
soul-admin/pnpm-lock.yaml
generated
Normal file
3407
soul-admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
soul-admin/postcss.config.js
Normal file
5
soul-admin/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
43
soul-admin/src/App.tsx
Normal file
43
soul-admin/src/App.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AdminLayout } from './layouts/AdminLayout'
|
||||
import { LoginPage } from './pages/login/LoginPage'
|
||||
import { DashboardPage } from './pages/dashboard/DashboardPage'
|
||||
import { OrdersPage } from './pages/orders/OrdersPage'
|
||||
import { UsersPage } from './pages/users/UsersPage'
|
||||
import { DistributionPage } from './pages/distribution/DistributionPage'
|
||||
import { WithdrawalsPage } from './pages/withdrawals/WithdrawalsPage'
|
||||
import { ContentPage } from './pages/content/ContentPage'
|
||||
import { ChaptersPage } from './pages/chapters/ChaptersPage'
|
||||
import { ReferralSettingsPage } from './pages/referral-settings/ReferralSettingsPage'
|
||||
import { SettingsPage } from './pages/settings/SettingsPage'
|
||||
import { PaymentPage } from './pages/payment/PaymentPage'
|
||||
import { SitePage } from './pages/site/SitePage'
|
||||
import { QRCodesPage } from './pages/qrcodes/QRCodesPage'
|
||||
import { MatchPage } from './pages/match/MatchPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="orders" element={<OrdersPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="distribution" element={<DistributionPage />} />
|
||||
<Route path="withdrawals" element={<WithdrawalsPage />} />
|
||||
<Route path="content" element={<ContentPage />} />
|
||||
<Route path="chapters" element={<ChaptersPage />} />
|
||||
<Route path="referral-settings" element={<ReferralSettingsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="payment" element={<PaymentPage />} />
|
||||
<Route path="site" element={<SitePage />} />
|
||||
<Route path="qrcodes" element={<QRCodesPage />} />
|
||||
<Route path="match" element={<MatchPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
77
soul-admin/src/api/client.ts
Normal file
77
soul-admin/src/api/client.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 统一 API 请求封装
|
||||
* 规则:API 路径与现网完全一致,仅通过 baseUrl 区分环境(Next 或未来 Gin)
|
||||
* 无缝切换:仅修改 VITE_API_BASE_URL 即可切换后端
|
||||
*/
|
||||
|
||||
const getBaseUrl = (): string => {
|
||||
const url = import.meta.env.VITE_API_BASE_URL
|
||||
if (typeof url === 'string' && url.length > 0) return url.replace(/\/$/, '')
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 请求完整 URL:baseUrl + path,path 必须与现网一致(如 /api/orders) */
|
||||
export function apiUrl(path: string): string {
|
||||
const base = getBaseUrl()
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return base ? `${base}${p}` : p
|
||||
}
|
||||
|
||||
export type RequestInitWithBody = RequestInit & { data?: unknown }
|
||||
|
||||
/**
|
||||
* 发起请求。path 为与现网一致的 API 路径(如 /api/admin、/api/orders)。
|
||||
* 自动带上 credentials: 'include' 以支持 Cookie 鉴权(与现有 Next 一致)。
|
||||
*/
|
||||
export async function request<T = unknown>(
|
||||
path: string,
|
||||
options: RequestInitWithBody = {}
|
||||
): Promise<T> {
|
||||
const { data, ...init } = options
|
||||
const url = apiUrl(path)
|
||||
const headers = new Headers(init.headers as HeadersInit)
|
||||
if (data !== undefined && data !== null && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
const body = data !== undefined && data !== null ? JSON.stringify(data) : init.body
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
body,
|
||||
credentials: 'include',
|
||||
})
|
||||
const contentType = res.headers.get('Content-Type') || ''
|
||||
const json: T = contentType.includes('application/json')
|
||||
? ((await res.json()) as T)
|
||||
: (res as unknown as T)
|
||||
if (!res.ok) {
|
||||
const err = new Error((json as { error?: string })?.error || `HTTP ${res.status}`) as Error & {
|
||||
status: number
|
||||
data: T
|
||||
}
|
||||
err.status = res.status
|
||||
err.data = json
|
||||
throw err
|
||||
}
|
||||
return json
|
||||
}
|
||||
|
||||
/** GET */
|
||||
export function get<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
return request<T>(path, { ...init, method: 'GET' })
|
||||
}
|
||||
|
||||
/** POST */
|
||||
export function post<T = unknown>(path: string, data?: unknown, init?: RequestInit): Promise<T> {
|
||||
return request<T>(path, { ...init, method: 'POST', data })
|
||||
}
|
||||
|
||||
/** PUT */
|
||||
export function put<T = unknown>(path: string, data?: unknown, init?: RequestInit): Promise<T> {
|
||||
return request<T>(path, { ...init, method: 'PUT', data })
|
||||
}
|
||||
|
||||
/** DELETE */
|
||||
export function del<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
||||
return request<T>(path, { ...init, method: 'DELETE' })
|
||||
}
|
||||
479
soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
479
soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
@@ -0,0 +1,479 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
User,
|
||||
Phone,
|
||||
History,
|
||||
RefreshCw,
|
||||
Link2,
|
||||
BookOpen,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
MessageCircle,
|
||||
Clock,
|
||||
Save,
|
||||
X,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { get, put, post } from '@/api/client'
|
||||
|
||||
interface UserDetailModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
userId: string | null
|
||||
onUserUpdated?: () => void
|
||||
}
|
||||
|
||||
interface UserDetail {
|
||||
id: string
|
||||
phone?: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
wechat_id?: string
|
||||
open_id?: string
|
||||
referral_code: string
|
||||
referred_by?: string
|
||||
has_full_book?: boolean
|
||||
is_admin?: boolean
|
||||
earnings?: number
|
||||
pending_earnings?: number
|
||||
referral_count?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
tags?: string
|
||||
ckb_tags?: string
|
||||
ckb_synced_at?: string
|
||||
}
|
||||
|
||||
interface UserTrack {
|
||||
id: string
|
||||
action: string
|
||||
actionLabel: string
|
||||
target?: string
|
||||
chapterTitle?: string
|
||||
createdAt: string
|
||||
timeAgo: string
|
||||
}
|
||||
|
||||
export function UserDetailModal({
|
||||
open,
|
||||
onClose,
|
||||
userId,
|
||||
onUserUpdated,
|
||||
}: UserDetailModalProps) {
|
||||
const [user, setUser] = useState<UserDetail | null>(null)
|
||||
const [tracks, setTracks] = useState<UserTrack[]>([])
|
||||
const [referrals, setReferrals] = useState<unknown[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('info')
|
||||
const [editPhone, setEditPhone] = useState('')
|
||||
const [editNickname, setEditNickname] = useState('')
|
||||
const [editTags, setEditTags] = useState<string[]>([])
|
||||
const [newTag, setNewTag] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open && userId) loadUserDetail()
|
||||
}, [open, userId])
|
||||
|
||||
async function loadUserDetail() {
|
||||
if (!userId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const userData = await get<{ success?: boolean; user?: UserDetail }>(
|
||||
`/api/db/users?id=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (userData?.success && userData.user) {
|
||||
const u = userData.user
|
||||
setUser(u)
|
||||
setEditPhone(u.phone || '')
|
||||
setEditNickname(u.nickname || '')
|
||||
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
|
||||
}
|
||||
try {
|
||||
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
|
||||
`/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
|
||||
)
|
||||
if (trackData?.success && trackData.tracks) setTracks(trackData.tracks)
|
||||
} catch {
|
||||
setTracks([])
|
||||
}
|
||||
try {
|
||||
const refData = await get<{ success?: boolean; referrals?: unknown[] }>(
|
||||
`/api/db/users/referrals?userId=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (refData?.success && refData.referrals) setReferrals(refData.referrals)
|
||||
} catch {
|
||||
setReferrals([])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load user detail error:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncCKB() {
|
||||
if (!user?.phone) {
|
||||
alert('用户未绑定手机号,无法同步')
|
||||
return
|
||||
}
|
||||
setSyncing(true)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
|
||||
action: 'full_sync',
|
||||
phone: user.phone,
|
||||
userId: user.id,
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('同步成功')
|
||||
loadUserDetail()
|
||||
} else {
|
||||
alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Sync CKB error:', e)
|
||||
alert('同步失败')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: user.id,
|
||||
phone: editPhone || undefined,
|
||||
nickname: editNickname || undefined,
|
||||
tags: JSON.stringify(editTags),
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('保存成功')
|
||||
loadUserDetail()
|
||||
onUserUpdated?.()
|
||||
} else {
|
||||
alert('保存失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save user error:', e)
|
||||
alert('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
if (newTag && !editTags.includes(newTag)) {
|
||||
setEditTags([...editTags, newTag])
|
||||
setNewTag('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setEditTags(editTags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
const icons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
view_chapter: BookOpen,
|
||||
purchase: ShoppingBag,
|
||||
match: Users,
|
||||
login: User,
|
||||
register: User,
|
||||
share: Link2,
|
||||
bind_phone: Phone,
|
||||
bind_wechat: MessageCircle,
|
||||
}
|
||||
const Icon = icons[action] || History
|
||||
return <Icon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
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>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-[#38bdac]" />
|
||||
用户详情
|
||||
{user?.phone && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-2">已绑定手机</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="flex flex-col h-[70vh]">
|
||||
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac]">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
user.nickname?.charAt(0) || '?'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-bold text-white">{user.nickname}</h3>
|
||||
{user.is_admin && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0">管理员</Badge>
|
||||
)}
|
||||
{user.has_full_book && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">全书已购</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{user.phone ? `📱 ${user.phone}` : '未绑定手机'}
|
||||
{user.wechat_id && ` · 💬 ${user.wechat_id}`}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
ID: {user.id} · 推广码: {user.referral_code}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[#38bdac] font-bold">¥{(user.earnings || 0).toFixed(2)}</p>
|
||||
<p className="text-gray-500 text-xs">累计收益</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-4">
|
||||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
基础信息
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
标签体系
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tracks" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
行为轨迹
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
关系链路
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">手机号</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="输入手机号"
|
||||
value={editPhone}
|
||||
onChange={(e) => setEditPhone(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="输入昵称"
|
||||
value={editNickname}
|
||||
onChange={(e) => setEditNickname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-400 text-sm">推荐人数</p>
|
||||
<p className="text-2xl font-bold text-white">{user.referral_count || 0}</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-yellow-400">
|
||||
¥{(user.pending_earnings || 0).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-400 text-sm">创建时间</p>
|
||||
<p className="text-sm text-white">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">存客宝同步</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSyncCKB}
|
||||
disabled={syncing || !user.phone}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 同步中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" /> 同步数据
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">同步状态:</span>
|
||||
{user.ckb_synced_at ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-1">已同步</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gray-500/20 text-gray-400 border-0 ml-1">未同步</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">最后同步:</span>
|
||||
<span className="text-gray-300 ml-1">
|
||||
{user.ckb_synced_at ? new Date(user.ckb_synced_at).toLocaleString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">系统标签</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{editTags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{editTags.length === 0 && <span className="text-gray-500 text-sm">暂无标签</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="添加新标签"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||||
/>
|
||||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]">
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tracks" className="flex-1 overflow-auto">
|
||||
<div className="space-y-2">
|
||||
{tracks.length > 0 ? (
|
||||
tracks.map((track) => (
|
||||
<div key={track.id} className="flex items-start gap-3 p-3 bg-[#0a1628] rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
|
||||
{getActionIcon(track.action)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">{track.actionLabel}</span>
|
||||
{track.chapterTitle && (
|
||||
<span className="text-gray-400 text-sm">- {track.chapterTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{track.timeAgo} · {new Date(track.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<History className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
|
||||
<p className="text-gray-400">暂无行为轨迹</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="relations" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">推荐的用户</span>
|
||||
</div>
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">共 {referrals.length} 人</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{referrals.length > 0 ? (
|
||||
referrals.map((ref: unknown, i: number) => {
|
||||
const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string }
|
||||
return (
|
||||
<div key={r.id || i} className="flex items-center justify-between p-2 bg-[#162840] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-xs text-[#38bdac]">
|
||||
{r.nickname?.charAt(0) || '?'}
|
||||
</div>
|
||||
<span className="text-white text-sm">{r.nickname}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.status === 'vip' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs">已购</Badge>
|
||||
)}
|
||||
<span className="text-gray-500 text-xs">
|
||||
{r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm text-center py-4">暂无推荐用户</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-700 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? '保存中...' : '保存修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">用户不存在</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
31
soul-admin/src/components/ui/badge.tsx
Normal file
31
soul-admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-white',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
return <Comp className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
52
soul-admin/src/components/ui/button.tsx
Normal file
52
soul-admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-white hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
42
soul-admin/src/components/ui/card.tsx
Normal file
42
soul-admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
83
soul-admin/src/components/ui/dialog.tsx
Normal file
83
soul-admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal(props: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle(props: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return <DialogPrimitive.Title className="text-lg font-semibold leading-none" {...props} />
|
||||
}
|
||||
|
||||
function DialogDescription(props: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return <DialogPrimitive.Description className="text-sm text-muted-foreground" {...props} />
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
export const DialogClose = DialogPrimitive.Close
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
18
soul-admin/src/components/ui/input.tsx
Normal file
18
soul-admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 md:text-sm focus-visible:ring-2 focus-visible:ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
soul-admin/src/components/ui/label.tsx
Normal file
20
soul-admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
79
soul-admin/src/components/ui/select.tsx
Normal file
79
soul-admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
|
||||
position === 'popper' && 'data-[side=bottom]:translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
|
||||
48
soul-admin/src/components/ui/slider.tsx
Normal file
48
soul-admin/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="bg-muted relative grow overflow-hidden rounded-full h-1.5 w-full">
|
||||
<SliderPrimitive.Range className="bg-primary absolute h-full" />
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={index}
|
||||
className="block size-4 shrink-0 rounded-full border border-primary bg-white shadow-sm focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
26
soul-admin/src/components/ui/switch.tsx
Normal file
26
soul-admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
63
soul-admin/src/components/ui/table.tsx
Normal file
63
soul-admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
49
soul-admin/src/components/ui/tabs.tsx
Normal file
49
soul-admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
19
soul-admin/src/components/ui/textarea.tsx
Normal file
19
soul-admin/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
36
soul-admin/src/index.css
Normal file
36
soul-admin/src/index.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.2 0.02 240);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.2 0.02 240);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.65 0.15 180);
|
||||
--primary-foreground: oklch(0.2 0 0);
|
||||
--secondary: oklch(0.27 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.27 0 0);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.27 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.35 0 0);
|
||||
--input: oklch(0.35 0 0);
|
||||
--ring: oklch(0.65 0.15 180);
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: #0a1628;
|
||||
color: var(--foreground);
|
||||
}
|
||||
127
soul-admin/src/layouts/AdminLayout.tsx
Normal file
127
soul-admin/src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
Wallet,
|
||||
BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
|
||||
{ icon: BookOpen, label: '内容管理', href: '/content' },
|
||||
{ icon: Users, label: '用户管理', href: '/users' },
|
||||
{ icon: Wallet, label: '交易中心', href: '/distribution' },
|
||||
{ icon: CreditCard, label: '推广设置', href: '/referral-settings' },
|
||||
{ icon: Settings, label: '系统设置', href: '/settings' },
|
||||
]
|
||||
|
||||
export function AdminLayout() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [authChecked, setAuthChecked] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
setAuthChecked(false)
|
||||
let cancelled = false
|
||||
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 })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) navigate('/login', { replace: true })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [mounted, navigate])
|
||||
|
||||
const handleLogout = async () => {
|
||||
await post('/api/admin/logout', {})
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
if (!mounted || !authChecked) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a1628]">
|
||||
<div className="w-64 bg-[#0f2137] border-r border-gray-700/50" />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-[#38bdac]">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a1628]">
|
||||
<div className="w-64 bg-[#0f2137] flex flex-col border-r border-gray-700/50 shadow-xl">
|
||||
<div className="p-6 border-b border-gray-700/50">
|
||||
<h1 className="text-xl font-bold text-[#38bdac]">管理后台</h1>
|
||||
<p className="text-xs text-gray-400 mt-1">Soul创业派对</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = location.pathname === item.href
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
|
||||
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-700/50 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-gray-400 hover:text-white rounded-lg hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="flex-1 overflow-auto bg-[#0a1628]">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
soul-admin/src/lib/utils.ts
Normal file
6
soul-admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
13
soul-admin/src/main.tsx
Normal file
13
soul-admin/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
316
soul-admin/src/pages/chapters/ChaptersPage.tsx
Normal file
316
soul-admin/src/pages/chapters/ChaptersPage.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface Section {
|
||||
id: string
|
||||
title: string
|
||||
price: number
|
||||
isFree: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface Chapter {
|
||||
id: string
|
||||
title: string
|
||||
sections?: Section[]
|
||||
price?: number
|
||||
isFree?: boolean
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface Part {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
chapters: Chapter[]
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
totalSections: number
|
||||
freeSections: number
|
||||
paidSections: number
|
||||
totalParts: number
|
||||
}
|
||||
|
||||
export function ChaptersPage() {
|
||||
const [structure, setStructure] = useState<Part[]>([])
|
||||
const [stats, setStats] = useState<Stats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedParts, setExpandedParts] = useState<string[]>([])
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null)
|
||||
const [editPrice, setEditPrice] = useState<number>(1)
|
||||
|
||||
async function loadChapters() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: { structure?: Part[]; stats?: Stats } }>(
|
||||
'/api/admin/chapters',
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
setStructure(data.data.structure ?? [])
|
||||
setStats(data.data.stats ?? null)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载章节失败:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadChapters()
|
||||
}, [])
|
||||
|
||||
const togglePart = (partId: string) => {
|
||||
setExpandedParts((prev) =>
|
||||
prev.includes(partId) ? prev.filter((id) => id !== partId) : [...prev, partId],
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdatePrice = async (sectionId: string) => {
|
||||
try {
|
||||
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
|
||||
action: 'updatePrice',
|
||||
chapterId: sectionId,
|
||||
data: { price: editPrice },
|
||||
})
|
||||
if (result?.success) {
|
||||
alert('价格更新成功')
|
||||
setEditingSection(null)
|
||||
loadChapters()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('更新价格失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleFree = async (sectionId: string, currentFree: boolean) => {
|
||||
try {
|
||||
const result = await post<{ success?: boolean }>('/api/admin/chapters', {
|
||||
action: 'toggleFree',
|
||||
chapterId: sectionId,
|
||||
data: { isFree: !currentFree },
|
||||
})
|
||||
if (result?.success) {
|
||||
alert('状态更新成功')
|
||||
loadChapters()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('更新状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center">
|
||||
<div className="text-xl text-gray-400">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
{/* 导航栏 */}
|
||||
<div className="sticky top-0 bg-black/90 backdrop-blur border-b border-white/10 z-50">
|
||||
<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={() => setExpandedParts(structure.map((p) => p.id))}
|
||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
|
||||
>
|
||||
展开全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedParts([])}
|
||||
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white"
|
||||
>
|
||||
收起全部
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-gradient-to-br from-cyan-500/20 to-cyan-500/5 border border-cyan-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-cyan-400">{stats.totalSections}</div>
|
||||
<div className="text-white/60 text-sm mt-1">总章节数</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-green-500/20 to-green-500/5 border border-green-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-green-400">{stats.freeSections}</div>
|
||||
<div className="text-white/60 text-sm mt-1">免费章节</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-yellow-500/20 to-yellow-500/5 border border-yellow-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-yellow-400">{stats.paidSections}</div>
|
||||
<div className="text-white/60 text-sm mt-1">付费章节</div>
|
||||
</div>
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-purple-500/5 border border-purple-500/30 rounded-xl p-4">
|
||||
<div className="text-3xl font-bold text-purple-400">{stats.totalParts}</div>
|
||||
<div className="text-white/60 text-sm mt-1">篇章数</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 章节列表 */}
|
||||
<div className="space-y-4">
|
||||
{structure.map((part) => (
|
||||
<div
|
||||
key={part.id}
|
||||
className="bg-white/5 border border-white/10 rounded-xl overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-4 cursor-pointer hover:bg-white/5"
|
||||
onClick={() => togglePart(part.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && togglePart(part.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">
|
||||
{part.type === 'preface'
|
||||
? '📖'
|
||||
: part.type === 'epilogue'
|
||||
? '🎬'
|
||||
: part.type === 'appendix'
|
||||
? '📎'
|
||||
: '📚'}
|
||||
</span>
|
||||
<span className="font-semibold text-white">{part.title}</span>
|
||||
<span className="text-white/40 text-sm">
|
||||
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} 节)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-white/40">{expandedParts.includes(part.id) ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
|
||||
{expandedParts.includes(part.id) && (
|
||||
<div className="border-t border-white/10">
|
||||
{part.chapters.map((chapter) => (
|
||||
<div
|
||||
key={chapter.id}
|
||||
className="border-b border-white/5 last:border-b-0"
|
||||
>
|
||||
{chapter.sections ? (
|
||||
<>
|
||||
<div className="px-6 py-3 bg-white/5 text-white/70 font-medium">
|
||||
{chapter.title}
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{chapter.sections.map((section) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="flex items-center justify-between px-6 py-3 hover:bg-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
section.isFree ? 'text-green-400' : 'text-yellow-400'
|
||||
}
|
||||
>
|
||||
{section.isFree ? '🔓' : '🔒'}
|
||||
</span>
|
||||
<span className="text-white/80">{section.id}</span>
|
||||
<span className="text-white/60">{section.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{editingSection === section.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={editPrice}
|
||||
onChange={(e) => setEditPrice(Number(e.target.value))}
|
||||
className="w-20 px-2 py-1 bg-white/10 border border-white/20 rounded text-white"
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdatePrice(section.id)}
|
||||
className="px-3 py-1 bg-cyan-500 text-black rounded text-sm"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingSection(null)}
|
||||
className="px-3 py-1 bg-white/20 rounded text-sm text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
section.isFree
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{section.isFree ? '免费' : `¥${section.price}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingSection(section.id)
|
||||
setEditPrice(section.price)
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
|
||||
>
|
||||
编辑价格
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleToggleFree(section.id, section.isFree)
|
||||
}
|
||||
className="px-2 py-1 text-xs bg-white/10 rounded hover:bg-white/20 text-white"
|
||||
>
|
||||
{section.isFree ? '设为付费' : '设为免费'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-between px-6 py-3 hover:bg-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={
|
||||
chapter.isFree ? 'text-green-400' : 'text-yellow-400'
|
||||
}
|
||||
>
|
||||
{chapter.isFree ? '🔓' : '🔒'}
|
||||
</span>
|
||||
<span className="text-white/80">{chapter.title}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
chapter.isFree
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-yellow-500/20 text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{chapter.isFree ? '免费' : `¥${chapter.price ?? 1}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1161
soul-admin/src/pages/content/ContentPage.tsx
Normal file
1161
soul-admin/src/pages/content/ContentPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
326
soul-admin/src/pages/dashboard/DashboardPage.tsx
Normal file
326
soul-admin/src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface OrderRow {
|
||||
id: string
|
||||
amount?: number
|
||||
productType?: string
|
||||
productId?: string
|
||||
description?: string
|
||||
userId?: string
|
||||
userNickname?: string
|
||||
userAvatar?: string
|
||||
referrerId?: string
|
||||
referralCode?: string
|
||||
createdAt?: string
|
||||
paymentMethod?: string
|
||||
}
|
||||
|
||||
interface UserRow {
|
||||
id: string
|
||||
nickname?: string
|
||||
phone?: string
|
||||
referral_code?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface UsersRes {
|
||||
success?: boolean
|
||||
users?: UserRow[]
|
||||
}
|
||||
|
||||
interface OrdersRes {
|
||||
success?: boolean
|
||||
orders?: OrderRow[]
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [users, setUsers] = useState<UserRow[]>([])
|
||||
const [purchases, setPurchases] = useState<OrderRow[]>([])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [usersData, ordersData] = await Promise.all([
|
||||
get<UsersRes>('/api/db/users'),
|
||||
get<OrdersRes>('/api/orders'),
|
||||
])
|
||||
if (usersData?.success && usersData.users) setUsers(usersData.users)
|
||||
if (ordersData?.success && ordersData.orders) setPurchases(ordersData.orders)
|
||||
} catch (e) {
|
||||
console.error('加载数据失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalRevenue = purchases.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||||
const totalUsers = users.length
|
||||
const totalPurchases = purchases.length
|
||||
|
||||
const formatOrderProduct = (p: OrderRow) => {
|
||||
const type = p.productType || ''
|
||||
const desc = p.description || ''
|
||||
if (desc) {
|
||||
if (type === 'section' && desc.includes('章节')) {
|
||||
if (desc.includes('-')) {
|
||||
const parts = desc.split('-')
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
title: `第${parts[1]}章 第${parts[2]}节`,
|
||||
subtitle: '《一场Soul的创业实验》',
|
||||
}
|
||||
}
|
||||
}
|
||||
return { title: desc, subtitle: '章节购买' }
|
||||
}
|
||||
if (type === 'fullbook' || desc.includes('全书')) {
|
||||
return { title: '《一场Soul的创业实验》', subtitle: '全书购买' }
|
||||
}
|
||||
if (type === 'match' || desc.includes('伙伴')) {
|
||||
return { title: '找伙伴匹配', subtitle: '功能服务' }
|
||||
}
|
||||
return {
|
||||
title: desc,
|
||||
subtitle: type === 'section' ? '单章' : type === 'fullbook' ? '全书' : '其他',
|
||||
}
|
||||
}
|
||||
if (type === 'section') return { title: `章节 ${p.productId || ''}`, subtitle: '单章购买' }
|
||||
if (type === 'fullbook') return { title: '《一场Soul的创业实验》', subtitle: '全书购买' }
|
||||
if (type === 'match') return { title: '找伙伴匹配', subtitle: '功能服务' }
|
||||
return { title: '未知商品', subtitle: type || '其他' }
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: '总用户数',
|
||||
value: totalUsers,
|
||||
icon: Users,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-500/20',
|
||||
link: '/users',
|
||||
},
|
||||
{
|
||||
title: '总收入',
|
||||
value: `¥${Number(totalRevenue).toFixed(2)}`,
|
||||
icon: TrendingUp,
|
||||
color: 'text-[#38bdac]',
|
||||
bg: 'bg-[#38bdac]/20',
|
||||
link: '/orders',
|
||||
},
|
||||
{
|
||||
title: '订单数',
|
||||
value: totalPurchases,
|
||||
icon: ShoppingBag,
|
||||
color: 'text-purple-400',
|
||||
bg: 'bg-purple-500/20',
|
||||
link: '/orders',
|
||||
},
|
||||
{
|
||||
title: '转化率',
|
||||
value: `${totalUsers > 0 ? ((totalPurchases / totalUsers) * 100).toFixed(1) : 0}%`,
|
||||
icon: BookOpen,
|
||||
color: 'text-orange-400',
|
||||
bg: 'bg-orange-500/20',
|
||||
link: '/distribution',
|
||||
},
|
||||
]
|
||||
|
||||
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">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className="bg-[#0f2137] border-gray-700/50 shadow-xl cursor-pointer hover:border-[#38bdac]/50 transition-colors group"
|
||||
onClick={() => stat.link && navigate(stat.link)}
|
||||
>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-400">{stat.title}</CardTitle>
|
||||
<div className={`p-2 rounded-lg ${stat.bg}`}>
|
||||
<stat.icon className={`w-4 h-4 ${stat.color}`} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold text-white">{stat.value}</div>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600 group-hover:text-[#38bdac] transition-colors" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">最近订单</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{purchases
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((p) => {
|
||||
const referrer: UserRow | undefined = p.referrerId
|
||||
? users.find((u) => u.id === p.referrerId)
|
||||
: undefined
|
||||
const inviteCode =
|
||||
p.referralCode ||
|
||||
referrer?.referral_code ||
|
||||
referrer?.nickname ||
|
||||
(p.referrerId ? String(p.referrerId).slice(0, 8) : '')
|
||||
const product = formatOrderProduct(p)
|
||||
const buyer =
|
||||
p.userNickname ||
|
||||
users.find((u) => u.id === p.userId)?.nickname ||
|
||||
'匿名用户'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-start justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30 hover:border-[#38bdac]/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{p.userAvatar ? (
|
||||
<img
|
||||
src={p.userAvatar}
|
||||
alt={buyer}
|
||||
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
const next = e.currentTarget.nextElementSibling as HTMLElement
|
||||
if (next) next.classList.remove('hidden')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<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 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
|
||||
>
|
||||
{buyer.charAt(0)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm text-gray-300">{buyer}</span>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
{product.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="px-1.5 py-0.5 bg-gray-700/50 rounded">
|
||||
{product.subtitle}
|
||||
</span>
|
||||
<span>
|
||||
{new Date(p.createdAt || 0).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{inviteCode && (
|
||||
<p className="text-xs text-gray-600 mt-1">推荐: {inviteCode}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<p className="text-sm font-bold text-[#38bdac]">
|
||||
+¥{Number(p.amount).toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
{p.paymentMethod || '微信'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{purchases.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ShoppingBag className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无订单数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">新注册用户</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{users
|
||||
.slice(-5)
|
||||
.reverse()
|
||||
.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||
{u.nickname?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{u.nickname || '匿名用户'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{u.phone || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
{u.createdAt
|
||||
? new Date(u.createdAt).toLocaleDateString()
|
||||
: '-'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<p className="text-gray-500 text-center py-8">暂无用户数据</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
954
soul-admin/src/pages/distribution/DistributionPage.tsx
Normal file
954
soul-admin/src/pages/distribution/DistributionPage.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Users,
|
||||
TrendingUp,
|
||||
Clock,
|
||||
Wallet,
|
||||
Search,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Link2,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { get, put } from '@/api/client'
|
||||
|
||||
interface DistributionOverview {
|
||||
todayClicks: number
|
||||
todayBindings: number
|
||||
todayConversions: number
|
||||
todayEarnings: number
|
||||
monthClicks: number
|
||||
monthBindings: number
|
||||
monthConversions: number
|
||||
monthEarnings: number
|
||||
totalClicks: number
|
||||
totalBindings: number
|
||||
totalConversions: number
|
||||
totalEarnings: number
|
||||
expiringBindings: number
|
||||
pendingWithdrawals: number
|
||||
pendingWithdrawAmount: number
|
||||
conversionRate: string
|
||||
totalDistributors: number
|
||||
activeDistributors: number
|
||||
}
|
||||
|
||||
interface Binding {
|
||||
id: string
|
||||
referrer_id: string
|
||||
referrer_name?: string
|
||||
referrer_code: string
|
||||
referee_id: string
|
||||
referee_phone?: string
|
||||
referee_nickname?: string
|
||||
bound_at: string
|
||||
expires_at: string
|
||||
status: 'active' | 'converted' | 'expired' | 'cancelled'
|
||||
commission?: number
|
||||
}
|
||||
|
||||
interface Withdrawal {
|
||||
id: string
|
||||
user_id?: string
|
||||
userId?: string
|
||||
user_name?: string
|
||||
userNickname?: string
|
||||
userPhone?: string
|
||||
userAvatar?: string
|
||||
amount: number
|
||||
method?: 'wechat' | 'alipay'
|
||||
account?: string
|
||||
name?: string
|
||||
status: string
|
||||
created_at?: string
|
||||
createdAt?: string
|
||||
processedAt?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
nickname: string
|
||||
phone: string
|
||||
referral_code: string
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
userId: string
|
||||
userNickname?: string
|
||||
userPhone?: string
|
||||
productType?: string
|
||||
type?: string
|
||||
productId?: string
|
||||
sectionId?: string
|
||||
bookName?: string
|
||||
chapterTitle?: string
|
||||
sectionTitle?: string
|
||||
amount: number
|
||||
status: string
|
||||
paymentMethod?: string
|
||||
referrerEarnings?: number
|
||||
referrerId?: string | null
|
||||
referrerNickname?: string | null
|
||||
referrerCode?: string | null
|
||||
referralCode?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function DistributionPage() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'orders' | 'bindings' | 'withdrawals'>(
|
||||
'overview',
|
||||
)
|
||||
const [orders, setOrders] = useState<Order[]>([])
|
||||
const [overview, setOverview] = useState<DistributionOverview | null>(null)
|
||||
const [bindings, setBindings] = useState<Binding[]>([])
|
||||
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
loadInitialData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData(activeTab)
|
||||
}, [activeTab])
|
||||
|
||||
async function loadInitialData() {
|
||||
try {
|
||||
const overviewData = await get<{ success?: boolean; overview?: DistributionOverview }>(
|
||||
'/api/admin/distribution/overview',
|
||||
)
|
||||
if (overviewData?.success && overviewData.overview) setOverview(overviewData.overview)
|
||||
} catch (e) {
|
||||
console.error('[Admin] 概览接口异常:', e)
|
||||
}
|
||||
try {
|
||||
const usersData = await get<{ success?: boolean; users?: User[] }>('/api/db/users')
|
||||
setUsers(usersData?.users || [])
|
||||
} catch (e) {
|
||||
console.error('[Admin] 用户数据加载失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTabData(tab: string) {
|
||||
if (loadedTabs.has(tab)) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const usersArr = users
|
||||
switch (tab) {
|
||||
case 'overview':
|
||||
break
|
||||
case 'orders': {
|
||||
try {
|
||||
const ordersData = await get<{ success?: boolean; orders?: Order[] }>('/api/orders')
|
||||
if (ordersData?.success && ordersData.orders) {
|
||||
const enriched = ordersData.orders.map((order) => {
|
||||
const user = usersArr.find((u) => u.id === order.userId)
|
||||
const referrer = order.referrerId
|
||||
? usersArr.find((u) => u.id === order.referrerId)
|
||||
: null
|
||||
return {
|
||||
...order,
|
||||
amount: parseFloat(String(order.amount)) || 0,
|
||||
userNickname: user?.nickname || order.userNickname || '未知用户',
|
||||
userPhone: user?.phone || order.userPhone || '-',
|
||||
referrerNickname: referrer?.nickname || null,
|
||||
referrerCode: referrer?.referral_code || null,
|
||||
type: order.productType || order.type,
|
||||
}
|
||||
})
|
||||
setOrders(enriched)
|
||||
} else setOrders([])
|
||||
} catch {
|
||||
setOrders([])
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'bindings': {
|
||||
try {
|
||||
const bindingsData = await get<{ success?: boolean; bindings?: Binding[] }>(
|
||||
'/api/db/distribution',
|
||||
)
|
||||
setBindings(bindingsData?.bindings || [])
|
||||
} catch {
|
||||
setBindings([])
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'withdrawals': {
|
||||
try {
|
||||
const withdrawalsData = await get<{
|
||||
success?: boolean
|
||||
withdrawals?: Withdrawal[]
|
||||
error?: string
|
||||
}>('/api/admin/withdrawals')
|
||||
if (withdrawalsData?.success && withdrawalsData.withdrawals) {
|
||||
const formatted = withdrawalsData.withdrawals.map((w) => ({
|
||||
...w,
|
||||
user_name: w.userNickname ?? w.user_name,
|
||||
created_at: w.created_at ?? w.createdAt,
|
||||
completed_at: w.processedAt ?? w.completed_at,
|
||||
account: w.account ?? '未绑定微信号',
|
||||
status:
|
||||
w.status === 'success' ? 'completed' : w.status === 'failed' ? 'rejected' : w.status,
|
||||
}))
|
||||
setWithdrawals(formatted)
|
||||
} else {
|
||||
if (!withdrawalsData?.success)
|
||||
alert(
|
||||
`获取提现记录失败: ${(withdrawalsData as { error?: string })?.error || '未知错误'}`,
|
||||
)
|
||||
setWithdrawals([])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('加载提现数据失败')
|
||||
setWithdrawals([])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
setLoadedTabs((prev) => new Set(prev).add(tab))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentTab() {
|
||||
setLoadedTabs((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(activeTab)
|
||||
return next
|
||||
})
|
||||
if (activeTab === 'overview') loadInitialData()
|
||||
loadTabData(activeTab)
|
||||
}
|
||||
|
||||
async function handleApproveWithdrawal(id: string) {
|
||||
if (!confirm('确认审核通过并打款?')) return
|
||||
try {
|
||||
await put('/api/admin/withdrawals', { id, action: 'approve' })
|
||||
refreshCurrentTab()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectWithdrawal(id: string) {
|
||||
const reason = prompt('请输入拒绝原因:')
|
||||
if (!reason) return
|
||||
try {
|
||||
await put('/api/admin/withdrawals', { id, action: 'reject', errorMessage: reason })
|
||||
refreshCurrentTab()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const styles: Record<string, string> = {
|
||||
active: 'bg-green-500/20 text-green-400',
|
||||
converted: 'bg-blue-500/20 text-blue-400',
|
||||
expired: 'bg-gray-500/20 text-gray-400',
|
||||
cancelled: 'bg-red-500/20 text-red-400',
|
||||
pending: 'bg-orange-500/20 text-orange-400',
|
||||
processing: 'bg-blue-500/20 text-blue-400',
|
||||
completed: 'bg-green-500/20 text-green-400',
|
||||
rejected: 'bg-red-500/20 text-red-400',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
active: '有效',
|
||||
converted: '已转化',
|
||||
expired: '已过期',
|
||||
cancelled: '已取消',
|
||||
pending: '待审核',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
rejected: '已拒绝',
|
||||
}
|
||||
return (
|
||||
<Badge className={`${styles[status] || 'bg-gray-500/20 text-gray-400'} border-0`}>
|
||||
{labels[status] || status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const filteredBindings = bindings.filter((b) => {
|
||||
if (statusFilter !== 'all' && b.status !== statusFilter) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
b.referee_nickname?.toLowerCase().includes(term) ||
|
||||
b.referee_phone?.includes(term) ||
|
||||
b.referrer_name?.toLowerCase().includes(term) ||
|
||||
b.referrer_code?.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const filteredWithdrawals = withdrawals.filter((w) => {
|
||||
if (statusFilter !== 'all' && w.status !== statusFilter) return false
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase()
|
||||
return (
|
||||
w.user_name?.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
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">交易中心</h1>
|
||||
<p className="text-gray-400 mt-1">统一管理:订单、分销绑定、提现审核</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={refreshCurrentTab}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
className="border-gray-700 text-gray-300 hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6 border-b border-gray-700 pb-4">
|
||||
{[
|
||||
{ key: 'overview', label: '数据概览', icon: TrendingUp },
|
||||
{ key: 'orders', label: '订单管理', icon: DollarSign },
|
||||
{ key: 'bindings', label: '绑定管理', icon: Link2 },
|
||||
{ key: 'withdrawals', label: '提现审核', icon: Wallet },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTab(tab.key as typeof activeTab)
|
||||
setStatusFilter('all')
|
||||
setSearchTerm('')
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === tab.key
|
||||
? 'bg-[#38bdac] text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw className="w-8 h-8 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'overview' && overview && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日点击</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayClicks}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||
<Eye className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日绑定</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayBindings}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
|
||||
<Link2 className="w-6 h-6 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日转化</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{overview.todayConversions}</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<CheckCircle className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-400 text-sm">今日佣金</p>
|
||||
<p className="text-2xl font-bold text-[#38bdac] mt-1">
|
||||
¥{overview.todayEarnings.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-[#38bdac]/20 flex items-center justify-center">
|
||||
<DollarSign className="w-6 h-6 text-[#38bdac]" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card className="bg-orange-500/10 border-orange-500/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center">
|
||||
<Clock className="w-6 h-6 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-orange-300 font-medium">即将过期绑定</p>
|
||||
<p className="text-2xl font-bold text-white">{overview.expiringBindings} 个</p>
|
||||
<p className="text-orange-300/60 text-sm">7天内到期,需关注转化</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-blue-500/10 border-blue-500/30">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
|
||||
<Wallet className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-blue-300 font-medium">待审核提现</p>
|
||||
<p className="text-2xl font-bold text-white">{overview.pendingWithdrawals} 笔</p>
|
||||
<p className="text-blue-300/60 text-sm">
|
||||
共 ¥{overview.pendingWithdrawAmount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setActiveTab('withdrawals')}
|
||||
variant="outline"
|
||||
className="border-blue-500/50 text-blue-400 hover:bg-blue-500/20"
|
||||
>
|
||||
去审核
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-[#38bdac]" />
|
||||
本月统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">点击量</p>
|
||||
<p className="text-xl font-bold text-white">{overview.monthClicks}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">绑定数</p>
|
||||
<p className="text-xl font-bold text-white">{overview.monthBindings}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">转化数</p>
|
||||
<p className="text-xl font-bold text-white">{overview.monthConversions}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">佣金</p>
|
||||
<p className="text-xl font-bold text-[#38bdac]">
|
||||
¥{overview.monthEarnings.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
|
||||
累计统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总点击</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{overview.totalClicks.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总绑定</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{overview.totalBindings.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总转化</p>
|
||||
<p className="text-xl font-bold text-white">{overview.totalConversions}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg">
|
||||
<p className="text-gray-400 text-sm">总佣金</p>
|
||||
<p className="text-xl font-bold text-[#38bdac]">
|
||||
¥{overview.totalEarnings.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-4 bg-[#38bdac]/10 rounded-lg flex items-center justify-between">
|
||||
<span className="text-gray-300">点击转化率</span>
|
||||
<span className="text-[#38bdac] font-bold text-xl">
|
||||
{overview.conversionRate}%
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||
推广统计
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-white">{overview.totalDistributors}</p>
|
||||
<p className="text-gray-400 text-sm mt-1">推广用户数</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-green-400">{overview.activeDistributors}</p>
|
||||
<p className="text-gray-400 text-sm mt-1">有收益用户</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-[#38bdac]">90%</p>
|
||||
<p className="text-gray-400 text-sm mt-1">佣金比例</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white/5 rounded-lg text-center">
|
||||
<p className="text-3xl font-bold text-orange-400">30天</p>
|
||||
<p className="text-gray-400 text-sm mt-1">绑定有效期</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'orders' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜索订单号、用户名、手机号..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="pending">待支付</option>
|
||||
<option value="failed">已失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{orders.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无订单数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-4 text-left font-medium">订单号</th>
|
||||
<th className="p-4 text-left font-medium">用户</th>
|
||||
<th className="p-4 text-left font-medium">商品</th>
|
||||
<th className="p-4 text-left font-medium">金额</th>
|
||||
<th className="p-4 text-left font-medium">支付方式</th>
|
||||
<th className="p-4 text-left font-medium">状态</th>
|
||||
<th className="p-4 text-left font-medium">推荐人/邀请码</th>
|
||||
<th className="p-4 text-left font-medium">分销佣金</th>
|
||||
<th className="p-4 text-left font-medium">下单时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredOrders.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)}...
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white text-sm">{order.userNickname}</p>
|
||||
<p className="text-gray-500 text-xs">{order.userPhone}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white text-sm">
|
||||
{(() => {
|
||||
const type = order.productType || order.type
|
||||
if (type === 'fullbook')
|
||||
return `${order.bookName || '《底层逻辑》'} - 全本`
|
||||
if (type === 'match') return '匹配次数购买'
|
||||
return `${order.bookName || '《底层逻辑》'} - ${order.sectionTitle || order.chapterTitle || `章节${order.productId || order.sectionId || ''}`}`
|
||||
})()}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{(() => {
|
||||
const type = order.productType || order.type
|
||||
if (type === 'fullbook') return '全书解锁'
|
||||
if (type === 'match') return '功能权益'
|
||||
return order.chapterTitle || '单章购买'
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-[#38bdac] font-bold">
|
||||
¥{typeof order.amount === 'number' ? order.amount.toFixed(2) : parseFloat(String(order.amount || '0')).toFixed(2)}
|
||||
</td>
|
||||
<td className="p-4 text-gray-300">
|
||||
{order.paymentMethod === 'wechat'
|
||||
? '微信支付'
|
||||
: order.paymentMethod === 'alipay'
|
||||
? '支付宝'
|
||||
: order.paymentMethod || '微信支付'}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{order.status === 'completed' || order.status === 'paid' ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">
|
||||
已完成
|
||||
</Badge>
|
||||
) : order.status === 'pending' || order.status === 'created' ? (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
|
||||
待支付
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-0">
|
||||
已失败
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-gray-300 text-sm">
|
||||
{order.referrerId || order.referralCode ? (
|
||||
<span
|
||||
title={
|
||||
order.referralCode ||
|
||||
order.referrerCode ||
|
||||
order.referrerId ||
|
||||
''
|
||||
}
|
||||
>
|
||||
{order.referrerNickname ||
|
||||
order.referralCode ||
|
||||
order.referrerCode ||
|
||||
order.referrerId?.slice(0, 8)}
|
||||
{(order.referralCode || order.referrerCode) &&
|
||||
` (${order.referralCode || order.referrerCode})`}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-[#FFD700]">
|
||||
{order.referrerEarnings
|
||||
? `¥${(typeof order.referrerEarnings === 'number' ? order.referrerEarnings : parseFloat(String(order.referrerEarnings))).toFixed(2)}`
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400 text-sm">
|
||||
{order.createdAt
|
||||
? new Date(order.createdAt).toLocaleString('zh-CN')
|
||||
: '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'bindings' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜索用户昵称、手机号、推广码..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">有效</option>
|
||||
<option value="converted">已转化</option>
|
||||
<option value="expired">已过期</option>
|
||||
</select>
|
||||
</div>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{filteredBindings.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无绑定数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-4 text-left font-medium">访客</th>
|
||||
<th className="p-4 text-left font-medium">分销商</th>
|
||||
<th className="p-4 text-left font-medium">绑定时间</th>
|
||||
<th className="p-4 text-left font-medium">到期时间</th>
|
||||
<th className="p-4 text-left font-medium">状态</th>
|
||||
<th className="p-4 text-left font-medium">佣金</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredBindings.map((binding) => (
|
||||
<tr key={binding.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white font-medium">
|
||||
{binding.referee_nickname || '匿名用户'}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">{binding.referee_phone}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white">{binding.referrer_name || '-'}</p>
|
||||
<p className="text-gray-500 text-xs font-mono">
|
||||
{binding.referrer_code}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{binding.bound_at
|
||||
? new Date(binding.bound_at).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{binding.expires_at
|
||||
? new Date(binding.expires_at).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-4">{getStatusBadge(binding.status)}</td>
|
||||
<td className="p-4">
|
||||
{binding.commission ? (
|
||||
<span className="text-[#38bdac] font-medium">
|
||||
¥{binding.commission.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'withdrawals' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜索用户名称、账号..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">待审核</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="rejected">已拒绝</option>
|
||||
</select>
|
||||
</div>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-0">
|
||||
{filteredWithdrawals.length === 0 ? (
|
||||
<div className="py-12 text-center text-gray-500">暂无提现记录</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-4 text-left font-medium">申请人</th>
|
||||
<th className="p-4 text-left font-medium">金额</th>
|
||||
<th className="p-4 text-left font-medium">收款方式</th>
|
||||
<th className="p-4 text-left font-medium">收款账号</th>
|
||||
<th className="p-4 text-left font-medium">申请时间</th>
|
||||
<th className="p-4 text-left font-medium">状态</th>
|
||||
<th className="p-4 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{filteredWithdrawals.map((withdrawal) => (
|
||||
<tr key={withdrawal.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{withdrawal.userAvatar ? (
|
||||
<img
|
||||
src={withdrawal.userAvatar}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
{(withdrawal.user_name || withdrawal.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-white font-medium">
|
||||
{withdrawal.user_name || withdrawal.name}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="text-[#38bdac] font-bold">
|
||||
¥{withdrawal.amount.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<Badge
|
||||
className={
|
||||
withdrawal.method === 'wechat'
|
||||
? 'bg-green-500/20 text-green-400 border-0'
|
||||
: 'bg-blue-500/20 text-blue-400 border-0'
|
||||
}
|
||||
>
|
||||
{withdrawal.method === 'wechat' ? '微信' : '支付宝'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<p className="text-white font-mono text-xs">
|
||||
{withdrawal.account}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">{withdrawal.name}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{(withdrawal.created_at || withdrawal.createdAt)
|
||||
? new Date(
|
||||
withdrawal.created_at || withdrawal.createdAt || '',
|
||||
).toLocaleString('zh-CN')
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-4">{getStatusBadge(withdrawal.status)}</td>
|
||||
<td className="p-4 text-right">
|
||||
{withdrawal.status === 'pending' && (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveWithdrawal(withdrawal.id)}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-1" />
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRejectWithdrawal(withdrawal.id)}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
soul-admin/src/pages/login/LoginPage.tsx
Normal file
105
soul-admin/src/pages/login/LoginPage.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Lock, User, ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { post } from '@/api/client'
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/admin', {
|
||||
username: username.trim(),
|
||||
password,
|
||||
})
|
||||
if (data?.success !== false) {
|
||||
navigate('/dashboard', { replace: true })
|
||||
return
|
||||
}
|
||||
setError((data as { error?: string }).error || '用户名或密码错误')
|
||||
} catch (e: unknown) {
|
||||
const err = e as { status?: number; message?: string }
|
||||
setError(err.status === 401 ? '用户名或密码错误' : err?.message || '网络错误,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a1628] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-[#38bdac]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-blue-500/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md relative z-10">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[#38bdac]/20 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-[#38bdac]/30">
|
||||
<ShieldCheck className="w-8 h-8 text-[#38bdac]" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">管理后台</h1>
|
||||
<p className="text-gray-400">一场SOUL的创业实验场</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#0f2137] rounded-2xl p-8 shadow-xl border border-gray-700/50 backdrop-blur-xl">
|
||||
<h2 className="text-xl font-semibold text-white mb-6 text-center">管理员登录</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">用户名</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-gray-400 text-sm mb-2">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="请输入密码"
|
||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 text-red-400 text-sm p-3 rounded-lg border border-red-500/20">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleLogin}
|
||||
disabled={loading}
|
||||
className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white py-5 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-gray-500 text-xs mt-6">Soul创业实验场 · 后台管理系统</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
575
soul-admin/src/pages/match/MatchPage.tsx
Normal file
575
soul-admin/src/pages/match/MatchPage.tsx
Normal file
@@ -0,0 +1,575 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Edit3,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface MatchType {
|
||||
id: string
|
||||
label: string
|
||||
matchLabel: string
|
||||
icon: string
|
||||
matchFromDB: boolean
|
||||
showJoinAfterMatch: boolean
|
||||
price: number
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface MatchConfig {
|
||||
matchTypes: MatchType[]
|
||||
freeMatchLimit: number
|
||||
matchPrice: number
|
||||
settings: {
|
||||
enableFreeMatches: boolean
|
||||
enablePaidMatches: boolean
|
||||
maxMatchesPerDay: number
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: MatchConfig = {
|
||||
matchTypes: [
|
||||
{
|
||||
id: 'partner',
|
||||
label: '创业合伙',
|
||||
matchLabel: '创业伙伴',
|
||||
icon: '⭐',
|
||||
matchFromDB: true,
|
||||
showJoinAfterMatch: false,
|
||||
price: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'investor',
|
||||
label: '资源对接',
|
||||
matchLabel: '资源对接',
|
||||
icon: '👥',
|
||||
matchFromDB: false,
|
||||
showJoinAfterMatch: true,
|
||||
price: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'mentor',
|
||||
label: '导师顾问',
|
||||
matchLabel: '商业顾问',
|
||||
icon: '❤️',
|
||||
matchFromDB: false,
|
||||
showJoinAfterMatch: true,
|
||||
price: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'team',
|
||||
label: '团队招募',
|
||||
matchLabel: '加入项目',
|
||||
icon: '🎮',
|
||||
matchFromDB: false,
|
||||
showJoinAfterMatch: true,
|
||||
price: 1,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
freeMatchLimit: 3,
|
||||
matchPrice: 1,
|
||||
settings: {
|
||||
enableFreeMatches: true,
|
||||
enablePaidMatches: true,
|
||||
maxMatchesPerDay: 10,
|
||||
},
|
||||
}
|
||||
|
||||
const ICONS = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨']
|
||||
|
||||
export function MatchPage() {
|
||||
const [config, setConfig] = useState<MatchConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showTypeModal, setShowTypeModal] = useState(false)
|
||||
const [editingType, setEditingType] = useState<MatchType | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
id: '',
|
||||
label: '',
|
||||
matchLabel: '',
|
||||
icon: '⭐',
|
||||
matchFromDB: false,
|
||||
showJoinAfterMatch: true,
|
||||
price: 1,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: MatchConfig; config?: MatchConfig }>(
|
||||
'/api/db/config?key=match_config',
|
||||
)
|
||||
const c = (data as { data?: MatchConfig })?.data ?? (data as { config?: MatchConfig })?.config
|
||||
if (c) setConfig({ ...DEFAULT_CONFIG, ...c })
|
||||
} catch (e) {
|
||||
console.error('加载匹配配置失败:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
|
||||
key: 'match_config',
|
||||
value: config,
|
||||
description: '匹配功能配置',
|
||||
})
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
alert('配置保存成功!')
|
||||
} else {
|
||||
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('保存配置失败:', e)
|
||||
alert('保存失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditType = (type: MatchType) => {
|
||||
setEditingType(type)
|
||||
setFormData({
|
||||
id: type.id,
|
||||
label: type.label,
|
||||
matchLabel: type.matchLabel,
|
||||
icon: type.icon,
|
||||
matchFromDB: type.matchFromDB,
|
||||
showJoinAfterMatch: type.showJoinAfterMatch,
|
||||
price: type.price,
|
||||
enabled: type.enabled,
|
||||
})
|
||||
setShowTypeModal(true)
|
||||
}
|
||||
|
||||
const handleAddType = () => {
|
||||
setEditingType(null)
|
||||
setFormData({
|
||||
id: '',
|
||||
label: '',
|
||||
matchLabel: '',
|
||||
icon: '⭐',
|
||||
matchFromDB: false,
|
||||
showJoinAfterMatch: true,
|
||||
price: 1,
|
||||
enabled: true,
|
||||
})
|
||||
setShowTypeModal(true)
|
||||
}
|
||||
|
||||
const handleSaveType = () => {
|
||||
if (!formData.id || !formData.label) {
|
||||
alert('请填写类型ID和名称')
|
||||
return
|
||||
}
|
||||
const newTypes = [...config.matchTypes]
|
||||
if (editingType) {
|
||||
const index = newTypes.findIndex((t) => t.id === editingType.id)
|
||||
if (index !== -1) newTypes[index] = { ...formData }
|
||||
} else {
|
||||
if (newTypes.some((t) => t.id === formData.id)) {
|
||||
alert('类型ID已存在')
|
||||
return
|
||||
}
|
||||
newTypes.push({ ...formData })
|
||||
}
|
||||
setConfig({ ...config, matchTypes: newTypes })
|
||||
setShowTypeModal(false)
|
||||
}
|
||||
|
||||
const handleDeleteType = (typeId: string) => {
|
||||
if (!confirm('确定要删除这个匹配类型吗?')) return
|
||||
setConfig({
|
||||
...config,
|
||||
matchTypes: config.matchTypes.filter((t) => t.id !== typeId),
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleType = (typeId: string) => {
|
||||
setConfig({
|
||||
...config,
|
||||
matchTypes: config.matchTypes.map((t) =>
|
||||
t.id === typeId ? { ...t, enabled: !t.enabled } : t,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-6xl mx-auto space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Settings className="w-6 h-6 text-[#38bdac]" />
|
||||
匹配功能配置
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">管理找伙伴功能的匹配类型和价格</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadConfig}
|
||||
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>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-yellow-400" />
|
||||
基础设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
配置免费匹配次数和付费规则
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">每日免费匹配次数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={config.freeMatchLimit}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, freeMatchLimit: parseInt(e.target.value, 10) || 0 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">用户每天可免费匹配的次数</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">付费匹配价格(元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={config.matchPrice}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, matchPrice: parseFloat(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">免费次数用完后的单次匹配价格</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">每日最大匹配次数</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={config.settings.maxMatchesPerDay}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
settings: {
|
||||
...config.settings,
|
||||
maxMatchesPerDay: parseInt(e.target.value, 10) || 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">包含免费和付费的总次数</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-8 pt-4 border-t border-gray-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={config.settings.enableFreeMatches}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig({
|
||||
...config,
|
||||
settings: { ...config.settings, enableFreeMatches: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-gray-300">启用免费匹配</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={config.settings.enablePaidMatches}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig({
|
||||
...config,
|
||||
settings: { ...config.settings, enablePaidMatches: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-gray-300">启用付费匹配</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||
匹配类型管理
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
配置不同的匹配类型及其价格
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleAddType}
|
||||
size="sm"
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加类型
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">图标</TableHead>
|
||||
<TableHead className="text-gray-400">类型ID</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>
|
||||
<TableHead className="text-gray-400">状态</TableHead>
|
||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{config.matchTypes.map((type) => (
|
||||
<TableRow key={type.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<span className="text-2xl">{type.icon}</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-gray-300">{type.id}</TableCell>
|
||||
<TableCell className="text-white font-medium">{type.label}</TableCell>
|
||||
<TableCell className="text-gray-300">{type.matchLabel}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
|
||||
¥{type.price}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{type.matchFromDB ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||||
是
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-600">
|
||||
否
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={type.enabled}
|
||||
onCheckedChange={() => handleToggleType(type.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditType(type)}
|
||||
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteType(type.id)}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showTypeModal} onOpenChange={setShowTypeModal}>
|
||||
<DialogContent
|
||||
className="bg-[#0f2137] border-gray-700 text-white max-w-lg"
|
||||
showCloseButton
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
{editingType ? (
|
||||
<Edit3 className="w-5 h-5 text-[#38bdac]" />
|
||||
) : (
|
||||
<Plus className="w-5 h-5 text-[#38bdac]" />
|
||||
)}
|
||||
{editingType ? '编辑匹配类型' : '添加匹配类型'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">类型ID(英文)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如: partner"
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
disabled={!!editingType}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">图标</Label>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{ICONS.map((icon) => (
|
||||
<button
|
||||
key={icon}
|
||||
type="button"
|
||||
className={`w-8 h-8 text-lg rounded ${
|
||||
formData.icon === icon ? 'bg-[#38bdac]/30 ring-1 ring-[#38bdac]' : 'bg-[#0a1628]'
|
||||
}`}
|
||||
onClick={() => setFormData({ ...formData, icon })}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">显示名称</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如: 创业合伙"
|
||||
value={formData.label}
|
||||
onChange={(e) => setFormData({ ...formData, label: 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="如: 创业伙伴"
|
||||
value={formData.matchLabel}
|
||||
onChange={(e) => setFormData({ ...formData, matchLabel: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">单次匹配价格(元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={formData.price}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, price: parseFloat(e.target.value) || 1 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-6 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.matchFromDB}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, matchFromDB: checked })}
|
||||
/>
|
||||
<Label className="text-gray-300 text-sm">从数据库匹配</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.showJoinAfterMatch}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, showJoinAfterMatch: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-gray-300 text-sm">匹配后显示加入</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, enabled: checked })}
|
||||
/>
|
||||
<Label className="text-gray-300 text-sm">启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowTypeModal(false)}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveType} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
271
soul-admin/src/pages/orders/OrdersPage.tsx
Normal file
271
soul-admin/src/pages/orders/OrdersPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, RefreshCw, Download, Filter } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface Purchase {
|
||||
id: string
|
||||
userId: string
|
||||
type?: 'section' | 'fullbook' | 'match'
|
||||
sectionId?: string
|
||||
sectionTitle?: string
|
||||
productId?: string
|
||||
amount: number
|
||||
status: 'pending' | 'completed' | 'failed' | 'paid' | 'created'
|
||||
paymentMethod?: string
|
||||
referrerEarnings?: number
|
||||
createdAt: string
|
||||
orderSn?: string
|
||||
userNickname?: string
|
||||
productType?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface UsersItem {
|
||||
id: string
|
||||
nickname?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export function OrdersPage() {
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([])
|
||||
const [users, setUsers] = useState<UsersItem[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
async function loadOrders() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [ordersData, usersData] = await Promise.all([
|
||||
get<{ success?: boolean; orders?: Purchase[] }>('/api/orders'),
|
||||
get<{ success?: boolean; users?: UsersItem[] }>('/api/db/users'),
|
||||
])
|
||||
if (ordersData?.success && ordersData.orders) setPurchases(ordersData.orders)
|
||||
if (usersData?.success && usersData.users) setUsers(usersData.users)
|
||||
} catch (e) {
|
||||
console.error('加载订单失败', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders()
|
||||
}, [])
|
||||
|
||||
const getUserNickname = (order: Purchase) =>
|
||||
order.userNickname || users.find((u) => u.id === order.userId)?.nickname || '匿名用户'
|
||||
|
||||
const getUserPhone = (userId: string) => users.find((u) => u.id === userId)?.phone || '-'
|
||||
|
||||
const formatProduct = (order: Purchase) => {
|
||||
const type = order.productType || order.type || ''
|
||||
const desc = order.description || ''
|
||||
if (desc) {
|
||||
if (type === 'section' && desc.includes('章节')) {
|
||||
if (desc.includes('-')) {
|
||||
const parts = desc.split('-')
|
||||
if (parts.length >= 3) {
|
||||
return { name: `第${parts[1]}章 第${parts[2]}节`, type: '《一场Soul的创业实验》' }
|
||||
}
|
||||
}
|
||||
return { name: desc, type: '章节购买' }
|
||||
}
|
||||
if (type === 'fullbook' || desc.includes('全书')) {
|
||||
return { name: '《一场Soul的创业实验》', type: '全书购买' }
|
||||
}
|
||||
if (type === 'match' || desc.includes('伙伴')) {
|
||||
return { name: '找伙伴匹配', type: '功能服务' }
|
||||
}
|
||||
return { name: desc, type: '其他' }
|
||||
}
|
||||
if (type === 'section') return { name: `章节 ${order.productId || order.sectionId || ''}`, type: '单章' }
|
||||
if (type === 'fullbook') return { name: '《一场Soul的创业实验》', type: '全书' }
|
||||
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 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
|
||||
)
|
||||
})
|
||||
.reduce((sum, p) => sum + Number(p.amount || 0), 0)
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<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">
|
||||
<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>
|
||||
<span className="text-gray-600">|</span>
|
||||
<span className="text-gray-400">今日:</span>
|
||||
<span className="text-[#FFD700] font-bold">¥{todayRevenue.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索订单号/用户/章节..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="pending">待支付</option>
|
||||
<option value="created">已创建</option>
|
||||
<option value="failed">已失败</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center 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>
|
||||
<TableHead className="text-gray-400">状态</TableHead>
|
||||
<TableHead className="text-gray-400">分销佣金</TableHead>
|
||||
<TableHead className="text-gray-400">下单时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPurchases.map((purchase) => {
|
||||
const product = formatProduct(purchase)
|
||||
return (
|
||||
<TableRow key={purchase.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell className="font-mono text-xs text-gray-400">
|
||||
{(purchase.orderSn || purchase.id || '').slice(0, 12)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-white text-sm">{getUserNickname(purchase)}</p>
|
||||
<p className="text-gray-500 text-xs">{getUserPhone(purchase.userId)}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-white text-sm">{product.name}</p>
|
||||
<p className="text-gray-500 text-xs">{product.type}</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-[#38bdac] font-bold">
|
||||
¥{Number(purchase.amount || 0).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{purchase.paymentMethod === 'wechat'
|
||||
? '微信支付'
|
||||
: purchase.paymentMethod === 'alipay'
|
||||
? '支付宝'
|
||||
: purchase.paymentMethod || '微信支付'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{purchase.status === 'paid' || purchase.status === 'completed' ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||||
已完成
|
||||
</Badge>
|
||||
) : purchase.status === 'pending' || purchase.status === 'created' ? (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/20 border-0">
|
||||
待支付
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
|
||||
已失败
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#FFD700]">
|
||||
{purchase.referrerEarnings
|
||||
? `¥${Number(purchase.referrerEarnings).toFixed(2)}`
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400 text-sm">
|
||||
{new Date(purchase.createdAt).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
{filteredPurchases.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
|
||||
暂无订单数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
418
soul-admin/src/pages/payment/PaymentPage.tsx
Normal file
418
soul-admin/src/pages/payment/PaymentPage.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Save,
|
||||
RefreshCw,
|
||||
Smartphone,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
Bitcoin,
|
||||
Globe,
|
||||
Copy,
|
||||
Check,
|
||||
HelpCircle,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface PaymentMethods {
|
||||
wechat: Record<string, unknown>
|
||||
alipay: Record<string, unknown>
|
||||
usdt: Record<string, unknown>
|
||||
paypal: Record<string, unknown>
|
||||
}
|
||||
|
||||
const defaultPayment: PaymentMethods = {
|
||||
wechat: {
|
||||
enabled: true,
|
||||
qrCode: '/images/wechat-pay.png',
|
||||
account: '卡若',
|
||||
websiteAppId: '',
|
||||
merchantId: '',
|
||||
groupQrCode: '/images/party-group-qr.png',
|
||||
},
|
||||
alipay: {
|
||||
enabled: true,
|
||||
qrCode: '/images/alipay.png',
|
||||
account: '卡若',
|
||||
partnerId: '',
|
||||
securityKey: '',
|
||||
},
|
||||
usdt: { enabled: false, network: 'TRC20', address: '', exchangeRate: 7.2 },
|
||||
paypal: { enabled: false, email: '', exchangeRate: 7.2 },
|
||||
}
|
||||
|
||||
export function PaymentPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [localSettings, setLocalSettings] = useState<PaymentMethods>(defaultPayment)
|
||||
const [copied, setCopied] = useState('')
|
||||
|
||||
const loadConfig = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await get<{ paymentMethods?: PaymentMethods }>('/api/config')
|
||||
if (data?.paymentMethods)
|
||||
setLocalSettings({ ...defaultPayment, ...data.paymentMethods })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 保存到后端
|
||||
await post('/api/config', { paymentMethods: localSettings })
|
||||
alert('配置已保存!')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
alert('保存失败: ' + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = (text: string, field: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(field)
|
||||
setTimeout(() => setCopied(''), 2000)
|
||||
}
|
||||
|
||||
const updateWechat = (field: string, value: unknown) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
wechat: { ...prev.wechat, [field]: value },
|
||||
}))
|
||||
}
|
||||
const updateAlipay = (field: string, value: unknown) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
alipay: { ...prev.alipay, [field]: value },
|
||||
}))
|
||||
}
|
||||
const updateUsdt = (field: string, value: unknown) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
usdt: { ...prev.usdt, [field]: value },
|
||||
}))
|
||||
}
|
||||
const updatePaypal = (field: string, value: unknown) => {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
paypal: { ...prev.paypal, [field]: value },
|
||||
}))
|
||||
}
|
||||
|
||||
const w = localSettings.wechat as Record<string, unknown>
|
||||
const a = localSettings.alipay as Record<string, unknown>
|
||||
const u = localSettings.usdt as Record<string, unknown>
|
||||
const p = localSettings.paypal as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2 text-white">支付配置</h1>
|
||||
<p className="text-gray-400">配置微信、支付宝、USDT、PayPal等支付参数</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadConfig}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
同步配置
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 bg-[#07C160]/10 border border-[#07C160]/30 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<HelpCircle className="w-5 h-5 text-[#07C160] flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium mb-2 text-[#07C160]">如何获取微信群跳转链接?</p>
|
||||
<ol className="text-[#07C160]/80 space-y-1 list-decimal list-inside">
|
||||
<li>打开微信,进入目标微信群</li>
|
||||
<li>点击右上角"..." → "群二维码"</li>
|
||||
<li>点击右上角"..." → "发送到电脑"</li>
|
||||
<li>在电脑上保存二维码图片,上传到图床获取URL</li>
|
||||
<li>或使用草料二维码等工具解析二维码获取链接</li>
|
||||
</ol>
|
||||
<p className="text-[#07C160]/60 mt-2">提示:微信群二维码7天后失效,建议使用活码工具</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="wechat" className="space-y-6">
|
||||
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1 grid grid-cols-4 w-full">
|
||||
<TabsTrigger
|
||||
value="wechat"
|
||||
className="data-[state=active]:bg-[#07C160]/20 data-[state=active]:text-[#07C160] text-gray-400"
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-2" />
|
||||
微信
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="alipay"
|
||||
className="data-[state=active]:bg-[#1677FF]/20 data-[state=active]:text-[#1677FF] text-gray-400"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
支付宝
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="usdt"
|
||||
className="data-[state=active]:bg-[#26A17B]/20 data-[state=active]:text-[#26A17B] text-gray-400"
|
||||
>
|
||||
<Bitcoin className="w-4 h-4 mr-2" />
|
||||
USDT
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="paypal"
|
||||
className="data-[state=active]:bg-[#003087]/20 data-[state=active]:text-[#169BD7] text-gray-400"
|
||||
>
|
||||
<Globe className="w-4 h-4 mr-2" />
|
||||
PayPal
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="wechat" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#07C160] flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
微信支付配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置微信支付参数和跳转链接</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(w.enabled)}
|
||||
onCheckedChange={(c) => updateWechat('enabled', c)}
|
||||
/>
|
||||
</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 font-mono text-sm"
|
||||
value={String(w.websiteAppId ?? '')}
|
||||
onChange={(e) => updateWechat('websiteAppId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">商户号</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
value={String(w.merchantId ?? '')}
|
||||
onChange={(e) => updateWechat('merchantId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
||||
<h4 className="text-white font-medium flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
||||
跳转链接配置(核心功能)
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">微信收款码/支付链接</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
||||
placeholder="https://收款码图片URL 或 weixin://支付链接"
|
||||
value={String(w.qrCode ?? '')}
|
||||
onChange={(e) => updateWechat('qrCode', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">用户点击微信支付后显示的二维码图片URL</p>
|
||||
</div>
|
||||
<div className="space-y-2 bg-[#07C160]/5 p-4 rounded-xl border border-[#07C160]/20">
|
||||
<Label className="text-[#07C160] font-medium">微信群跳转链接(支付成功后跳转)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-[#07C160]/30 text-white placeholder:text-gray-500"
|
||||
placeholder="https://weixin.qq.com/g/... 或微信群二维码图片URL"
|
||||
value={String(w.groupQrCode ?? '')}
|
||||
onChange={(e) => updateWechat('groupQrCode', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-[#07C160]/70">用户支付成功后将自动跳转到此链接,进入指定微信群</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alipay" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#1677FF] flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
支付宝配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">已加载真实支付宝参数</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(a.enabled)}
|
||||
onCheckedChange={(c) => updateAlipay('enabled', c)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">合作者身份 (PID)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
value={String(a.partnerId ?? '')}
|
||||
onChange={(e) => updateAlipay('partnerId', e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="border-gray-700 bg-transparent"
|
||||
onClick={() => handleCopy(String(a.partnerId ?? ''), 'pid')}
|
||||
>
|
||||
{copied === 'pid' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">安全校验码 (Key)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
value={String(a.securityKey ?? '')}
|
||||
onChange={(e) => updateAlipay('securityKey', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
||||
<h4 className="text-white font-medium flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
||||
跳转链接配置
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">支付宝收款码/跳转链接</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
||||
placeholder="https://qr.alipay.com/... 或收款码图片URL"
|
||||
value={String(a.qrCode ?? '')}
|
||||
onChange={(e) => updateAlipay('qrCode', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">用户点击支付宝支付后显示的二维码</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usdt" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#26A17B] flex items-center gap-2">
|
||||
<Bitcoin className="w-5 h-5" />
|
||||
USDT配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置加密货币收款地址</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(u.enabled)}
|
||||
onCheckedChange={(c) => updateUsdt('enabled', c)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">网络类型</Label>
|
||||
<select
|
||||
className="w-full bg-[#0a1628] border border-gray-700 text-white rounded-md p-2"
|
||||
value={String(u.network ?? 'TRC20')}
|
||||
onChange={(e) => updateUsdt('network', e.target.value)}
|
||||
>
|
||||
<option value="TRC20">TRC20 (波场)</option>
|
||||
<option value="ERC20">ERC20 (以太坊)</option>
|
||||
<option value="BEP20">BEP20 (币安链)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">收款地址</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
placeholder="T... (TRC20地址)"
|
||||
value={String(u.address ?? '')}
|
||||
onChange={(e) => updateUsdt('address', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={Number(u.exchangeRate) ?? 7.2}
|
||||
onChange={(e) => updateUsdt('exchangeRate', Number.parseFloat(e.target.value) || 7.2)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="paypal" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#169BD7] flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
PayPal配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置PayPal收款账户</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(p.enabled)}
|
||||
onCheckedChange={(c) => updatePaypal('enabled', c)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">PayPal邮箱</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="your@email.com"
|
||||
value={String(p.email ?? '')}
|
||||
onChange={(e) => updatePaypal('email', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={Number(p.exchangeRate) ?? 7.2}
|
||||
onChange={(e) => updatePaypal('exchangeRate', Number(e.target.value) || 7.2)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
soul-admin/src/pages/qrcodes/QRCodesPage.tsx
Normal file
242
soul-admin/src/pages/qrcodes/QRCodesPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { QrCode, Upload, Link, ExternalLink, Copy, Check, HelpCircle } from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
export function QRCodesPage() {
|
||||
const [liveQRUrls, setLiveQRUrls] = useState('')
|
||||
const [wechatGroupUrl, setWechatGroupUrl] = useState('')
|
||||
const [copied, setCopied] = useState('')
|
||||
const [config, setConfig] = useState<{
|
||||
paymentMethods?: { wechat?: { groupQrCode?: string } }
|
||||
liveQRCodes?: { id: string; name: string; urls: string[]; clickCount: number }[]
|
||||
}>({})
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const data = await get<{
|
||||
paymentMethods?: { wechat?: { groupQrCode?: string } }
|
||||
liveQRCodes?: { id: string; name: string; urls: string[]; clickCount: number }[]
|
||||
}>('/api/config')
|
||||
const urls = data?.liveQRCodes?.[0]?.urls
|
||||
if (Array.isArray(urls)) setLiveQRUrls(urls.join('\n'))
|
||||
const group = data?.paymentMethods?.wechat?.groupQrCode
|
||||
if (group) setWechatGroupUrl(group)
|
||||
setConfig({
|
||||
paymentMethods: data?.paymentMethods,
|
||||
liveQRCodes: data?.liveQRCodes,
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const handleCopy = (text: string, field: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(field)
|
||||
setTimeout(() => setCopied(''), 2000)
|
||||
}
|
||||
|
||||
const handleSaveLiveQR = async () => {
|
||||
try {
|
||||
const urls = liveQRUrls
|
||||
.split('\n')
|
||||
.map((u) => u.trim())
|
||||
.filter(Boolean)
|
||||
const updatedLiveQRCodes = [...(config.liveQRCodes || [])]
|
||||
if (updatedLiveQRCodes[0]) {
|
||||
updatedLiveQRCodes[0].urls = urls
|
||||
} else {
|
||||
updatedLiveQRCodes.push({ id: 'live-1', name: '微信群活码', urls, clickCount: 0 })
|
||||
}
|
||||
await post('/api/config', { liveQRCodes: updatedLiveQRCodes })
|
||||
alert('群活码配置已保存!')
|
||||
await loadConfig()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveWechatGroup = async () => {
|
||||
try {
|
||||
await post('/api/config', {
|
||||
paymentMethods: {
|
||||
...(config.paymentMethods || {}),
|
||||
wechat: {
|
||||
...(config.paymentMethods?.wechat || {}),
|
||||
groupQrCode: wechatGroupUrl,
|
||||
},
|
||||
},
|
||||
})
|
||||
alert('微信群链接已保存!用户支付成功后将自动跳转')
|
||||
await loadConfig()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestJump = () => {
|
||||
if (wechatGroupUrl) window.open(wechatGroupUrl, '_blank')
|
||||
else alert('请先配置微信群链接')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-5xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white">微信群活码管理</h2>
|
||||
<p className="text-gray-400 mt-1">配置微信群跳转链接,用户支付后自动跳转加群</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 bg-[#07C160]/10 border border-[#07C160]/30 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<HelpCircle className="w-5 h-5 text-[#07C160] flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium mb-2 text-[#07C160]">微信群活码配置指南</p>
|
||||
<div className="text-[#07C160]/80 space-y-2">
|
||||
<p className="font-medium">方法一:使用草料活码(推荐)</p>
|
||||
<ol className="list-decimal list-inside space-y-1 pl-2">
|
||||
<li>访问草料二维码创建活码</li>
|
||||
<li>上传微信群二维码图片,生成永久链接</li>
|
||||
<li>复制生成的短链接填入下方配置</li>
|
||||
<li>群满后可直接在草料后台更换新群码,链接不变</li>
|
||||
</ol>
|
||||
<p className="font-medium mt-3">方法二:直接使用微信群链接</p>
|
||||
<ol className="list-decimal list-inside space-y-1 pl-2">
|
||||
<li>微信打开目标群 → 右上角"..." → 群二维码</li>
|
||||
<li>长按二维码 → 识别二维码 → 复制链接</li>
|
||||
</ol>
|
||||
<p className="text-[#07C160]/60 mt-2">注意:微信原生群二维码7天后失效,建议使用草料活码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-[#07C160] flex items-center gap-2">
|
||||
<QrCode className="w-5 h-5" />
|
||||
支付成功跳转链接(核心配置)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
用户支付完成后自动跳转到此链接,进入指定微信群
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300 flex items-center gap-2">
|
||||
<Link className="w-4 h-4" />
|
||||
微信群链接 / 活码链接
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://cli.im/xxxxx 或 https://weixin.qq.com/g/..."
|
||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 flex-1"
|
||||
value={wechatGroupUrl}
|
||||
onChange={(e) => setWechatGroupUrl(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="border-gray-700 bg-transparent hover:bg-gray-700/50"
|
||||
onClick={() => handleCopy(wechatGroupUrl, 'group')}
|
||||
>
|
||||
{copied === 'group' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
支持格式:草料短链、微信群链接(https://weixin.qq.com/g/...)、企业微信链接等
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button onClick={handleSaveWechatGroup} className="flex-1 bg-[#07C160] hover:bg-[#06AD51] text-white">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestJump}
|
||||
variant="outline"
|
||||
className="border-[#07C160] text-[#07C160] hover:bg-[#07C160]/10 bg-transparent"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
测试跳转
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<QrCode className="w-5 h-5 text-[#38bdac]" />
|
||||
多群轮换(高级配置)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
配置多个群链接,系统自动轮换分配,避免单群满员
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300 flex items-center gap-2">
|
||||
<Link className="w-4 h-4" />
|
||||
多个群链接(每行一个)
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="https://cli.im/group1\nhttps://cli.im/group2"
|
||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 min-h-[120px] font-mono text-sm"
|
||||
value={liveQRUrls}
|
||||
onChange={(e) => setLiveQRUrls(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">每行填写一个群链接,系统将按顺序或随机分配</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-[#0a1628] rounded-lg border border-gray-700/50">
|
||||
<span className="text-sm text-gray-400">已配置群数量</span>
|
||||
<span className="font-bold text-[#38bdac]">
|
||||
{liveQRUrls.split('\n').filter(Boolean).length} 个
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={handleSaveLiveQR} className="w-full bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
保存多群配置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-[#0f2137] rounded-xl p-4 border border-gray-700/50">
|
||||
<h4 className="text-white font-medium mb-3">常见问题</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<p className="text-[#38bdac]">Q: 为什么推荐使用草料活码?</p>
|
||||
<p className="text-gray-400">
|
||||
A: 草料活码是永久链接,群满后可直接在后台更换新群码,无需修改网站配置。微信原生群码7天失效。
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#38bdac]">Q: 支付后没有跳转怎么办?</p>
|
||||
<p className="text-gray-400">
|
||||
A: 1) 检查链接是否正确填写 2) 部分浏览器可能拦截弹窗,用户需手动允许 3) 建议使用https开头的链接
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
276
soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx
Normal file
276
soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Save, Percent, Users, Wallet, Info } from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface ReferralConfig {
|
||||
distributorShare: number
|
||||
minWithdrawAmount: number
|
||||
bindingDays: number
|
||||
userDiscount: number
|
||||
enableAutoWithdraw: boolean
|
||||
}
|
||||
|
||||
const DEFAULT: ReferralConfig = {
|
||||
distributorShare: 90,
|
||||
minWithdrawAmount: 10,
|
||||
bindingDays: 30,
|
||||
userDiscount: 5,
|
||||
enableAutoWithdraw: false,
|
||||
}
|
||||
|
||||
export function ReferralSettingsPage() {
|
||||
const [config, setConfig] = useState<ReferralConfig>(DEFAULT)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
get<{ success?: boolean; data?: ReferralConfig }>('/api/db/config?key=referral_config')
|
||||
.then((data) => {
|
||||
const c = (data as { data?: ReferralConfig; config?: ReferralConfig })?.data ?? (data as { config?: ReferralConfig })?.config
|
||||
if (c) {
|
||||
setConfig({
|
||||
distributorShare: c.distributorShare ?? 90,
|
||||
minWithdrawAmount: c.minWithdrawAmount ?? 10,
|
||||
bindingDays: c.bindingDays ?? 30,
|
||||
userDiscount: c.userDiscount ?? 5,
|
||||
enableAutoWithdraw: c.enableAutoWithdraw ?? false,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const safeConfig = {
|
||||
distributorShare: Number(config.distributorShare) || 0,
|
||||
minWithdrawAmount: Number(config.minWithdrawAmount) || 0,
|
||||
bindingDays: Number(config.bindingDays) || 0,
|
||||
userDiscount: Number(config.userDiscount) || 0,
|
||||
enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
|
||||
}
|
||||
const body = {
|
||||
key: 'referral_config',
|
||||
config: safeConfig,
|
||||
description: '分销 / 推广规则配置',
|
||||
}
|
||||
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', body)
|
||||
if (!res || (res as { success?: boolean }).success === false) {
|
||||
alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
|
||||
return
|
||||
}
|
||||
alert(
|
||||
'✅ 分销配置已保存成功!\n\n• 小程序与网站的推广规则会一起生效\n• 绑定关系会使用新的天数配置\n• 佣金比例会立即应用到新订单\n\n如有缓存,请刷新前台/小程序页面。',
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('保存失败: ' + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNumberChange = (field: keyof ReferralConfig) => (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const value = parseFloat((e.target as HTMLInputElement).value || '0')
|
||||
setConfig((prev) => ({ ...prev, [field]: isNaN(value) ? 0 : value }))
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-500">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Wallet className="w-5 h-5 text-[#38bdac]" />
|
||||
推广 / 分销设置
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
统一管理「好友优惠」「你得 90% 收益」「绑定期 30 天」「提现门槛」等规则,小程序和 Web 共用这套配置。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<Percent className="w-4 h-4 text-[#38bdac]" />
|
||||
推广规则
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
这三项会直接体现在小程序「推广规则」卡片上,同时影响实收佣金计算。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300 flex items-center gap-2">
|
||||
<Info className="w-3 h-3 text-[#38bdac]" />
|
||||
好友优惠(%)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={config.userDiscount}
|
||||
onChange={handleNumberChange('userDiscount')}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
例如 5 表示好友立减 5%(在价格配置基础上生效)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300 flex items-center gap-2">
|
||||
<Users className="w-3 h-3 text-[#38bdac]" />
|
||||
推广者分成(%)
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<Slider
|
||||
className="flex-1"
|
||||
min={10}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[config.distributorShare]}
|
||||
onValueChange={([val]) =>
|
||||
setConfig((prev) => ({ ...prev, distributorShare: val }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="w-20 bg-[#0a1628] border-gray-700 text-white text-center"
|
||||
value={config.distributorShare}
|
||||
onChange={handleNumberChange('distributorShare')}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
实际佣金 = 订单金额 ×{' '}
|
||||
<span className="text-[#38bdac] font-mono">{config.distributorShare}%</span>
|
||||
,支付回调和分销统计都会用这个值。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300 flex items-center gap-2">
|
||||
<Users className="w-3 h-3 text-[#38bdac]" />
|
||||
绑定有效期(天)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={config.bindingDays}
|
||||
onChange={handleNumberChange('bindingDays')}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
好友通过你的链接进来并登录后,绑定在你名下的天数。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<Wallet className="w-4 h-4 text-[#38bdac]" />
|
||||
提现规则
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
与「提现中心」「自动提现」相关的参数,影响推广者看到的可提现金额和最低门槛。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">最低提现金额(元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={config.minWithdrawAmount}
|
||||
onChange={handleNumberChange('minWithdrawAmount')}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
小程序「满 X 元可提现」展示的门槛,同时用于后端接口校验。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300 flex items-center gap-2">
|
||||
自动提现开关
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-[#38bdac]/40 text-[#38bdac] text-[10px]"
|
||||
>
|
||||
预留
|
||||
</Badge>
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Switch
|
||||
checked={config.enableAutoWithdraw}
|
||||
onCheckedChange={(checked) =>
|
||||
setConfig((prev) => ({ ...prev, enableAutoWithdraw: checked }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-gray-400">
|
||||
开启后,可结合定时任务实现「收益自动打款到微信零钱」。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-gray-200 text-sm">
|
||||
<Info className="w-4 h-4 text-[#38bdac]" />
|
||||
使用说明
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-xs text-gray-400 leading-relaxed">
|
||||
<p>
|
||||
1. 以上配置会写入{' '}
|
||||
<code className="font-mono text-[11px] text-[#38bdac]">
|
||||
system_config.referral_config
|
||||
</code>
|
||||
,小程序「推广中心」、Web 推广页以及支付回调都会读取同一份配置。
|
||||
</p>
|
||||
<p>
|
||||
2. 修改后新订单立即生效;旧订单的历史佣金不会自动重算,只影响之后产生的订单。
|
||||
</p>
|
||||
<p>
|
||||
3. 如遇前端展示与实际结算不一致,优先以此处配置为准,再排查缓存和小程序版本。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
662
soul-admin/src/pages/settings/SettingsPage.tsx
Normal file
662
soul-admin/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,662 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Save,
|
||||
Settings,
|
||||
Users,
|
||||
DollarSign,
|
||||
UserCircle,
|
||||
Calendar,
|
||||
MapPin,
|
||||
BookOpen,
|
||||
Gift,
|
||||
X,
|
||||
Plus,
|
||||
Smartphone,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
|
||||
interface AuthorInfo {
|
||||
name?: string
|
||||
startDate?: string
|
||||
bio?: string
|
||||
liveTime?: string
|
||||
platform?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface LocalSettings {
|
||||
sectionPrice: number
|
||||
baseBookPrice: number
|
||||
distributorShare: number
|
||||
authorInfo: AuthorInfo
|
||||
}
|
||||
|
||||
interface MpConfig {
|
||||
appId: string
|
||||
apiDomain: string
|
||||
buyerDiscount: number
|
||||
referralBindDays: number
|
||||
minWithdraw: number
|
||||
}
|
||||
|
||||
interface FeatureConfig {
|
||||
matchEnabled: boolean
|
||||
referralEnabled: boolean
|
||||
searchEnabled: boolean
|
||||
aboutEnabled: boolean
|
||||
}
|
||||
|
||||
const defaultAuthor: AuthorInfo = {
|
||||
name: '卡若',
|
||||
startDate: '2025年10月15日',
|
||||
bio: '连续创业者,私域运营专家,每天早上6-9点在Soul派对房分享真实商业故事',
|
||||
liveTime: '06:00-09:00',
|
||||
platform: 'Soul派对房',
|
||||
description: '连续创业者,私域运营专家',
|
||||
}
|
||||
|
||||
const defaultSettings: LocalSettings = {
|
||||
sectionPrice: 1,
|
||||
baseBookPrice: 9.9,
|
||||
distributorShare: 90,
|
||||
authorInfo: { ...defaultAuthor },
|
||||
}
|
||||
|
||||
const defaultMp: MpConfig = {
|
||||
appId: 'wxb8bbb2b10dec74aa',
|
||||
apiDomain: 'https://soul.quwanzhi.com',
|
||||
buyerDiscount: 5,
|
||||
referralBindDays: 30,
|
||||
minWithdraw: 10,
|
||||
}
|
||||
|
||||
const defaultFeatures: FeatureConfig = {
|
||||
matchEnabled: true,
|
||||
referralEnabled: true,
|
||||
searchEnabled: true,
|
||||
aboutEnabled: true,
|
||||
}
|
||||
|
||||
function parseConfigResponse(raw: unknown): {
|
||||
freeChapters?: string[]
|
||||
mpConfig?: Partial<MpConfig>
|
||||
features?: Partial<FeatureConfig>
|
||||
sectionPrice?: number
|
||||
baseBookPrice?: number
|
||||
distributorShare?: number
|
||||
authorInfo?: AuthorInfo
|
||||
} {
|
||||
if (!raw || typeof raw !== 'object') return {}
|
||||
const o = raw as Record<string, unknown>
|
||||
const out: ReturnType<typeof parseConfigResponse> = {}
|
||||
if (Array.isArray(o.freeChapters)) out.freeChapters = o.freeChapters as string[]
|
||||
if (o.mpConfig && typeof o.mpConfig === 'object') out.mpConfig = o.mpConfig as Partial<MpConfig>
|
||||
if (o.features && typeof o.features === 'object') out.features = o.features as Partial<FeatureConfig>
|
||||
if (typeof o.sectionPrice === 'number') out.sectionPrice = o.sectionPrice
|
||||
if (typeof o.baseBookPrice === 'number') out.baseBookPrice = o.baseBookPrice
|
||||
if (typeof o.distributorShare === 'number') out.distributorShare = o.distributorShare
|
||||
if (o.authorInfo && typeof o.authorInfo === 'object') out.authorInfo = o.authorInfo as AuthorInfo
|
||||
return out
|
||||
}
|
||||
|
||||
function mergeFromConfigList(list: unknown[]): ReturnType<typeof parseConfigResponse> {
|
||||
const out: ReturnType<typeof parseConfigResponse> = {}
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const row = item as { config_key?: string; config_value?: string }
|
||||
const key = row.config_key
|
||||
let val: unknown
|
||||
try {
|
||||
val = typeof row.config_value === 'string' ? JSON.parse(row.config_value) : row.config_value
|
||||
} catch {
|
||||
val = row.config_value
|
||||
}
|
||||
if (key === 'feature_config' && val && typeof val === 'object') out.features = val as Partial<FeatureConfig>
|
||||
if (key === 'mp_config' && val && typeof val === 'object') out.mpConfig = val as Partial<MpConfig>
|
||||
if (key === 'free_chapters' && Array.isArray(val)) out.freeChapters = val as string[]
|
||||
if (key === 'site_settings' && val && typeof val === 'object') {
|
||||
const s = val as Record<string, unknown>
|
||||
if (typeof s.sectionPrice === 'number') out.sectionPrice = s.sectionPrice
|
||||
if (typeof s.baseBookPrice === 'number') out.baseBookPrice = s.baseBookPrice
|
||||
if (typeof s.distributorShare === 'number') out.distributorShare = s.distributorShare
|
||||
if (s.authorInfo && typeof s.authorInfo === 'object') out.authorInfo = s.authorInfo as AuthorInfo
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
|
||||
const [freeChapters, setFreeChapters] = useState<string[]>([
|
||||
'preface',
|
||||
'epilogue',
|
||||
'1.1',
|
||||
'appendix-1',
|
||||
'appendix-2',
|
||||
'appendix-3',
|
||||
])
|
||||
const [newFreeChapter, setNewFreeChapter] = useState('')
|
||||
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMp)
|
||||
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [configRes, appConfigRes] = await Promise.all([
|
||||
get<{ success?: boolean; data?: unknown } | Record<string, unknown>>('/api/db/config'),
|
||||
get<Record<string, unknown>>('/api/config').catch(() => null),
|
||||
])
|
||||
let parsed = parseConfigResponse(
|
||||
(configRes as { data?: unknown })?.data ?? configRes,
|
||||
)
|
||||
const data = (configRes as { data?: unknown })?.data
|
||||
if (Array.isArray(data)) parsed = { ...parsed, ...mergeFromConfigList(data) }
|
||||
if (parsed.freeChapters?.length) setFreeChapters(parsed.freeChapters)
|
||||
if (parsed.mpConfig && Object.keys(parsed.mpConfig).length)
|
||||
setMpConfig((prev) => ({ ...prev, ...parsed.mpConfig }))
|
||||
if (parsed.features && Object.keys(parsed.features).length)
|
||||
setFeatureConfig((prev) => ({ ...prev, ...parsed.features }))
|
||||
if (
|
||||
appConfigRes?.authorInfo &&
|
||||
typeof appConfigRes.authorInfo === 'object'
|
||||
) {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, ...(appConfigRes.authorInfo as AuthorInfo) },
|
||||
}))
|
||||
}
|
||||
if (
|
||||
typeof parsed.sectionPrice === 'number' ||
|
||||
typeof parsed.baseBookPrice === 'number' ||
|
||||
typeof parsed.distributorShare === 'number' ||
|
||||
(parsed.authorInfo && Object.keys(parsed.authorInfo).length)
|
||||
) {
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
...(typeof parsed.sectionPrice === 'number' && { sectionPrice: parsed.sectionPrice }),
|
||||
...(typeof parsed.baseBookPrice === 'number' && { baseBookPrice: parsed.baseBookPrice }),
|
||||
...(typeof parsed.distributorShare === 'number' && { distributorShare: parsed.distributorShare }),
|
||||
...(parsed.authorInfo && { authorInfo: { ...prev.authorInfo, ...parsed.authorInfo } }),
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load config error:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await post('/api/db/settings', localSettings).catch(() => {})
|
||||
await post('/api/db/config', {
|
||||
key: 'free_chapters',
|
||||
value: freeChapters,
|
||||
description: '免费章节ID列表',
|
||||
}).catch(() => {})
|
||||
await post('/api/db/config', {
|
||||
key: 'mp_config',
|
||||
value: mpConfig,
|
||||
description: '小程序配置',
|
||||
}).catch(() => {})
|
||||
await post('/api/db/config', {
|
||||
key: 'feature_config',
|
||||
value: featureConfig,
|
||||
description: '功能开关配置',
|
||||
})
|
||||
const verifyRes = await get<{ features?: FeatureConfig }>('/api/db/config').catch(() => ({}))
|
||||
const verifyData = Array.isArray((verifyRes as { data?: unknown })?.data)
|
||||
? mergeFromConfigList((verifyRes as { data: unknown[] }).data)
|
||||
: parseConfigResponse((verifyRes as { data?: unknown })?.data ?? verifyRes)
|
||||
if (verifyData.features)
|
||||
setFeatureConfig((prev) => ({ ...prev, ...verifyData.features }))
|
||||
alert(
|
||||
'设置已保存!\n\n找伙伴功能:' +
|
||||
(verifyData.features?.matchEnabled ? '✅ 开启' : '❌ 关闭'),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Save settings error:', error)
|
||||
alert('保存失败: ' + (error instanceof Error ? error.message : String(error)))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addFreeChapter = () => {
|
||||
if (newFreeChapter && !freeChapters.includes(newFreeChapter)) {
|
||||
setFreeChapters([...freeChapters, newFreeChapter])
|
||||
setNewFreeChapter('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeFreeChapter = (chapter: string) => {
|
||||
setFreeChapters(freeChapters.filter((c) => c !== chapter))
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-gray-500">加载中...</div>
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<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">配置全站基础参数与开关</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? '保存中...' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<UserCircle className="w-5 h-5 text-[#38bdac]" />
|
||||
关于作者
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
配置作者信息,将在"关于作者"页面显示
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author-name" className="text-gray-300 flex items-center gap-1">
|
||||
<UserCircle className="w-3 h-3" />
|
||||
主理人名称
|
||||
</Label>
|
||||
<Input
|
||||
id="author-name"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={localSettings.authorInfo.name ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, name: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start-date" className="text-gray-300 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
开播日期
|
||||
</Label>
|
||||
<Input
|
||||
id="start-date"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="例如: 2025年10月15日"
|
||||
value={localSettings.authorInfo.startDate ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, startDate: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="live-time" className="text-gray-300 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
直播时间
|
||||
</Label>
|
||||
<Input
|
||||
id="live-time"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="例如: 06:00-09:00"
|
||||
value={localSettings.authorInfo.liveTime ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, liveTime: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="platform" className="text-gray-300 flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
直播平台
|
||||
</Label>
|
||||
<Input
|
||||
id="platform"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="例如: Soul派对房"
|
||||
value={localSettings.authorInfo.platform ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, platform: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-gray-300 flex items-center gap-1">
|
||||
<BookOpen className="w-3 h-3" />
|
||||
简介描述
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={localSettings.authorInfo.description ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, description: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bio" className="text-gray-300">
|
||||
详细介绍
|
||||
</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
className="bg-[#0a1628] border-gray-700 text-white min-h-[100px]"
|
||||
placeholder="输入作者详细介绍..."
|
||||
value={localSettings.authorInfo.bio ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
authorInfo: { ...prev.authorInfo, bio: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 p-4 rounded-xl bg-[#0a1628] border border-[#38bdac]/30">
|
||||
<p className="text-xs text-gray-500 mb-2">预览效果</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center text-xl font-bold text-white">
|
||||
{(localSettings.authorInfo.name ?? 'K').charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white font-semibold">{localSettings.authorInfo.name}</p>
|
||||
<p className="text-gray-400 text-xs">{localSettings.authorInfo.description}</p>
|
||||
<p className="text-[#38bdac] text-xs mt-1">
|
||||
每日 {localSettings.authorInfo.liveTime} · {localSettings.authorInfo.platform}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5 text-[#38bdac]" />
|
||||
价格设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
配置书籍和章节的定价
|
||||
</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">单节价格 (元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={localSettings.sectionPrice}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
sectionPrice: Number.parseFloat(e.target.value) || 1,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">整本价格 (元)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={localSettings.baseBookPrice}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
baseBookPrice: Number.parseFloat(e.target.value) || 9.9,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Gift className="w-5 h-5 text-[#38bdac]" />
|
||||
免费章节
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
设置哪些章节对所有用户免费开放
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{freeChapters.map((chapter) => (
|
||||
<span
|
||||
key={chapter}
|
||||
className="inline-flex items-center gap-1 bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/30 px-3 py-1 rounded-md text-sm"
|
||||
>
|
||||
{chapter}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFreeChapter(chapter)}
|
||||
className="ml-1 hover:text-red-400"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white flex-1"
|
||||
placeholder="输入章节ID,如 1.2、2.1、preface"
|
||||
value={newFreeChapter}
|
||||
onChange={(e) => setNewFreeChapter(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addFreeChapter()}
|
||||
/>
|
||||
<Button onClick={addFreeChapter} className="bg-[#38bdac] hover:bg-[#2da396]">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">
|
||||
常用ID: preface(序言), epilogue(尾声), appendix-1/2/3(附录), 1.1/1.2等(章节)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Settings className="w-5 h-5 text-[#38bdac]" />
|
||||
功能开关
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
控制各个功能模块的显示/隐藏
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<Users className="w-4 h-4 text-[#38bdac]" />
|
||||
<Label htmlFor="match-enabled" className="text-white font-medium cursor-pointer">
|
||||
找伙伴功能
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 ml-6">控制小程序和Web端的找伙伴功能显示</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="match-enabled"
|
||||
checked={featureConfig.matchEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setFeatureConfig((prev) => ({ ...prev, matchEnabled: 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">
|
||||
<Gift className="w-4 h-4 text-[#38bdac]" />
|
||||
<Label htmlFor="referral-enabled" className="text-white font-medium cursor-pointer">
|
||||
推广功能
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 ml-6">控制推广中心的显示(我的页面入口)</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="referral-enabled"
|
||||
checked={featureConfig.referralEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setFeatureConfig((prev) => ({ ...prev, referralEnabled: 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">
|
||||
<BookOpen className="w-4 h-4 text-[#38bdac]" />
|
||||
<Label htmlFor="search-enabled" className="text-white font-medium cursor-pointer">
|
||||
搜索功能
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 ml-6">控制首页搜索栏的显示</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="search-enabled"
|
||||
checked={featureConfig.searchEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setFeatureConfig((prev) => ({ ...prev, 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}
|
||||
onCheckedChange={(checked) =>
|
||||
setFeatureConfig((prev) => ({ ...prev, 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">
|
||||
💡 关闭功能后,相关入口会自动隐藏。建议在功能开发完成后再开启。
|
||||
</p>
|
||||
</div>
|
||||
</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">微信小程序相关参数设置</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"
|
||||
value={mpConfig.appId}
|
||||
onChange={(e) => setMpConfig((prev) => ({ ...prev, appId: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">API域名</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={mpConfig.apiDomain}
|
||||
onChange={(e) => setMpConfig((prev) => ({ ...prev, apiDomain: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">购买者优惠 (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={mpConfig.buyerDiscount}
|
||||
onChange={(e) =>
|
||||
setMpConfig((prev) => ({ ...prev, buyerDiscount: Number(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.referralBindDays}
|
||||
onChange={(e) =>
|
||||
setMpConfig((prev) => ({ ...prev, referralBindDays: Number(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}
|
||||
onChange={(e) =>
|
||||
setMpConfig((prev) => ({ ...prev, minWithdraw: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
380
soul-admin/src/pages/site/SitePage.tsx
Normal file
380
soul-admin/src/pages/site/SitePage.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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'
|
||||
|
||||
const defaultSiteConfig = {
|
||||
siteName: '卡若日记',
|
||||
siteTitle: '一场SOUL的创业实验场',
|
||||
siteDescription: '来自Soul派对房的真实商业故事',
|
||||
logo: '/logo.png',
|
||||
favicon: '/favicon.ico',
|
||||
primaryColor: '#00CED1',
|
||||
}
|
||||
|
||||
const defaultMenuConfig = {
|
||||
home: { enabled: true, label: '首页' },
|
||||
chapters: { enabled: true, label: '目录' },
|
||||
match: { enabled: true, label: '匹配' },
|
||||
my: { enabled: true, label: '我的' },
|
||||
}
|
||||
|
||||
const defaultPageConfig = {
|
||||
homeTitle: '一场SOUL的创业实验场',
|
||||
homeSubtitle: '来自Soul派对房的真实商业故事',
|
||||
chaptersTitle: '我要看',
|
||||
matchTitle: '语音匹配',
|
||||
myTitle: '我的',
|
||||
aboutTitle: '关于作者',
|
||||
}
|
||||
|
||||
export function SitePage() {
|
||||
const [localSettings, setLocalSettings] = useState({
|
||||
siteConfig: { ...defaultSiteConfig },
|
||||
menuConfig: { ...defaultMenuConfig },
|
||||
pageConfig: { ...defaultPageConfig },
|
||||
})
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
get<{
|
||||
siteConfig?: Record<string, string>
|
||||
menuConfig?: Record<string, { enabled?: boolean; label?: string }>
|
||||
pageConfig?: Record<string, string>
|
||||
}>('/api/config')
|
||||
.then((data) => {
|
||||
if (data?.siteConfig)
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, ...data.siteConfig },
|
||||
}))
|
||||
if (data?.menuConfig)
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
menuConfig: { ...prev.menuConfig, ...data.menuConfig },
|
||||
}))
|
||||
if (data?.pageConfig)
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
pageConfig: { ...prev.pageConfig, ...data.pageConfig },
|
||||
}))
|
||||
})
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
alert('配置已保存(当前为前端状态,后端可对接 /api/db/config 持久化)')
|
||||
}
|
||||
|
||||
const sc = localSettings.siteConfig
|
||||
const menu = localSettings.menuConfig
|
||||
const page = localSettings.pageConfig
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<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">配置网站名称、图标、菜单和页面标题</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className={`${saved ? 'bg-green-500' : 'bg-[#00CED1]'} hover:bg-[#20B2AA] text-white transition-colors`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saved ? '已保存' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-[#00CED1]" />
|
||||
网站基础信息
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置网站名称、标题和描述</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site-name" className="text-gray-300">网站名称</Label>
|
||||
<Input
|
||||
id="site-name"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={sc.siteName ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, siteName: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site-title" className="text-gray-300">网站标题</Label>
|
||||
<Input
|
||||
id="site-title"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={sc.siteTitle ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, siteTitle: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site-desc" className="text-gray-300">网站描述</Label>
|
||||
<Input
|
||||
id="site-desc"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={sc.siteDescription ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, siteDescription: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo" className="text-gray-300">Logo地址</Label>
|
||||
<Input
|
||||
id="logo"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={sc.logo ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, logo: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="favicon" className="text-gray-300">Favicon地址</Label>
|
||||
<Input
|
||||
id="favicon"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={sc.favicon ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, favicon: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Palette className="w-5 h-5 text-[#00CED1]" />
|
||||
主题颜色
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置网站主题色</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="space-y-2 flex-1">
|
||||
<Label htmlFor="primary-color" className="text-gray-300">主色调</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="primary-color"
|
||||
type="color"
|
||||
className="w-16 h-10 bg-[#0a1628] border-gray-700 cursor-pointer p-1"
|
||||
value={sc.primaryColor ?? '#00CED1'}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, primaryColor: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white flex-1"
|
||||
value={sc.primaryColor ?? '#00CED1'}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
siteConfig: { ...prev.siteConfig, primaryColor: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="w-24 h-24 rounded-xl flex items-center justify-center text-white font-bold"
|
||||
style={{ backgroundColor: sc.primaryColor ?? '#00CED1' }}
|
||||
>
|
||||
预览
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Menu className="w-5 h-5 text-[#00CED1]" />
|
||||
底部菜单配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">控制底部导航栏菜单的显示和名称</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{Object.entries(menu).map(([key, config]) => (
|
||||
<div key={key} className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<Switch
|
||||
checked={config?.enabled ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
menuConfig: {
|
||||
...prev.menuConfig,
|
||||
[key]: { ...config, enabled: checked },
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span className="text-gray-300 w-16 capitalize">{key}</span>
|
||||
<Input
|
||||
className="bg-[#0f2137] border-gray-700 text-white max-w-[200px]"
|
||||
value={config?.label ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
menuConfig: {
|
||||
...prev.menuConfig,
|
||||
[key]: { ...config, label: e.target.value },
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-sm ${config?.enabled ? 'text-green-400' : 'text-gray-500'}`}>
|
||||
{config?.enabled ? '显示' : '隐藏'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-[#00CED1]" />
|
||||
页面标题配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置各个页面的标题和副标题</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">首页标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={page.homeTitle ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
pageConfig: { ...prev.pageConfig, homeTitle: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">首页副标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={page.homeSubtitle ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
pageConfig: { ...prev.pageConfig, homeSubtitle: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">目录页标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={page.chaptersTitle ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
pageConfig: { ...prev.pageConfig, chaptersTitle: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">匹配页标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={page.matchTitle ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
pageConfig: { ...prev.pageConfig, matchTitle: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">我的页标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={page.myTitle ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
pageConfig: { ...prev.pageConfig, myTitle: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">关于作者标题</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={page.aboutTitle ?? ''}
|
||||
onChange={(e) =>
|
||||
setLocalSettings((prev) => ({
|
||||
...prev,
|
||||
pageConfig: { ...prev.pageConfig, aboutTitle: e.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
703
soul-admin/src/pages/users/UsersPage.tsx
Normal file
703
soul-admin/src/pages/users/UsersPage.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Search,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Key,
|
||||
Save,
|
||||
X,
|
||||
RefreshCw,
|
||||
Users,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
import { get, del, post, put } from '@/api/client'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
open_id?: string | null
|
||||
phone?: string | null
|
||||
nickname: string
|
||||
wechat_id?: string | null
|
||||
avatar?: string | null
|
||||
is_admin?: boolean | number
|
||||
has_full_book?: boolean | number
|
||||
referral_code: string
|
||||
earnings: number | string
|
||||
pending_earnings?: number | string
|
||||
withdrawn_earnings?: number | string
|
||||
referral_count: number
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [, setError] = useState<string | null>(null)
|
||||
const [showUserModal, setShowUserModal] = useState(false)
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null)
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [showReferralsModal, setShowReferralsModal] = useState(false)
|
||||
const [referralsData, setReferralsData] = useState<{ referrals?: unknown[]; stats?: Record<string, unknown> }>({
|
||||
referrals: [],
|
||||
stats: {},
|
||||
})
|
||||
const [referralsLoading, setReferralsLoading] = useState(false)
|
||||
const [selectedUserForReferrals, setSelectedUserForReferrals] = useState<User | null>(null)
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
const [selectedUserIdForDetail, setSelectedUserIdForDetail] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
phone: '',
|
||||
nickname: '',
|
||||
password: '',
|
||||
is_admin: false,
|
||||
has_full_book: false,
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
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 || '加载失败')
|
||||
} catch (err) {
|
||||
console.error('Load users error:', err)
|
||||
setError('网络错误,请检查连接')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
const filteredUsers = users.filter(
|
||||
(u) =>
|
||||
(u.nickname || '').includes(searchTerm) ||
|
||||
(u.phone || '').includes(searchTerm),
|
||||
)
|
||||
|
||||
async function handleDelete(userId: string) {
|
||||
if (!confirm('确定要删除这个用户吗?')) return
|
||||
try {
|
||||
const data = await del<{ success?: boolean; error?: string }>(
|
||||
`/api/db/users?id=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (data?.success) loadUsers()
|
||||
else alert('删除失败: ' + (data?.error || '未知错误'))
|
||||
} catch (err) {
|
||||
console.error('Delete user error:', err)
|
||||
alert('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditUser = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setFormData({
|
||||
phone: user.phone || '',
|
||||
nickname: user.nickname || '',
|
||||
password: '',
|
||||
is_admin: !!(user.is_admin ?? false),
|
||||
has_full_book: !!(user.has_full_book ?? false),
|
||||
})
|
||||
setShowUserModal(true)
|
||||
}
|
||||
|
||||
const handleAddUser = () => {
|
||||
setEditingUser(null)
|
||||
setFormData({
|
||||
phone: '',
|
||||
nickname: '',
|
||||
password: '',
|
||||
is_admin: false,
|
||||
has_full_book: false,
|
||||
})
|
||||
setShowUserModal(true)
|
||||
}
|
||||
|
||||
async function handleSaveUser() {
|
||||
if (!formData.phone || !formData.nickname) {
|
||||
alert('请填写手机号和昵称')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingUser) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: editingUser.id,
|
||||
nickname: formData.nickname,
|
||||
is_admin: formData.is_admin,
|
||||
has_full_book: formData.has_full_book,
|
||||
...(formData.password && { password: formData.password }),
|
||||
})
|
||||
if (!data?.success) {
|
||||
alert('更新失败: ' + (data?.error || '未知错误'))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
phone: formData.phone,
|
||||
nickname: formData.nickname,
|
||||
password: formData.password,
|
||||
is_admin: formData.is_admin,
|
||||
})
|
||||
if (!data?.success) {
|
||||
alert('创建失败: ' + (data?.error || '未知错误'))
|
||||
return
|
||||
}
|
||||
}
|
||||
setShowUserModal(false)
|
||||
loadUsers()
|
||||
} catch (err) {
|
||||
console.error('Save user error:', err)
|
||||
alert('保存失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = (user: User) => {
|
||||
setEditingUser(user)
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setShowPasswordModal(true)
|
||||
}
|
||||
|
||||
async function handleViewReferrals(user: User) {
|
||||
setSelectedUserForReferrals(user)
|
||||
setShowReferralsModal(true)
|
||||
setReferralsLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; referrals?: unknown[]; stats?: Record<string, unknown> }>(
|
||||
`/api/db/users/referrals?userId=${encodeURIComponent(user.id)}`,
|
||||
)
|
||||
if (data?.success) setReferralsData({ referrals: data.referrals || [], stats: data.stats || {} })
|
||||
else setReferralsData({ referrals: [], stats: {} })
|
||||
} catch (err) {
|
||||
console.error('Load referrals error:', err)
|
||||
setReferralsData({ referrals: [], stats: {} })
|
||||
} finally {
|
||||
setReferralsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = (user: User) => {
|
||||
setSelectedUserIdForDetail(user.id)
|
||||
setShowDetailModal(true)
|
||||
}
|
||||
|
||||
async function handleSavePassword() {
|
||||
if (!newPassword) {
|
||||
alert('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('两次输入的密码不一致')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
alert('密码长度不能少于6位')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: editingUser?.id,
|
||||
password: newPassword,
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('密码修改成功')
|
||||
setShowPasswordModal(false)
|
||||
} else {
|
||||
alert('密码修改失败: ' + (data?.error || '未知错误'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Change password error:', err)
|
||||
alert('密码修改失败')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadUsers}
|
||||
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="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索用户..."
|
||||
className="pl-10 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500 w-64"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAddUser} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
{editingUser ? (
|
||||
<Edit3 className="w-5 h-5 text-[#38bdac]" />
|
||||
) : (
|
||||
<UserPlus className="w-5 h-5 text-[#38bdac]" />
|
||||
)}
|
||||
{editingUser ? '编辑用户' : '添加用户'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">手机号</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="请输入手机号"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
disabled={!!editingUser}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">昵称</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="请输入昵称"
|
||||
value={formData.nickname}
|
||||
onChange={(e) => setFormData({ ...formData, nickname: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">
|
||||
{editingUser ? '新密码 (留空则不修改)' : '密码'}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder={editingUser ? '留空则不修改' : '请输入密码'}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">管理员权限</Label>
|
||||
<Switch
|
||||
checked={formData.is_admin}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_admin: checked })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">已购全书</Label>
|
||||
<Switch
|
||||
checked={formData.has_full_book}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, has_full_book: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowUserModal(false)}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveUser}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isSaving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showPasswordModal} onOpenChange={setShowPasswordModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Key className="w-5 h-5 text-[#38bdac]" />
|
||||
修改密码
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="bg-[#0a1628] rounded-lg p-3">
|
||||
<p className="text-gray-400 text-sm">用户:{editingUser?.nickname}</p>
|
||||
<p className="text-gray-400 text-sm">手机号:{editingUser?.phone}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">新密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="请输入新密码 (至少6位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">确认密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="请再次输入新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPasswordModal(false)}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSavePassword}
|
||||
disabled={isSaving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{isSaving ? '保存中...' : '确认修改'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UserDetailModal
|
||||
open={showDetailModal}
|
||||
onClose={() => setShowDetailModal(false)}
|
||||
userId={selectedUserIdForDetail}
|
||||
onUserUpdated={loadUsers}
|
||||
/>
|
||||
|
||||
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-[#38bdac]" />
|
||||
绑定关系详情 - {selectedUserForReferrals?.nickname}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-[#38bdac]">
|
||||
{(referralsData.stats?.total as number) || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">绑定总数</div>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{(referralsData.stats?.purchased as number) || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">已付费</div>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-400">
|
||||
¥{((referralsData.stats?.earnings as number) || 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">累计收益</div>
|
||||
</div>
|
||||
<div className="bg-[#0a1628] rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-orange-400">
|
||||
¥{((referralsData.stats?.pendingEarnings as number) || 0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">待提现</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{referralsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : (referralsData.referrals?.length ?? 0) > 0 ? (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{(referralsData.referrals ?? []).map((ref: unknown, i: number) => {
|
||||
const r = ref as {
|
||||
id?: string
|
||||
nickname?: string
|
||||
phone?: string
|
||||
hasOpenId?: boolean
|
||||
status?: string
|
||||
purchasedSections?: number
|
||||
createdAt?: string
|
||||
}
|
||||
return (
|
||||
<div key={r.id || i} className="flex items-center justify-between bg-[#0a1628] rounded-lg p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
|
||||
{r.nickname?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-white text-sm">{r.nickname}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{r.phone || (r.hasOpenId ? '微信用户' : '未绑定')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.status === 'vip' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs">全书已购</Badge>
|
||||
)}
|
||||
{r.status === 'paid' && (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-0 text-xs">
|
||||
已付费{r.purchasedSections}章
|
||||
</Badge>
|
||||
)}
|
||||
{r.status === 'free' && (
|
||||
<Badge className="bg-gray-500/20 text-gray-400 border-0 text-xs">未付费</Badge>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">
|
||||
{r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">暂无绑定用户</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowReferralsModal(false)}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center 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>
|
||||
<TableHead className="text-gray-400">注册时间</TableHead>
|
||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id} className="hover:bg-[#0a1628] border-gray-700/50">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
user.nickname?.charAt(0) || '?'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-white">{user.nickname}</p>
|
||||
{user.is_admin && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 hover:bg-purple-500/20 border-0 text-xs">
|
||||
管理员
|
||||
</Badge>
|
||||
)}
|
||||
{user.open_id && !user.id?.startsWith('user_') && (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0 text-xs">
|
||||
微信
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 font-mono">
|
||||
{user.open_id ? user.open_id.slice(0, 12) + '...' : user.id?.slice(0, 12)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{user.phone && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-gray-500">📱</span>
|
||||
<span className="text-gray-300">{user.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{user.wechat_id && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-gray-500">💬</span>
|
||||
<span className="text-gray-300">{user.wechat_id}</span>
|
||||
</div>
|
||||
)}
|
||||
{user.open_id && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-gray-500">🔗</span>
|
||||
<span className="text-gray-500 truncate max-w-[100px]" title={user.open_id}>
|
||||
{user.open_id.slice(0, 12)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!user.phone && !user.wechat_id && !user.open_id && (
|
||||
<span className="text-gray-600 text-xs">未绑定</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.has_full_book ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||||
全书已购
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-gray-500 border-gray-600">
|
||||
未购买
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="text-white font-medium">
|
||||
¥{parseFloat(String(user.earnings || 0)).toFixed(2)}
|
||||
</div>
|
||||
{parseFloat(String(user.pending_earnings || 0)) > 0 && (
|
||||
<div className="text-xs text-yellow-400">
|
||||
待提现: ¥{parseFloat(String(user.pending_earnings || 0)).toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="text-xs text-[#38bdac] cursor-pointer hover:underline flex items-center gap-1"
|
||||
onClick={() => handleViewReferrals(user)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleViewReferrals(user)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<Users className="w-3 h-3" />
|
||||
绑定{user.referral_count || 0}人
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-[#38bdac] text-xs bg-[#38bdac]/10 px-2 py-0.5 rounded">
|
||||
{user.referral_code || '-'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">
|
||||
{user.created_at ? new Date(user.created_at).toLocaleDateString() : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetail(user)}
|
||||
className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10"
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleChangePassword(user)}
|
||||
className="text-gray-400 hover:text-yellow-400 hover:bg-yellow-400/10"
|
||||
title="修改密码"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
onClick={() => handleDelete(user.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredUsers.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
433
soul-admin/src/pages/withdrawals/WithdrawalsPage.tsx
Normal file
433
soul-admin/src/pages/withdrawals/WithdrawalsPage.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
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 { get, put } from '@/api/client'
|
||||
|
||||
interface Withdrawal {
|
||||
id: string
|
||||
user_id?: string
|
||||
userId?: string
|
||||
userNickname?: string
|
||||
user_name?: string
|
||||
userPhone?: string
|
||||
userAvatar?: string
|
||||
referralCode?: string
|
||||
amount: number
|
||||
status: 'pending' | 'processing' | 'success' | 'failed' | 'completed' | 'rejected' | 'pending_confirm'
|
||||
wechatOpenid?: string
|
||||
transactionId?: string
|
||||
errorMessage?: string
|
||||
createdAt?: string
|
||||
created_at?: string
|
||||
processedAt?: string
|
||||
completed_at?: string
|
||||
method?: 'wechat' | 'alipay'
|
||||
account?: string
|
||||
name?: string
|
||||
userCommissionInfo?: {
|
||||
totalCommission: number
|
||||
withdrawnEarnings: number
|
||||
pendingWithdrawals: number
|
||||
availableAfterThis: number
|
||||
}
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total: number
|
||||
pendingCount: number
|
||||
pendingAmount: number
|
||||
successCount: number
|
||||
successAmount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
export function WithdrawalsPage() {
|
||||
const [withdrawals, setWithdrawals] = useState<Withdrawal[]>([])
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
total: 0,
|
||||
pendingCount: 0,
|
||||
pendingAmount: 0,
|
||||
successCount: 0,
|
||||
successAmount: 0,
|
||||
failedCount: 0,
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'all' | 'pending' | 'success' | 'failed'>('all')
|
||||
const [processing, setProcessing] = useState<string | null>(null)
|
||||
|
||||
async function loadWithdrawals() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
withdrawals?: Withdrawal[]
|
||||
stats?: Partial<Stats>
|
||||
}>(`/api/admin/withdrawals?status=${filter}`)
|
||||
if (data?.success) {
|
||||
const list = (data.withdrawals || []).map((w) => ({
|
||||
...w,
|
||||
createdAt: w.created_at ?? w.createdAt,
|
||||
userNickname: w.user_name ?? w.userNickname,
|
||||
}))
|
||||
setWithdrawals(list)
|
||||
setStats({
|
||||
total: data.stats?.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,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load withdrawals error:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadWithdrawals()
|
||||
}, [filter])
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
const withdrawal = withdrawals.find((w) => w.id === id)
|
||||
if (
|
||||
withdrawal?.userCommissionInfo &&
|
||||
withdrawal.userCommissionInfo.availableAfterThis < 0
|
||||
) {
|
||||
if (
|
||||
!confirm(
|
||||
`⚠️ 风险警告:该用户审核后余额为负数(¥${withdrawal.userCommissionInfo.availableAfterThis.toFixed(2)}),可能存在超额提现。\n\n确认已核实用户账户并完成打款?`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!confirm('确认已完成打款?批准后将更新用户提现记录。')) return
|
||||
}
|
||||
setProcessing(id)
|
||||
try {
|
||||
const data = await put<{ success?: boolean; error?: string }>(
|
||||
'/api/admin/withdrawals',
|
||||
{ id, action: 'approve' },
|
||||
)
|
||||
if (data?.success) loadWithdrawals()
|
||||
else alert('操作失败: ' + (data?.error ?? ''))
|
||||
} catch {
|
||||
alert('操作失败')
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(id: string) {
|
||||
const reason = prompt('请输入拒绝原因(将返还用户余额):')
|
||||
if (!reason) return
|
||||
setProcessing(id)
|
||||
try {
|
||||
const data = await put<{ success?: boolean; error?: string }>(
|
||||
'/api/admin/withdrawals',
|
||||
{ id, action: 'reject', reason },
|
||||
)
|
||||
if (data?.success) loadWithdrawals()
|
||||
else alert('操作失败: ' + (data?.error ?? ''))
|
||||
} catch {
|
||||
alert('操作失败')
|
||||
} finally {
|
||||
setProcessing(null)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'pending_confirm':
|
||||
return (
|
||||
<Badge className="bg-orange-500/20 text-orange-400 hover:bg-orange-500/20 border-0">
|
||||
待处理
|
||||
</Badge>
|
||||
)
|
||||
case 'processing':
|
||||
return (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 hover:bg-blue-500/20 border-0">
|
||||
处理中
|
||||
</Badge>
|
||||
)
|
||||
case 'success':
|
||||
case 'completed':
|
||||
return (
|
||||
<Badge className="bg-green-500/20 text-green-400 hover:bg-green-500/20 border-0">
|
||||
已完成
|
||||
</Badge>
|
||||
)
|
||||
case 'failed':
|
||||
case 'rejected':
|
||||
return (
|
||||
<Badge className="bg-red-500/20 text-red-400 hover:bg-red-500/20 border-0">
|
||||
已拒绝
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge className="bg-gray-500/20 text-gray-400 border-0">{status}</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-6xl mx-auto">
|
||||
<div className="flex justify-between items-start mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">分账提现管理</h1>
|
||||
<p className="text-gray-400 mt-1">管理用户分销收益的提现申请</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadWithdrawals}
|
||||
disabled={loading}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 分账规则说明 */}
|
||||
<Card className="bg-gradient-to-r from-[#38bdac]/10 to-[#0f2137] border-[#38bdac]/30 mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<DollarSign className="w-5 h-5 text-[#38bdac] mt-0.5" />
|
||||
<div>
|
||||
<h3 className="text-white font-medium mb-2">自动分账规则</h3>
|
||||
<div className="text-sm text-gray-400 space-y-1">
|
||||
<p>
|
||||
• <span className="text-[#38bdac]">分销比例</span>:推广者获得订单金额的{' '}
|
||||
<span className="text-white font-medium">90%</span>
|
||||
</p>
|
||||
<p>
|
||||
• <span className="text-[#38bdac]">结算方式</span>:用户付款后,分销收益自动计入推广者账户
|
||||
</p>
|
||||
<p>
|
||||
• <span className="text-[#38bdac]">提现方式</span>:用户在小程序端点击提现,系统自动转账到微信零钱
|
||||
</p>
|
||||
<p>
|
||||
• <span className="text-[#38bdac]">审批流程</span>
|
||||
:待处理的提现需管理员手动确认打款后批准
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-[#38bdac]">{stats.total}</div>
|
||||
<div className="text-sm text-gray-400">总申请</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-orange-400">{stats.pendingCount}</div>
|
||||
<div className="text-sm text-gray-400">待处理</div>
|
||||
<div className="text-xs text-orange-400 mt-1">
|
||||
¥{stats.pendingAmount.toFixed(2)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-400">{stats.successCount}</div>
|
||||
<div className="text-sm text-gray-400">已完成</div>
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
¥{stats.successAmount.toFixed(2)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red-400">{stats.failedCount}</div>
|
||||
<div className="text-sm text-gray-400">已拒绝</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
{(['all', 'pending', 'success', 'failed'] as const).map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
variant={filter === f ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter(f)}
|
||||
className={
|
||||
filter === f
|
||||
? 'bg-[#38bdac] hover:bg-[#2da396] text-white'
|
||||
: 'border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent'
|
||||
}
|
||||
>
|
||||
{f === 'all'
|
||||
? '全部'
|
||||
: f === 'pending'
|
||||
? '待处理'
|
||||
: f === 'success'
|
||||
? '已完成'
|
||||
: '已拒绝'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : withdrawals.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Wallet className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无提现记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#0a1628] text-gray-400">
|
||||
<th className="p-4 text-left font-medium">申请时间</th>
|
||||
<th className="p-4 text-left font-medium">用户</th>
|
||||
<th className="p-4 text-left font-medium">提现金额</th>
|
||||
<th className="p-4 text-left font-medium">用户佣金信息</th>
|
||||
<th className="p-4 text-left font-medium">状态</th>
|
||||
<th className="p-4 text-left font-medium">处理时间</th>
|
||||
<th className="p-4 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-700/50">
|
||||
{withdrawals.map((w) => (
|
||||
<tr key={w.id} className="hover:bg-[#0a1628] transition-colors">
|
||||
<td className="p-4 text-gray-400">
|
||||
{new Date(w.created_at ?? w.createdAt ?? '').toLocaleString()}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{w.userAvatar ? (
|
||||
<img
|
||||
src={w.userAvatar}
|
||||
alt={w.userNickname ?? w.user_name ?? ''}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm text-[#38bdac]">
|
||||
{(w.userNickname ?? w.user_name ?? '?').charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{w.userNickname ?? w.user_name ?? '未知'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{w.userPhone ?? w.referralCode ?? (w.user_id ?? w.userId ?? '').slice(0, 10)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<span className="font-bold text-orange-400">
|
||||
¥{Number(w.amount).toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{w.userCommissionInfo ? (
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-gray-500">累计佣金:</span>
|
||||
<span className="text-[#38bdac] font-medium">
|
||||
¥{w.userCommissionInfo.totalCommission.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-gray-500">已提现:</span>
|
||||
<span className="text-gray-400">
|
||||
¥{w.userCommissionInfo.withdrawnEarnings.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-gray-500">待审核:</span>
|
||||
<span className="text-orange-400">
|
||||
¥{w.userCommissionInfo.pendingWithdrawals.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4 pt-1 border-t border-gray-700/30">
|
||||
<span className="text-gray-500">审核后余额:</span>
|
||||
<span
|
||||
className={
|
||||
w.userCommissionInfo.availableAfterThis >= 0
|
||||
? 'text-green-400 font-medium'
|
||||
: 'text-red-400 font-medium'
|
||||
}
|
||||
>
|
||||
¥{w.userCommissionInfo.availableAfterThis.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 text-xs">暂无数据</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{getStatusBadge(w.status)}
|
||||
{w.errorMessage && (
|
||||
<p className="text-xs text-red-400 mt-1">{w.errorMessage}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-gray-400">
|
||||
{(w.processedAt ?? w.completed_at)
|
||||
? new Date(w.processedAt ?? w.completed_at ?? '').toLocaleString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
{(w.status === 'pending' || w.status === 'pending_confirm') && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApprove(w.id)}
|
||||
disabled={processing === w.id}
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
批准
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleReject(w.id)}
|
||||
disabled={processing === w.id}
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10 bg-transparent"
|
||||
>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(w.status === 'success' || w.status === 'completed') &&
|
||||
w.transactionId && (
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{w.transactionId}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
soul-admin/src/vite-env.d.ts
vendored
Normal file
9
soul-admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
24
soul-admin/tsconfig.json
Normal file
24
soul-admin/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
soul-admin/tsconfig.node.json
Normal file
9
soul-admin/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
soul-admin/tsconfig.tsbuildinfo
Normal file
1
soul-admin/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.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/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"}
|
||||
15
soul-admin/vite.config.ts
Normal file
15
soul-admin/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
},
|
||||
})
|
||||
248
开发文档/2、架构/拆解计划_管理端抽离与API转Gin.md
Normal file
248
开发文档/2、架构/拆解计划_管理端抽离与API转Gin.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# 拆解计划:管理端抽离 + API 转 Gin(API 路径不变、无缝切换)
|
||||
|
||||
## 目标与原则
|
||||
|
||||
- **管理端**:从当前 Next 单体中抽离为独立前端项目(SPA),所有请求使用**可配置 baseUrl**,**API 路径与现网完全一致**(如 `/api/orders`、`/api/admin/withdrawals`),便于先对接到现有 Next,后续一键切到 Gin。
|
||||
- **小程序**:不改动;仅在未来切换后端时修改 `baseUrl` 指向 Gin。
|
||||
- **后端**:先仍由当前 Next 提供 API;第二阶段用 **Gin** 重写全部接口,**路径、方法、请求/响应体与现有 Next API 保持一致**,实现无缝切换。
|
||||
|
||||
---
|
||||
|
||||
## 阶段一:管理端抽离(独立前端)
|
||||
|
||||
### 1.1 产出物
|
||||
|
||||
- 新仓库或新目录:**soul-admin**(独立 SPA)
|
||||
- 技术栈:React 18 + TypeScript + Vite 5 + React Router 6 + Tailwind CSS 4 + Radix UI(与现有一致)+ Zustand + 统一 API 封装(baseUrl 来自 env)
|
||||
|
||||
### 1.2 API 基地址规范(必须 100% 遵守)
|
||||
|
||||
- 环境变量:`VITE_API_BASE_URL`(如开发 `http://localhost:3006`,生产 `https://soul.quwanzhi.com`)。
|
||||
- 所有请求统一为:`${VITE_API_BASE_URL}${path}`,其中 **path 与现网完全一致**,例如:
|
||||
- `/api/admin`(GET 鉴权 / POST 登录)
|
||||
- `/api/admin/logout`(POST)
|
||||
- `/api/orders`(GET)
|
||||
- `/api/db/users`(GET/POST/DELETE)
|
||||
- `/api/admin/withdrawals`(GET/POST)
|
||||
- 等等(见下方完整清单)。
|
||||
- 禁止在管理端项目内写死域名或写死 `/api/...` 相对路径(相对路径仅在请求封装内与 baseUrl 拼接)。
|
||||
|
||||
### 1.3 管理端页面与 API 对照表(迁移时逐项核对)
|
||||
|
||||
| 管理端页面 | 使用的 API(路径保持不变) |
|
||||
|------------|----------------------------|
|
||||
| 登录 `/admin/login` | POST `/api/admin`(登录)、GET `/api/admin`(鉴权) |
|
||||
| 布局/侧栏 | GET `/api/admin`(鉴权)、POST `/api/admin/logout`(退出) |
|
||||
| 数据概览 `/admin` | GET `/api/db/users`、GET `/api/orders` |
|
||||
| 订单管理 `/admin/orders` | GET `/api/orders`、GET `/api/db/users` |
|
||||
| 用户管理 `/admin/users` | GET `/api/db/users`、DELETE `/api/db/users?id=xxx`、POST `/api/db/users`、GET `/api/db/users/referrals?userId=xxx` |
|
||||
| 交易中心 `/admin/distribution` | GET `/api/admin/distribution/overview`、GET `/api/db/users`、GET `/api/orders`、GET `/api/db/distribution`、GET `/api/admin/withdrawals`、POST `/api/admin/withdrawals`(审核/打款) |
|
||||
| 提现管理 `/admin/withdrawals` | GET `/api/admin/withdrawals?status=xxx`、POST `/api/admin/withdrawals`(审核/打款) |
|
||||
| 内容管理 `/admin/content` | GET `/api/db/book?action=read&id=xxx`、POST `/api/db/book`、POST `/api/upload`、GET `/api/search?q=xxx`、GET `/api/db/book?action=export`、POST `/api/db/init` |
|
||||
| 章节管理 `/admin/chapters` | GET `/api/admin/chapters`、POST `/api/admin/chapters`、PUT `/api/admin/chapters`、DELETE `/api/admin/chapters` |
|
||||
| 推广设置 `/admin/referral-settings` | GET `/api/db/config?key=referral_config`、POST `/api/db/config` |
|
||||
| 系统设置 `/admin/settings` | GET `/api/db/config`、POST `/api/db/settings`、POST `/api/db/config`(多次)、GET `/api/db/config`(验证) |
|
||||
| 站点/支付/二维码 `/admin/site`、`/admin/payment`、`/admin/qrcodes` | 依赖 `useStore().fetchSettings()` → GET `/api/config`;若另有保存逻辑需按实际请求补全 |
|
||||
| 找伙伴配置 `/admin/match` | GET `/api/db/config?key=match_config`、POST `/api/db/config` |
|
||||
|
||||
说明:`/api/db/settings` 在当前 Next 中未见对应 route,若 Next 未实现则 Gin 阶段需实现该路径并与管理端约定请求/响应体。
|
||||
|
||||
### 1.4 迁移清单(执行顺序)— Phase 1 已完成
|
||||
|
||||
**已完成**:独立项目 `soul-admin/` 已创建,所有请求通过 `src/api/client.ts` 使用 `VITE_API_BASE_URL` + 与现网一致的 path,API 路径未做任何改动。
|
||||
|
||||
1. 创建 soul-admin 项目(Vite + React + TS + Tailwind + React Router)。
|
||||
2. 配置 `VITE_API_BASE_URL`,实现统一请求封装(如 `src/api/client.ts`),所有 `fetch` 使用 `baseUrl + path`,path 与上表一致。
|
||||
3. 迁移 `app/admin/layout.tsx` → 管理端布局与侧栏;鉴权请求 GET `/api/admin`、POST `/api/admin/logout` 走封装。
|
||||
4. 迁移 `app/admin/login/page.tsx` → 登录页,POST `/api/admin` 走封装。
|
||||
5. 按上表逐页迁移:`page.tsx`、`loading.tsx`(可改为本地 loading 状态),替换 `fetch('/api/...')` 为封装后的请求(路径不变)。
|
||||
6. 迁移管理端用到的 `components/ui/*` 及 `components/admin/*`(若有);迁移 `lib/store.ts` 中管理端会用到的部分,且其中 `fetch` 改为使用 baseUrl 封装。
|
||||
7. 移除对 `next/link`、`next/navigation` 的依赖,改用 React Router 的 `Link`、`useNavigate`、`useLocation`;路由表与现有 `/admin/*` 一一对应。
|
||||
8. 验收:独立启动管理端,`VITE_API_BASE_URL=http://localhost:3006`,登录、各页数据加载、提现/订单等操作均正常,且浏览器网络请求 path 与现网一致。
|
||||
|
||||
---
|
||||
|
||||
## 阶段二:API 转 Gin(路径不变、无缝切换)
|
||||
|
||||
### 2.1 产出物
|
||||
|
||||
- 新仓库或新目录:**soul-server**(Gin 项目,Go 1.25.7)。
|
||||
- 所有对外 HTTP 路径与 Next 时期**完全一致**,请求方法、Query、Body、响应 JSON 结构与现有接口保持一致(以便管理端与小程序的 baseUrl 仅改域名/端口即可)。
|
||||
|
||||
### 2.2 完整 API 路径清单(73 个 Route → 路径规范)
|
||||
|
||||
以下为当前 `app/api` 下 route 与路径的对应关系,Gin 需逐条实现,路径不可改。
|
||||
|
||||
| 序号 | 路径 | 方法 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 1 | /api/admin | GET, POST | 鉴权 / 登录 |
|
||||
| 2 | /api/admin/logout | POST | 退出 |
|
||||
| 3 | /api/admin/chapters | GET, POST, PUT, DELETE | 后台章节 |
|
||||
| 4 | /api/admin/content | (见 content) | 后台内容 |
|
||||
| 5 | /api/admin/distribution/overview | GET | 分销概览 |
|
||||
| 6 | /api/admin/payment | (依实现) | 后台支付配置 |
|
||||
| 7 | /api/admin/referral | (依实现) | 后台推荐 |
|
||||
| 8 | /api/admin/withdrawals | GET, POST | 提现列表与审核 |
|
||||
| 9 | /api/auth/login | POST | C 端登录 |
|
||||
| 10 | /api/auth/reset-password | POST | 重置密码 |
|
||||
| 11 | /api/book/all-chapters | GET | 全部章节 |
|
||||
| 12 | /api/book/chapter/:id | GET | 单章 |
|
||||
| 13 | /api/book/chapters | GET, POST, PUT, DELETE | 章节 CRUD |
|
||||
| 14 | /api/book/hot | GET | 热门 |
|
||||
| 15 | /api/book/latest-chapters | GET | 最新章节 |
|
||||
| 16 | /api/book/stats | GET | 统计 |
|
||||
| 17 | /api/book/search | GET | 搜索 |
|
||||
| 18 | /api/book/sync | POST | 同步 |
|
||||
| 19 | /api/config | GET | 前端配置 |
|
||||
| 20 | /api/content | GET | 内容 |
|
||||
| 21 | /api/ckb/join | POST | CKB 加入 |
|
||||
| 22 | /api/ckb/match | POST | CKB 匹配 |
|
||||
| 23 | /api/ckb/sync | POST | CKB 同步 |
|
||||
| 24 | /api/cron/sync-orders | GET/POST | 定时同步订单 |
|
||||
| 25 | /api/cron/unbind-expired | GET/POST | 定时解绑 |
|
||||
| 26 | /api/db/book | GET, POST | 书/内容 |
|
||||
| 27 | /api/db/chapters | GET | 章节 |
|
||||
| 28 | /api/db/config | GET, POST | 系统配置 |
|
||||
| 29 | /api/db/distribution | GET | 分销数据 |
|
||||
| 30 | /api/db/init | POST | 初始化 |
|
||||
| 31 | /api/db/migrate | POST | 迁移 |
|
||||
| 32 | /api/db/users | GET, POST, DELETE | 用户 |
|
||||
| 33 | /api/db/users/referrals | GET | 用户推荐列表 |
|
||||
| 34 | /api/distribution | GET | 分销 |
|
||||
| 35 | /api/distribution/auto-withdraw-config | GET, POST | 自动提现配置 |
|
||||
| 36 | /api/distribution/messages | GET, POST | 消息 |
|
||||
| 37 | /api/documentation/generate | POST | 文档生成 |
|
||||
| 38 | /api/match/config | GET, POST | 找伙伴配置 |
|
||||
| 39 | /api/match/users | GET | 匹配用户 |
|
||||
| 40 | /api/menu | GET | 菜单 |
|
||||
| 41 | /api/miniprogram/login | POST | 小程序登录 |
|
||||
| 42 | /api/miniprogram/pay | POST | 小程序支付 |
|
||||
| 43 | /api/miniprogram/pay/notify | POST | 支付回调 |
|
||||
| 44 | /api/miniprogram/phone | POST | 手机号 |
|
||||
| 45 | /api/miniprogram/qrcode | GET | 二维码 |
|
||||
| 46 | /api/orders | GET | 订单列表 |
|
||||
| 47 | /api/payment/alipay/notify | POST | 支付宝回调 |
|
||||
| 48 | /api/payment/callback | GET/POST | 支付回调 |
|
||||
| 49 | /api/payment/create-order | POST | 创建订单 |
|
||||
| 50 | /api/payment/methods | GET | 支付方式 |
|
||||
| 51 | /api/payment/query | GET | 查询订单 |
|
||||
| 52 | /api/payment/status/:orderSn | GET | 订单状态 |
|
||||
| 53 | /api/payment/verify | POST | 核销 |
|
||||
| 54 | /api/payment/wechat/notify | POST | 微信支付回调 |
|
||||
| 55 | /api/payment/wechat/transfer/notify | POST | 微信转账回调 |
|
||||
| 56 | /api/referral/bind | POST | 绑定推荐码 |
|
||||
| 57 | /api/referral/data | GET | 分销数据 |
|
||||
| 58 | /api/referral/visit | POST | 推荐访问 |
|
||||
| 59 | /api/search | GET | 搜索 |
|
||||
| 60 | /api/sync | POST | 同步 |
|
||||
| 61 | /api/upload | POST | 上传 |
|
||||
| 62 | /api/user/addresses | GET, POST | 地址列表与新增 |
|
||||
| 63 | /api/user/addresses/:id | GET, PUT, DELETE | 地址单条 |
|
||||
| 64 | /api/user/check-purchased | GET | 是否已购 |
|
||||
| 65 | /api/user/profile | GET, POST | 用户资料 |
|
||||
| 66 | /api/user/purchase-status | GET | 购买状态 |
|
||||
| 67 | /api/user/reading-progress | GET, POST | 阅读进度 |
|
||||
| 68 | /api/user/track | GET | 行为轨迹 |
|
||||
| 69 | /api/user/update | POST | 更新用户 |
|
||||
| 70 | /api/wechat/login | POST | 微信登录 |
|
||||
| 71 | /api/withdraw | POST | 申请提现 |
|
||||
| 72 | /api/withdraw/records | GET | 提现记录 |
|
||||
| 73 | /api/withdraw/pending-confirm | GET | 待确认提现 |
|
||||
|
||||
说明:部分路径在 Next 中为同一 route 多方法(如 GET/POST),Gin 需按现有行为实现;带 `:id`、`:orderSn` 为路径参数,与 Next 动态段一致。
|
||||
|
||||
### 2.3 Gin 实现顺序建议
|
||||
|
||||
1. 基础设施:Go 1.25.7、Gin、GORM、MySQL、env 配置、CORS、日志。
|
||||
2. 鉴权:复刻现有 Cookie 或改为 JWT(若改 JWT,管理端需同步改为带 Authorization 头);小程序 openid/session 逻辑与现网一致。
|
||||
3. 按「管理端必需」优先:`/api/admin`、`/api/admin/logout`、`/api/db/*`、`/api/orders`、`/api/admin/withdrawals`、`/api/admin/distribution/overview`、`/api/admin/chapters`、`/api/config`、`/api/db/config`、`/api/db/settings`(若现网无则按管理端调用约定实现)。
|
||||
4. 再实现小程序与 C 端所需:`/api/miniprogram/*`、`/api/wechat/*`、`/api/user/*`、`/api/book/*`、`/api/referral/*`、`/api/payment/*`、`/api/withdraw/*` 等。
|
||||
5. 最后:定时任务对应接口、文档生成等。
|
||||
|
||||
### 2.4 无缝切换检查清单
|
||||
|
||||
- [ ] 管理端 `VITE_API_BASE_URL` 改为 Gin 地址后,登录、各页请求均 200,数据与 Next 时期一致。
|
||||
- [ ] 小程序 `baseUrl` 改为 Gin 地址后,登录、支付、提现、推荐等流程正常。
|
||||
- [ ] 所有 73 个路径在 Gin 中均有实现,且请求/响应与现网兼容(可做契约测试或对比脚本)。
|
||||
|
||||
---
|
||||
|
||||
## 附录:当前 Next 中 route 文件与路径映射(供 Gin 对照)
|
||||
|
||||
```
|
||||
app/api/admin/route.ts → /api/admin
|
||||
app/api/admin/logout/route.ts → /api/admin/logout
|
||||
app/api/admin/chapters/route.ts → /api/admin/chapters
|
||||
app/api/admin/content/route.ts → /api/admin/content
|
||||
app/api/admin/distribution/overview/route.ts → /api/admin/distribution/overview
|
||||
app/api/admin/payment/route.ts → /api/admin/payment
|
||||
app/api/admin/referral/route.ts → /api/admin/referral
|
||||
app/api/admin/withdrawals/route.ts → /api/admin/withdrawals
|
||||
app/api/auth/login/route.ts → /api/auth/login
|
||||
app/api/auth/reset-password/route.ts → /api/auth/reset-password
|
||||
app/api/book/all-chapters/route.ts → /api/book/all-chapters
|
||||
app/api/book/chapter/[id]/route.ts → /api/book/chapter/:id
|
||||
app/api/book/chapters/route.ts → /api/book/chapters
|
||||
app/api/book/hot/route.ts → /api/book/hot
|
||||
app/api/book/latest-chapters/route.ts → /api/book/latest-chapters
|
||||
app/api/book/stats/route.ts → /api/book/stats
|
||||
app/api/book/search/route.ts → /api/book/search
|
||||
app/api/book/sync/route.ts → /api/book/sync
|
||||
app/api/config/route.ts → /api/config
|
||||
app/api/content/route.ts → /api/content
|
||||
app/api/ckb/join/route.ts → /api/ckb/join
|
||||
app/api/ckb/match/route.ts → /api/ckb/match
|
||||
app/api/ckb/sync/route.ts → /api/ckb/sync
|
||||
app/api/cron/sync-orders/route.ts → /api/cron/sync-orders
|
||||
app/api/cron/unbind-expired/route.ts → /api/cron/unbind-expired
|
||||
app/api/db/book/route.ts → /api/db/book
|
||||
app/api/db/chapters/route.ts → /api/db/chapters
|
||||
app/api/db/config/route.ts → /api/db/config
|
||||
app/api/db/distribution/route.ts → /api/db/distribution
|
||||
app/api/db/init/route.ts → /api/db/init
|
||||
app/api/db/migrate/route.ts → /api/db/migrate
|
||||
app/api/db/users/route.ts → /api/db/users
|
||||
app/api/db/users/referrals/route.ts → /api/db/users/referrals
|
||||
app/api/distribution/route.ts → /api/distribution
|
||||
app/api/distribution/auto-withdraw-config/route.ts → /api/distribution/auto-withdraw-config
|
||||
app/api/distribution/messages/route.ts → /api/distribution/messages
|
||||
app/api/documentation/generate/route.ts → /api/documentation/generate
|
||||
app/api/match/config/route.ts → /api/match/config
|
||||
app/api/match/users/route.ts → /api/match/users
|
||||
app/api/menu/route.ts → /api/menu
|
||||
app/api/miniprogram/login/route.ts → /api/miniprogram/login
|
||||
app/api/miniprogram/pay/route.ts → /api/miniprogram/pay
|
||||
app/api/miniprogram/pay/notify/route.ts → /api/miniprogram/pay/notify
|
||||
app/api/miniprogram/phone/route.ts → /api/miniprogram/phone
|
||||
app/api/miniprogram/qrcode/route.ts → /api/miniprogram/qrcode
|
||||
app/api/orders/route.ts → /api/orders
|
||||
app/api/payment/alipay/notify/route.ts → /api/payment/alipay/notify
|
||||
app/api/payment/callback/route.ts → /api/payment/callback
|
||||
app/api/payment/create-order/route.ts → /api/payment/create-order
|
||||
app/api/payment/methods/route.ts → /api/payment/methods
|
||||
app/api/payment/query/route.ts → /api/payment/query
|
||||
app/api/payment/status/[orderSn]/route.ts → /api/payment/status/:orderSn
|
||||
app/api/payment/verify/route.ts → /api/payment/verify
|
||||
app/api/payment/wechat/notify/route.ts → /api/payment/wechat/notify
|
||||
app/api/payment/wechat/transfer/notify/route.ts → /api/payment/wechat/transfer/notify
|
||||
app/api/referral/bind/route.ts → /api/referral/bind
|
||||
app/api/referral/data/route.ts → /api/referral/data
|
||||
app/api/referral/visit/route.ts → /api/referral/visit
|
||||
app/api/search/route.ts → /api/search
|
||||
app/api/sync/route.ts → /api/sync
|
||||
app/api/upload/route.ts → /api/upload
|
||||
app/api/user/addresses/route.ts → /api/user/addresses
|
||||
app/api/user/addresses/[id]/route.ts → /api/user/addresses/:id
|
||||
app/api/user/check-purchased/route.ts → /api/user/check-purchased
|
||||
app/api/user/profile/route.ts → /api/user/profile
|
||||
app/api/user/purchase-status/route.ts → /api/user/purchase-status
|
||||
app/api/user/reading-progress/route.ts → /api/user/reading-progress
|
||||
app/api/user/track/route.ts → /api/user/track
|
||||
app/api/user/update/route.ts → /api/user/update
|
||||
app/api/wechat/login/route.ts → /api/wechat/login
|
||||
app/api/withdraw/route.ts → /api/withdraw
|
||||
app/api/withdraw/records/route.ts → /api/withdraw/records
|
||||
app/api/withdraw/pending-confirm/route.ts → /api/withdraw/pending-confirm
|
||||
```
|
||||
|
||||
注意:当前项目中没有 `app/api/db/settings/route.ts`,管理端系统设置页调用了 POST `/api/db/settings`。若 Next 未实现,Gin 需新增该路径并约定 body/response 与前端一致。
|
||||
@@ -42,6 +42,28 @@
|
||||
| **API密钥(v2)** | `wx3e31b068be59ddc131b068be59ddc2` | 32位 |
|
||||
| **支付回调地址** | `https://soul.quwanzhi.com/api/miniprogram/pay/notify` | |
|
||||
|
||||
#### 支付接入证书与 APIv3(支付/转账共用)
|
||||
|
||||
| 项目 | 值 | 备注 |
|
||||
|:---|:---|:---|
|
||||
| **商户号(mch_id)** | `1318592501` | 与上一致 |
|
||||
| **APIv3 密钥(api_v3_key)** | `wx3e31b068be59ddc131b068be59ddc2` | 商户平台 → API安全 → APIv3密钥 |
|
||||
| **证书序列号(cert_serial_no)** | `4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5` | 可从证书文件读取或直接配置 |
|
||||
| **公钥证书 URL** | `https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_cert.pem` | 可选:用于本地读取序列号 |
|
||||
| **私钥文件 URL** | `https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem` | 可选:支持通过 WECHAT_KEY_URL 拉取 |
|
||||
|
||||
**.env 配置示例(二选一)**
|
||||
- **方式 A(推荐:本地文件)**:将上述两个 pem 下载到项目或服务器目录,例如 `./certs/`,然后配置:
|
||||
- `WECHAT_MCH_ID=1318592501`
|
||||
- `WECHAT_API_V3_KEY=wx3e31b068be59ddc131b068be59ddc2`
|
||||
- `WECHAT_MCH_CERT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5`
|
||||
- `WECHAT_CERT_PATH=./certs/apiclient_cert.pem`
|
||||
- `WECHAT_KEY_PATH=./certs/apiclient_key.pem`
|
||||
- **方式 B(证书序列号 + 私钥 URL)**:不下载证书文件时,可只配序列号,私钥用 URL 拉取(仅转账/支付相关接口支持):
|
||||
- `WECHAT_MCH_CERT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5`
|
||||
- `WECHAT_KEY_URL=https://karuocert.oss-cn-shenzhen.aliyuncs.com/1318592501/apiclient_key.pem`
|
||||
注意:私钥 URL 若为公网可访问,存在泄露风险,建议仅内网或鉴权后使用。
|
||||
|
||||
---
|
||||
|
||||
## 三、支付宝
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
| `WECHAT_APP_ID` / `WECHAT_APPID` | 小程序 AppID | 不填则用默认 wxb8bbb2b10dec74aa |
|
||||
| **`WECHAT_CERT_PATH`** | 商户证书路径(apiclient_cert.pem) | **与 payment 共用**;配置后证书序列号会**自动从该文件读取**,无需再配 WECHAT_MCH_CERT_SERIAL_NO |
|
||||
| **`WECHAT_KEY_PATH`** | 商户私钥路径(apiclient_key.pem) | **与 payment 共用** |
|
||||
| **`WECHAT_KEY_URL`** | 商户私钥文件 URL(可选) | 配置后首次转账时会拉取并缓存,适用于证书存放在 OSS 等场景 |
|
||||
| `WECHAT_API_V3_KEY` / `WECHAT_MCH_KEY` | APIv3 密钥(回调解密) | 与 payment 的 mchKey 共用 |
|
||||
|
||||
**只需在 .env 中配置与现有支付相同的证书路径即可**(若 payment 已配 `WECHAT_CERT_PATH`、`WECHAT_KEY_PATH`,转账会直接复用):
|
||||
@@ -117,7 +118,14 @@ WECHAT_CERT_PATH=./certs/apiclient_cert.pem
|
||||
WECHAT_KEY_PATH=./certs/apiclient_key.pem
|
||||
```
|
||||
|
||||
若未配置证书路径,也可单独配置:`WECHAT_MCH_CERT_SERIAL_NO`(证书序列号)、`WECHAT_MCH_PRIVATE_KEY` 或 `WECHAT_KEY_PATH`(私钥)。
|
||||
若证书存放在 OSS 等 URL,可只配证书序列号 + 私钥 URL(不下载到本地):
|
||||
|
||||
```env
|
||||
WECHAT_MCH_CERT_SERIAL_NO=4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5
|
||||
WECHAT_KEY_URL=https://your-bucket.oss.xxx.com/1318592501/apiclient_key.pem
|
||||
```
|
||||
|
||||
若未配置证书路径,也可单独配置:`WECHAT_MCH_CERT_SERIAL_NO`(证书序列号)、`WECHAT_MCH_PRIVATE_KEY`、`WECHAT_KEY_PATH`(本地路径)或 `WECHAT_KEY_URL`(私钥)。
|
||||
|
||||
### 3. 提取证书序列号
|
||||
|
||||
|
||||
Reference in New Issue
Block a user