更新提现功能,新增私钥文件 URL 配置选项以支持从远程拉取私钥,优化相关错误处理逻辑,确保在转账前正确加载私钥。同时,更新文档以反映新的环境变量配置,提升系统的灵活性和用户体验。

This commit is contained in:
乘风
2026-02-09 11:12:16 +08:00
parent de10a203b3
commit 6d7e06449f
53 changed files with 12485 additions and 5 deletions

View File

@@ -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_PATHapiclient_key.pem 路径)或 WECHAT_MCH_PRIVATE_KEY`,
errorMessage: `微信转账配置不完整:商户私钥未配置。请在 .env 中配置 WECHAT_KEY_PATHWECHAT_MCH_PRIVATE_KEY 或 WECHAT_KEY_URL`,
}
}

46
middleware.ts Normal file
View 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
View File

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

View File

@@ -0,0 +1,2 @@
# 开发环境:对接当前 Next 后端(与现网 API 路径完全一致,无缝切换)
VITE_API_BASE_URL=http://localhost:3006

2
soul-admin/.env.example Normal file
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
soul-admin/dist/index.html vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}

43
soul-admin/src/App.tsx Normal file
View 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

View 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 ''
}
/** 请求完整 URLbaseUrl + pathpath 必须与现网一致(如 /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' })
}

View 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>
)
}

View 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 }

View 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 }

View 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 }

View 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

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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
View 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);
}

View 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>
)
}

View 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
View 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>,
)

View 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>
)
}

File diff suppressed because it is too large Load Diff

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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">USDTPayPal等支付参数</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>&quot;...&quot; &quot;&quot;</li>
<li>&quot;...&quot; &quot;&quot;</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>
)
}

View 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> &quot;...&quot; </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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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"]
}

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true
},
"include": ["vite.config.ts"]
}

View 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
View 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,
},
})

View File

@@ -0,0 +1,248 @@
# 拆解计划:管理端抽离 + API 转 GinAPI 路径不变、无缝切换)
## 目标与原则
- **管理端**:从当前 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` + 与现网一致的 pathAPI 路径未做任何改动。
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/POSTGin 需按现有行为实现;带 `: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 与前端一致。

View File

@@ -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 若为公网可访问,存在泄露风险,建议仅内网或鉴权后使用。
---
## 三、支付宝

View File

@@ -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. 提取证书序列号