This commit is contained in:
Alex-larget
2026-03-18 21:10:11 +08:00
parent c1c03bde4c
commit 7f5a75e489
75 changed files with 24073 additions and 0 deletions

2
new-soul/soul-admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
.DS_Store

View File

@@ -0,0 +1,318 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-admin 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。
不安装依赖、不重启、不调用宝塔 API。
"""
from __future__ import print_function
import os
import sys
import shlex
import tempfile
import argparse
import zipfile
try:
import paramiko
except ImportError:
print("错误: 请先安装 paramiko")
print(" pip install paramiko")
sys.exit(1)
# ==================== 配置 ====================
# 站点根目录Nginx 等指向的目录的上一级,即 dist 的父目录)
DEPLOY_BASE_PATH = "/www/wwwroot/self/soul-admin"
# 切换后 chown 的属主,宝塔一般为 www:www空则跳过
DEPLOY_WWW_USER = os.environ.get("DEPLOY_WWW_USER", "www:www")
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
def get_cfg():
base = os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/")
return {
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
"user": os.environ.get("DEPLOY_USER", "root"),
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
"base_path": base,
"dist_path": base + "/dist",
"dist2_path": base + "/dist2",
"www_user": os.environ.get("DEPLOY_WWW_USER", DEPLOY_WWW_USER).strip(),
}
# ==================== 本地构建 ====================
def run_build(root):
"""执行本地 pnpm build"""
use_shell = sys.platform == "win32"
dist_dir = os.path.join(root, "dist")
index_html = os.path.join(dist_dir, "index.html")
try:
r = __import__("subprocess").run(
["pnpm", "build"],
cwd=root,
shell=use_shell,
timeout=300,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if r.returncode != 0:
print(" [失败] 构建失败,退出码:", r.returncode)
for line in (r.stdout or "").strip().split("\n")[-10:]:
print(" " + line)
return False
except __import__("subprocess").TimeoutExpired:
print(" [失败] 构建超时")
return False
except FileNotFoundError:
print(" [失败] 未找到 pnpm请安装: npm install -g pnpm")
return False
except Exception as e:
print(" [失败] 构建异常:", str(e))
return False
if not os.path.isfile(index_html):
print(" [失败] 未找到 dist/index.html")
return False
print(" [成功] 构建完成")
return True
# ==================== 打包 dist 为 zip ====================
def pack_dist_zip(root):
"""将本地 dist 目录打包为 zip解压到 dist2 后即为站点根内容)"""
print("[2/4] 打包 dist 为 zip ...")
dist_dir = os.path.join(root, "dist")
if not os.path.isdir(dist_dir):
print(" [失败] 未找到 dist 目录")
return None
index_html = os.path.join(dist_dir, "index.html")
if not os.path.isfile(index_html):
print(" [失败] 未找到 dist/index.html请先执行 pnpm build")
return None
zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip")
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for dirpath, _dirs, filenames in os.walk(dist_dir):
for f in filenames:
full = os.path.join(dirpath, f)
arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
zf.write(full, arcname)
print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
return zip_path
except Exception as e:
print(" [失败] 打包异常:", str(e))
return None
# ==================== SSH 上传并解压到 dist2 ====================
def upload_zip_and_extract_to_dist2(cfg, zip_path):
"""上传 zip 到服务器并解压到 dist2"""
print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
sys.stdout.flush()
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
sys.stdout.flush()
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
key_filename=cfg["ssh_key"],
timeout=30,
banner_timeout=30,
)
else:
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
password=cfg["password"],
timeout=30,
banner_timeout=30,
)
print(" [OK] SSH 已连接,正在上传 zip%.1f MB..." % zip_size_mb)
sys.stdout.flush()
remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
sftp = client.open_sftp()
chunk_mb = 5.0
last_reported = [0]
def _progress(transferred, total):
if total and total > 0:
now_mb = transferred / (1024 * 1024)
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
last_reported[0] = now_mb
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
sys.stdout.flush()
sftp.put(zip_path, remote_zip, callback=_progress)
if zip_size_mb >= chunk_mb:
print("")
print(" [OK] zip 已上传,正在服务器解压到 dist2 ...")
sys.stdout.flush()
sftp.close()
dist2 = cfg["dist2_path"]
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (
dist2,
dist2,
remote_zip,
dist2,
remote_zip,
)
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
if err:
print(" 服务器 stderr: %s" % err[:500])
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 解压失败,退出码: %s" % exit_status)
if out:
print(" stdout: %s" % out[:300])
return False
print(" [成功] 已解压到: %s" % dist2)
return True
except Exception as e:
print(" [失败] SSH 错误: %s" % str(e))
import traceback
traceback.print_exc()
return False
finally:
client.close()
# ==================== 服务器目录切换dist → dist1dist2 → dist ====================
def remote_swap_dist(cfg):
"""服务器上dist→dist1dist2→dist删除 dist1实现无缝切换"""
print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
key_filename=cfg["ssh_key"],
timeout=15,
)
else:
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
password=cfg["password"],
timeout=15,
)
base = cfg["base_path"]
# 若当前没有 dist首次部署则 dist2 直接改名为 dist若有 dist 则先备份再替换
cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % base
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 切换失败 (退出码: %s)" % exit_status)
if err:
print(" 服务器 stderr: %s" % err)
if out and "OK" not in out:
print(" 服务器 stdout: %s" % out)
return False
print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
# 切换后设置 www 访问权限,否则 Nginx 无法读文件导致无法访问
www_user = cfg.get("www_user")
if www_user:
dist_path = cfg["dist_path"]
chown_cmd = "chown -R %s %s && echo OK" % (www_user, shlex.quote(dist_path))
stdin, stdout, stderr = client.exec_command(chown_cmd, timeout=60)
chown_out = stdout.read().decode("utf-8", errors="replace").strip()
chown_err = stderr.read().decode("utf-8", errors="replace").strip()
if stdout.channel.recv_exit_status() != 0 or "OK" not in chown_out:
print(" [警告] chown 失败,站点可能无法访问: %s" % (chown_err or chown_out))
else:
print(" [成功] 已设置属主: %s" % www_user)
return True
except Exception as e:
print(" [失败] SSH 错误: %s" % str(e))
return False
finally:
client.close()
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(
description="soul-admin 静态站点部署dist2 解压后目录切换,无缝更新)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist",
)
parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build")
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
if os.path.isfile(os.path.join(script_dir, "package.json")):
root = script_dir
else:
root = os.path.dirname(script_dir)
cfg = get_cfg()
print("=" * 60)
print(" soul-admin 部署dist/dist2 无缝切换)")
print("=" * 60)
print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"]))
print("=" * 60)
if not args.no_build:
print("[1/4] 本地构建 pnpm build ...")
if not run_build(root):
return 1
else:
if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile(
os.path.join(root, "dist", "index.html")
):
print("[错误] 未找到 dist/index.html请先执行 pnpm build 或去掉 --no-build")
return 1
print("[1/4] 跳过本地构建")
zip_path = pack_dist_zip(root)
if not zip_path:
return 1
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
return 1
try:
os.remove(zip_path)
except Exception:
pass
if not remote_swap_dist(cfg):
return 1
print("")
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
return 0
if __name__ == "__main__":
sys.exit(main())

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>

View File

@@ -0,0 +1,59 @@
{
"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": {
"@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",
"@tiptap/extension-image": "^3.20.1",
"@tiptap/extension-link": "^3.20.1",
"@tiptap/extension-mention": "^3.20.1",
"@tiptap/extension-placeholder": "^3.20.1",
"@tiptap/extension-table": "^3.20.1",
"@tiptap/extension-table-cell": "^3.20.1",
"@tiptap/extension-table-header": "^3.20.1",
"@tiptap/extension-table-row": "^3.20.1",
"@tiptap/pm": "^3.20.1",
"@tiptap/react": "^3.20.1",
"@tiptap/starter-kit": "^3.20.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.562.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"tailwind-merge": "3.4.0",
"zustand": "5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.15.0",
"@tailwindcss/postcss": "^4.1.18",
"@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",
"picomatch": "4.0.2",
"postcss": "^8.4.49",
"tailwindcss": "^4.1.9",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.3"
}
}

4131
new-soul/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': {},
},
}

View File

@@ -0,0 +1,58 @@
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 { 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'
import { MatchRecordsPage } from './pages/match-records/MatchRecordsPage'
import { VipRolesPage } from './pages/vip-roles/VipRolesPage'
import { MentorsPage } from './pages/mentors/MentorsPage'
import { MentorConsultationsPage } from './pages/mentor-consultations/MentorConsultationsPage'
import { FindPartnerPage } from './pages/find-partner/FindPartnerPage'
import { ApiDocPage } from './pages/api-doc/ApiDocPage'
import { ApiDocsPage } from './pages/api-docs/ApiDocsPage'
import { NotFoundPage } from './pages/not-found/NotFoundPage'
function App() {
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="referral-settings" element={<ReferralSettingsPage />} />
<Route path="author-settings" element={<Navigate to="/settings?tab=author" replace />} />
<Route path="vip-roles" element={<VipRolesPage />} />
<Route path="mentors" element={<MentorsPage />} />
<Route path="mentor-consultations" element={<MentorConsultationsPage />} />
<Route path="admin-users" element={<Navigate to="/settings?tab=admin" replace />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="payment" element={<PaymentPage />} />
<Route path="site" element={<SitePage />} />
<Route path="qrcodes" element={<QRCodesPage />} />
<Route path="find-partner" element={<FindPartnerPage />} />
<Route path="match" element={<MatchPage />} />
<Route path="match-records" element={<MatchRecordsPage />} />
<Route path="api-doc" element={<ApiDocPage />} />
<Route path="api-docs" element={<ApiDocsPage />} />
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
)
}
export default App

View File

@@ -0,0 +1,28 @@
/**
* 管理端 JWT 本地存储localStorage与 soul-api JWT 鉴权配合
*/
const ADMIN_TOKEN_KEY = 'admin_token'
export function getAdminToken(): string | null {
try {
return localStorage.getItem(ADMIN_TOKEN_KEY)
} catch {
return null
}
}
export function setAdminToken(token: string): void {
try {
localStorage.setItem(ADMIN_TOKEN_KEY, token)
} catch {
// ignore
}
}
export function clearAdminToken(): void {
try {
localStorage.removeItem(ADMIN_TOKEN_KEY)
} catch {
// ignore
}
}

View File

@@ -0,0 +1,87 @@
import { get } from './client'
export interface CkbDevice {
id: number | string
memo: string
wechatId?: string
status?: 'online' | 'offline' | string
avatar?: string
nickname?: string
totalFriend?: number
}
export interface CkbDevicesResponse {
success?: boolean
error?: string
devices?: CkbDevice[]
total?: number
}
// 管理端 - 存客宝设备列表(供链接人与事选择设备)
export function getCkbDevices(params?: { page?: number; limit?: number; keyword?: string }) {
const search = new URLSearchParams()
if (params?.page) search.set('page', String(params.page))
if (params?.limit) search.set('limit', String(params.limit))
if (params?.keyword?.trim()) search.set('keyword', params.keyword.trim())
const qs = search.toString()
const path = qs ? `/api/admin/ckb/devices?${qs}` : '/api/admin/ckb/devices'
return get<CkbDevicesResponse>(path)
}
// 管理端 - 人物详情(本地 + 存客宝缓存字段),用于编辑回显
export interface PersonDetailResponse {
success?: boolean
error?: string
person?: {
personId: string
token?: string
name: string
label?: string
ckbApiKey?: string
greeting?: string
tips?: string
remarkType?: string
remarkFormat?: string
addFriendInterval?: number
startTime?: string
endTime?: string
deviceGroups?: string
}
}
export function getPersonDetail(personId: string) {
return get<PersonDetailResponse>(`/api/db/person?personId=${encodeURIComponent(personId)}`)
}
export interface CkbPlan {
id: number | string
name: string
apiKey?: string
sceneId?: number
scenario?: number
enabled?: boolean
greeting?: string
tips?: string
remarkType?: string
remarkFormat?: string
addInterval?: number
startTime?: string
endTime?: string
deviceGroups?: (number | string)[]
}
export interface CkbPlansResponse {
success?: boolean
error?: string
plans?: CkbPlan[]
total?: number
}
export function getCkbPlans(params?: { page?: number; limit?: number; keyword?: string }) {
const search = new URLSearchParams()
if (params?.page) search.set('page', String(params.page))
if (params?.limit) search.set('limit', String(params.limit))
if (params?.keyword?.trim()) search.set('keyword', params.keyword.trim())
const qs = search.toString()
return get<CkbPlansResponse>(qs ? `/api/admin/ckb/plans?${qs}` : '/api/admin/ckb/plans')
}

View File

@@ -0,0 +1,92 @@
/**
* 统一 API 请求封装
* 规则API 路径与现网完全一致,仅通过 baseUrl 区分环境Next 或未来 Gin
* 鉴权:管理端使用 JWT自动带 Authorization: Bearer <token>token 存 localStorage
*/
import { getAdminToken } from './auth'
/** 未设置环境变量时使用的默认 API 地址(零配置部署) */
const DEFAULT_API_BASE = 'https://soulapi.quwanzhi.com'
/** 请求超时(毫秒),避免接口无响应时一直卡在加载中 */
const REQUEST_TIMEOUT = 15000
const getBaseUrl = (): string => {
const url = import.meta.env.VITE_API_BASE_URL
if (typeof url === 'string' && url.length > 0) return url.replace(/\/$/, '')
return DEFAULT_API_BASE
}
/** 请求完整 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
* 若有 admin_tokenJWT则自动带 Authorization: Bearercredentials: 'include' 保留以兼容需 Cookie 的接口。
*/
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)
const token = getAdminToken()
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
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 controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
const res = await fetch(url, {
...init,
headers,
body,
credentials: 'include',
signal: controller.signal,
}).finally(() => clearTimeout(timeoutId))
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,239 @@
.rich-editor-wrapper {
border: 1px solid #374151;
border-radius: 0.5rem;
background: #0a1628;
overflow: hidden;
}
.rich-editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid #374151;
background: #0f1d32;
flex-wrap: wrap;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 1px;
}
.toolbar-divider {
width: 1px;
height: 20px;
background: #374151;
margin: 0 4px;
}
.rich-editor-toolbar button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.rich-editor-toolbar button:hover { background: #1f2937; color: #d1d5db; }
.rich-editor-toolbar button.is-active { background: rgba(56, 189, 172, 0.2); color: #38bdac; }
.rich-editor-toolbar button:disabled { opacity: 0.3; cursor: not-allowed; }
.link-tag-select {
background: #0a1628;
border: 1px solid #374151;
color: #d1d5db;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
max-width: 160px;
}
.link-input-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid #374151;
background: #0f1d32;
}
.link-input {
flex: 1;
background: #0a1628;
border: 1px solid #374151;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 13px;
}
.link-confirm, .link-remove {
padding: 4px 10px;
border-radius: 4px;
border: none;
font-size: 12px;
cursor: pointer;
}
.link-confirm { background: #38bdac; color: white; }
.link-remove { background: #374151; color: #9ca3af; }
.rich-editor-content {
min-height: 300px;
max-height: 500px;
overflow-y: auto;
padding: 12px 16px;
color: #e5e7eb;
font-size: 14px;
line-height: 1.7;
}
.rich-editor-content:focus { outline: none; }
.rich-editor-content h1 { font-size: 1.5em; font-weight: 700; margin: 0.8em 0 0.4em; color: white; }
.rich-editor-content h2 { font-size: 1.3em; font-weight: 600; margin: 0.7em 0 0.3em; color: white; }
.rich-editor-content h3 { font-size: 1.15em; font-weight: 600; margin: 0.6em 0 0.3em; color: white; }
.rich-editor-content p { margin: 0.4em 0; }
.rich-editor-content strong { color: white; }
.rich-editor-content code { background: #1f2937; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; color: #38bdac; }
.rich-editor-content pre { background: #1f2937; padding: 12px; border-radius: 6px; overflow-x: auto; margin: 0.6em 0; }
.rich-editor-content blockquote {
border-left: 3px solid #38bdac;
padding-left: 12px;
margin: 0.6em 0;
color: #9ca3af;
}
.rich-editor-content ul, .rich-editor-content ol { padding-left: 1.5em; margin: 0.4em 0; }
.rich-editor-content li { margin: 0.2em 0; }
.rich-editor-content hr { border: none; border-top: 1px solid #374151; margin: 1em 0; }
.rich-editor-content img { max-width: 100%; border-radius: 6px; margin: 0.5em 0; }
.rich-editor-content a, .rich-link { color: #38bdac; text-decoration: underline; cursor: pointer; }
.rich-editor-content table {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
}
.rich-editor-content th, .rich-editor-content td {
border: 1px solid #374151;
padding: 6px 10px;
text-align: left;
}
.rich-editor-content th { background: #1f2937; font-weight: 600; }
.rich-editor-content .ProseMirror-placeholder::before {
content: attr(data-placeholder);
color: #6b7280;
float: left;
height: 0;
pointer-events: none;
}
.mention-tag {
background: rgba(56, 189, 172, 0.15);
color: #38bdac;
border-radius: 4px;
padding: 1px 4px;
font-weight: 500;
}
/* #linkTag 高亮:与小程序 read.wxss .link-tag 金黄色保持一致 */
.link-tag-node {
background: rgba(255, 215, 0, 0.12);
color: #FFD700;
border-radius: 4px;
padding: 1px 4px;
font-weight: 500;
cursor: default;
user-select: all;
}
.mention-popup {
position: fixed;
z-index: 9999;
background: #1a2638;
border: 1px solid #374151;
border-radius: 8px;
padding: 4px;
min-width: 180px;
max-height: 240px;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
.mention-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
color: #d1d5db;
font-size: 13px;
}
.mention-item:hover, .mention-item.is-selected {
background: rgba(56, 189, 172, 0.15);
color: #38bdac;
}
.mention-name { font-weight: 500; }
.mention-id { font-size: 11px; color: #6b7280; }
.bubble-menu {
display: flex;
gap: 2px;
background: #1a2638;
border: 1px solid #374151;
border-radius: 6px;
padding: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.bubble-menu button {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 4px;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
}
.bubble-menu button:hover { background: #1f2937; color: #d1d5db; }
.bubble-menu button.is-active { color: #38bdac; }
/* @ 按钮高亮 */
.mention-trigger-btn { color: #38bdac !important; }
.mention-trigger-btn:hover { background: rgba(56, 189, 172, 0.2) !important; }
/* 上传进度条 */
.upload-progress-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
background: #0f1d32;
border-bottom: 1px solid #374151;
}
.upload-progress-track {
flex: 1;
height: 4px;
background: #1f2937;
border-radius: 2px;
overflow: hidden;
}
.upload-progress-fill {
height: 100%;
background: linear-gradient(90deg, #38bdac, #4ae3ce);
border-radius: 2px;
transition: width 0.3s ease;
}
.upload-progress-text {
font-size: 11px;
color: #38bdac;
white-space: nowrap;
}

View File

@@ -0,0 +1,635 @@
import { useEditor, EditorContent, type Editor, Node as TiptapNode, mergeAttributes } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Mention from '@tiptap/extension-mention'
import Placeholder from '@tiptap/extension-placeholder'
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table'
import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'
import {
Bold, Italic, Strikethrough, Code, List, ListOrdered, Quote,
Heading1, Heading2, Heading3, Image as ImageIcon, Link as LinkIcon,
Table as TableIcon, Undo, Redo, Minus, Video, AtSign,
} from 'lucide-react'
export interface PersonItem {
id: string // token文章 @ 时存此值,小程序用此兑换真实密钥
personId?: string // 管理端编辑/删除用
name: string
aliases?: string // comma-separated alternative names
label?: string
ckbApiKey?: string // 存客宝真实密钥,管理端可见,不对外暴露
ckbPlanId?: number
// 存客宝创建计划用(与 Cunkebao 好友申请设置一致)
remarkType?: string
remarkFormat?: string
addFriendInterval?: number
startTime?: string
endTime?: string
deviceGroups?: string
}
export interface LinkTagItem {
id: string
label: string
aliases?: string // comma-separated alternative labels
url: string
type: 'url' | 'miniprogram' | 'ckb'
appId?: string
pagePath?: string
}
export interface RichEditorRef {
getHTML: () => string
getMarkdown: () => string
}
interface RichEditorProps {
content: string
onChange: (html: string) => void
onImageUpload?: (file: File) => Promise<string>
onVideoUpload?: (file: File) => Promise<string>
persons?: PersonItem[]
linkTags?: LinkTagItem[]
onPersonCreate?: (name: string) => Promise<PersonItem | null>
placeholder?: string
className?: string
}
function normalizeMatchKey(value?: string): string {
return (value || '').trim().toLowerCase()
}
function getPersonMatchKeys(person: PersonItem): string[] {
return [person.name, ...(person.aliases ? person.aliases.split(',') : [])]
.map(normalizeMatchKey)
.filter(Boolean)
}
function getLinkTagMatchKeys(tag: LinkTagItem): string[] {
return [tag.label, ...(tag.aliases ? tag.aliases.split(',') : [])]
.map(normalizeMatchKey)
.filter(Boolean)
}
function autoMatchMentionsAndTags(html: string, persons: PersonItem[], linkTags: LinkTagItem[]): string {
if (!html || (!persons.length && !linkTags.length) || typeof document === 'undefined') return html
const personMap = new Map<string, PersonItem>()
const linkTagMap = new Map<string, LinkTagItem>()
for (const person of persons) {
for (const key of getPersonMatchKeys(person)) {
if (!personMap.has(key)) personMap.set(key, person)
}
}
for (const tag of linkTags) {
for (const key of getLinkTagMatchKeys(tag)) {
if (!linkTagMap.has(key)) linkTagMap.set(key, tag)
}
}
const container = document.createElement('div')
container.innerHTML = html
const processTextNode = (node: Text) => {
const text = node.textContent || ''
if (!text || (!text.includes('@') && !text.includes('') && !text.includes('#'))) return
const parent = node.parentNode
if (!parent) return
const fragment = document.createDocumentFragment()
const regex = /([@][^\s@##]+|#[^\s@##]+)/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
const [full] = match
const index = match.index
if (index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, index)))
}
if (full.startsWith('@') || full.startsWith('')) {
const person = personMap.get(normalizeMatchKey(full.slice(1)))
if (person) {
const span = document.createElement('span')
span.setAttribute('data-type', 'mention')
span.setAttribute('data-id', person.id)
span.setAttribute('data-label', person.name)
span.className = 'mention-tag'
span.textContent = `@${person.name}`
fragment.appendChild(span)
} else {
fragment.appendChild(document.createTextNode(full))
}
} else {
const tag = linkTagMap.get(normalizeMatchKey(full.slice(1)))
if (tag) {
const span = document.createElement('span')
span.setAttribute('data-type', 'linkTag')
span.setAttribute('data-url', tag.url || '')
span.setAttribute('data-tag-type', tag.type || 'url')
span.setAttribute('data-tag-id', tag.id || '')
span.setAttribute('data-page-path', tag.pagePath || '')
span.setAttribute('data-app-id', tag.appId || '')
if (tag.type === 'miniprogram' && tag.appId) {
span.setAttribute('data-mp-key', tag.appId)
}
span.className = 'link-tag-node'
span.textContent = `#${tag.label}`
fragment.appendChild(span)
} else {
fragment.appendChild(document.createTextNode(full))
}
}
lastIndex = index + full.length
}
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
}
parent.replaceChild(fragment, node)
}
const walk = (node: globalThis.Node) => {
if (node.nodeType === globalThis.Node.ELEMENT_NODE) {
const el = node as HTMLElement
if (el.matches('[data-type="mention"], [data-type="linkTag"], a, code, pre, script, style')) return
Array.from(el.childNodes).forEach(walk)
return
}
if (node.nodeType === globalThis.Node.TEXT_NODE) processTextNode(node as Text)
}
Array.from(container.childNodes).forEach(walk)
return container.innerHTML
}
function htmlToMarkdown(html: string): string {
if (!html) return ''
let md = html
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n')
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n')
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n')
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**')
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**')
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*')
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*')
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, '~~$1~~')
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, '~~$1~~')
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`')
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n')
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, '![$2]($1)')
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '![]($1)')
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)')
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n')
md = md.replace(/<\/?[uo]l[^>]*>/gi, '\n')
md = md.replace(/<br\s*\/?>/gi, '\n')
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n')
md = md.replace(/<hr\s*\/?>/gi, '---\n\n')
md = md.replace(/<span[^>]*data-type="mention"[^>]*data-id="([^"]*)"[^>]*>@([^<]*)<\/span>/gi, '@$2')
md = md.replace(/<span[^>]*data-type="linkTag"[^>]*data-url="([^"]*)"[^>]*>#([^<]*)<\/span>/gi, '#[$2]($1)')
md = md.replace(/<[^>]+>/g, '')
md = md.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
md = md.replace(/\n{3,}/g, '\n\n')
return md.trim()
}
function markdownToHtml(md: string): string {
if (!md) return ''
if (md.startsWith('<') && md.includes('</')) return md
let html = md
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>')
html = html.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '<em>$1</em>')
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>')
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
html = html.replace(/^> (.+)$/gm, '<blockquote><p>$1</p></blockquote>')
html = html.replace(/^---$/gm, '<hr />')
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
const lines = html.split('\n')
const result: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
if (/^<(?:h[1-6]|blockquote|hr|li|ul|ol|table|img)/.test(trimmed)) {
result.push(trimmed)
} else {
result.push(`<p>${trimmed}</p>`)
}
}
return result.join('')
}
/**
* LinkTagExtension — 自定义 TipTap 内联节点,保留所有 data-* 属性
* 解决insertContent(html) 会经过 TipTap schema 导致自定义属性被丢弃的问题
*/
const LinkTagExtension = TiptapNode.create({
name: 'linkTag',
group: 'inline',
inline: true,
selectable: true,
atom: true,
addAttributes() {
return {
label: { default: '' },
url: { default: '' },
tagType: { default: 'url', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-type') || 'url' },
tagId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-tag-id') || '' },
pagePath: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-page-path') || '' },
appId: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-app-id') || '' },
mpKey: { default: '', parseHTML: (el: HTMLElement) => el.getAttribute('data-mp-key') || '' },
}
},
parseHTML() {
return [{ tag: 'span[data-type="linkTag"]', getAttrs: (el: HTMLElement) => ({
label: el.textContent?.replace(/^#/, '').trim() || '',
url: el.getAttribute('data-url') || '',
tagType: el.getAttribute('data-tag-type') || 'url',
tagId: el.getAttribute('data-tag-id') || '',
pagePath: el.getAttribute('data-page-path')|| '',
appId: el.getAttribute('data-app-id') || '',
mpKey: el.getAttribute('data-mp-key') || '',
}) }]
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: Record<string, any> }) {
return ['span', mergeAttributes(HTMLAttributes, {
'data-type': 'linkTag',
'data-url': node.attrs.url,
'data-tag-type': node.attrs.tagType,
'data-tag-id': node.attrs.tagId,
'data-page-path': node.attrs.pagePath,
'data-app-id': node.attrs.appId || '',
'data-mp-key': node.attrs.mpKey || node.attrs.appId || '',
class: 'link-tag-node',
}), `#${node.attrs.label}`]
},
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const MentionSuggestion = (
personsRef: React.RefObject<PersonItem[]>,
onPersonCreateRef: React.RefObject<((name: string) => Promise<PersonItem | null>) | undefined>
): any => ({
items: ({ query }: { query: string }) => {
const persons = personsRef.current || []
const q = query.toLowerCase().trim()
const filtered = persons.filter(p => {
if (p.name.toLowerCase().includes(q) || p.id.includes(q)) return true
if (p.aliases) {
return p.aliases.split(',').some(a => a.trim().toLowerCase().includes(q))
}
return false
}).slice(0, 8)
// 当 query 有内容且无精确名称匹配时,追加「新增人物」选项
if (q.length >= 1 && !persons.some(p => p.name.toLowerCase() === q)) {
filtered.push({ id: '__new__', name: `+ 新增「${query.trim()}`, _newName: query.trim() } as PersonItem & { _newName: string })
}
return filtered
},
render: () => {
let popup: HTMLDivElement | null = null
let selectedIndex = 0
let items: PersonItem[] = []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let command: ((p: { id: string; label: string }) => void) | null = null
const selectItem = async (idx: number) => {
const item = items[idx]
if (!item || !command) return
const newItem = item as PersonItem & { _newName?: string }
if (newItem.id === '__new__' && newItem._newName && onPersonCreateRef.current) {
try {
const created = await onPersonCreateRef.current(newItem._newName)
if (created) command({ id: created.id, label: created.name })
} catch (_) { /* 创建失败,不插入 */ }
} else {
command({ id: item.id, label: item.name })
}
}
const update = () => {
if (!popup) return
popup.innerHTML = items.map((item, i) =>
`<div class="mention-item ${i === selectedIndex ? 'is-selected' : ''}" data-index="${i}">
<span class="mention-name">@${item.name}</span>
<span class="mention-id">${(item as PersonItem & { _newName?: string }).id === '__new__' ? '' : (item.label || item.id)}</span>
</div>`
).join('')
popup.querySelectorAll('.mention-item').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.getAttribute('data-index') || '0')
selectItem(idx)
})
})
}
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onStart: (props: any) => {
popup = document.createElement('div')
popup.className = 'mention-popup'
document.body.appendChild(popup)
items = props.items
command = props.command
selectedIndex = 0
update()
if (props.clientRect) {
const rect = props.clientRect()
if (rect) {
popup.style.top = `${rect.bottom + 4}px`
popup.style.left = `${rect.left}px`
}
}
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onUpdate: (props: any) => {
items = props.items
command = props.command
selectedIndex = 0
update()
if (props.clientRect && popup) {
const rect = props.clientRect()
if (rect) {
popup.style.top = `${rect.bottom + 4}px`
popup.style.left = `${rect.left}px`
}
}
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'ArrowUp') { selectedIndex = Math.max(0, selectedIndex - 1); update(); return true }
if (props.event.key === 'ArrowDown') { selectedIndex = Math.min(items.length - 1, selectedIndex + 1); update(); return true }
if (props.event.key === 'Enter') { selectItem(selectedIndex); return true }
if (props.event.key === 'Escape') { popup?.remove(); popup = null; return true }
return false
},
onExit: () => { popup?.remove(); popup = null },
}
},
})
const RichEditor = forwardRef<RichEditorRef, RichEditorProps>(({
content,
onChange,
onImageUpload,
onVideoUpload,
persons = [],
linkTags = [],
onPersonCreate,
placeholder = '开始编辑内容...',
className,
}, ref) => {
const fileInputRef = useRef<HTMLInputElement>(null)
const videoInputRef = useRef<HTMLInputElement>(null)
const [videoUploading, setVideoUploading] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const [showLinkInput, setShowLinkInput] = useState(false)
const [imageUploading, setImageUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const initialContent = useRef(autoMatchMentionsAndTags(markdownToHtml(content), persons, linkTags))
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const personsRef = useRef(persons)
personsRef.current = persons
const linkTagsRef = useRef(linkTags)
linkTagsRef.current = linkTags
const onPersonCreateRef = useRef(onPersonCreate)
onPersonCreateRef.current = onPersonCreate
const debounceTimer = useRef<ReturnType<typeof setTimeout>>()
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({ inline: true, allowBase64: true }),
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'rich-link' } }),
Mention.configure({
HTMLAttributes: { class: 'mention-tag' },
suggestion: {
...MentionSuggestion(personsRef, onPersonCreateRef),
allowedPrefixes: null,
},
}),
LinkTagExtension,
Placeholder.configure({ placeholder }),
Table.configure({ resizable: true }),
TableRow, TableCell, TableHeader,
],
content: initialContent.current,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
if (debounceTimer.current) clearTimeout(debounceTimer.current)
debounceTimer.current = setTimeout(() => {
const currentHtml = ed.getHTML()
const linkedHtml = autoMatchMentionsAndTags(currentHtml, personsRef.current || [], linkTagsRef.current || [])
if (linkedHtml !== currentHtml) {
ed.commands.setContent(linkedHtml, { emitUpdate: false })
onChangeRef.current(linkedHtml)
return
}
onChangeRef.current(currentHtml)
}, 300)
},
editorProps: {
attributes: { class: 'rich-editor-content' },
},
})
useImperativeHandle(ref, () => ({
getHTML: () => editor?.getHTML() || '',
getMarkdown: () => htmlToMarkdown(editor?.getHTML() || ''),
}))
useEffect(() => {
if (!editor) return
const html = autoMatchMentionsAndTags(markdownToHtml(content), personsRef.current || [], linkTagsRef.current || [])
if (html !== editor.getHTML()) {
editor.commands.setContent(html, { emitUpdate: false })
}
}, [content, editor, persons, linkTags])
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
if (onImageUpload) {
setImageUploading(true)
setUploadProgress(10)
const progressTimer = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 15, 90))
}, 300)
try {
const url = await onImageUpload(file)
clearInterval(progressTimer)
setUploadProgress(100)
if (url) editor.chain().focus().setImage({ src: url }).run()
} finally {
clearInterval(progressTimer)
setTimeout(() => { setImageUploading(false); setUploadProgress(0) }, 500)
}
} else {
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') editor.chain().focus().setImage({ src: reader.result }).run()
}
reader.readAsDataURL(file)
}
e.target.value = ''
}, [editor, onImageUpload])
const handleVideoUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file || !editor) return
if (onVideoUpload) {
setVideoUploading(true)
setUploadProgress(5)
const progressTimer = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 8, 90))
}, 500)
try {
const url = await onVideoUpload(file)
clearInterval(progressTimer)
setUploadProgress(100)
if (url) {
editor.chain().focus().insertContent(
`<p><video src="${url}" controls style="max-width:100%;border-radius:8px"></video></p>`
).run()
}
} finally {
clearInterval(progressTimer)
setTimeout(() => { setVideoUploading(false); setUploadProgress(0) }, 500)
}
}
e.target.value = ''
}, [editor, onVideoUpload])
const triggerMention = useCallback(() => {
if (!editor) return
editor.chain().focus().insertContent('@').run()
}, [editor])
const insertLinkTag = useCallback((tag: LinkTagItem) => {
if (!editor) return
// 通过自定义扩展节点插入,确保 data-* 属性不被 TipTap schema 丢弃
editor.chain().focus().insertContent({
type: 'linkTag',
attrs: {
label: tag.label,
url: tag.url || '',
tagType: tag.type || 'url',
tagId: tag.id || '',
pagePath: tag.pagePath || '',
appId: tag.appId || '',
mpKey: tag.type === 'miniprogram' ? (tag.appId || '') : '',
},
}).run()
}, [editor])
const addLink = useCallback(() => {
if (!editor || !linkUrl) return
editor.chain().focus().setLink({ href: linkUrl }).run()
setLinkUrl('')
setShowLinkInput(false)
}, [editor, linkUrl])
if (!editor) return null
return (
<div className={`rich-editor-wrapper ${className || ''}`}>
<div className="rich-editor-toolbar">
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleBold().run()} className={editor.isActive('bold') ? 'is-active' : ''} type="button"><Bold className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleItalic().run()} className={editor.isActive('italic') ? 'is-active' : ''} type="button"><Italic className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleStrike().run()} className={editor.isActive('strike') ? 'is-active' : ''} type="button"><Strikethrough className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleCode().run()} className={editor.isActive('code') ? 'is-active' : ''} type="button"><Code className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''} type="button"><Heading1 className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''} type="button"><Heading2 className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''} type="button"><Heading3 className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().toggleBulletList().run()} className={editor.isActive('bulletList') ? 'is-active' : ''} type="button"><List className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleOrderedList().run()} className={editor.isActive('orderedList') ? 'is-active' : ''} type="button"><ListOrdered className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().toggleBlockquote().run()} className={editor.isActive('blockquote') ? 'is-active' : ''} type="button"><Quote className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().setHorizontalRule().run()} type="button"><Minus className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleImageUpload} className="hidden" />
<button onClick={() => fileInputRef.current?.click()} type="button" title="插入图片"><ImageIcon className="w-4 h-4" /></button>
<input ref={videoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" onChange={handleVideoUpload} className="hidden" />
<button onClick={() => videoInputRef.current?.click()} disabled={videoUploading || !onVideoUpload} type="button" title="插入视频" className={videoUploading ? 'opacity-50' : ''}><Video className="w-4 h-4" /></button>
<button onClick={() => setShowLinkInput(!showLinkInput)} className={editor.isActive('link') ? 'is-active' : ''} type="button" title="插入链接"><LinkIcon className="w-4 h-4" /></button>
<button onClick={triggerMention} type="button" title="@ 指定人物" className="mention-trigger-btn"><AtSign className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} type="button"><TableIcon className="w-4 h-4" /></button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} type="button"><Undo className="w-4 h-4" /></button>
<button onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} type="button"><Redo className="w-4 h-4" /></button>
</div>
{linkTags.length > 0 && (
<>
<div className="toolbar-divider" />
<div className="toolbar-group">
<select
className="link-tag-select"
onChange={(e) => {
const tag = linkTags.find(t => t.id === e.target.value)
if (tag) insertLinkTag(tag)
e.target.value = ''
}}
defaultValue=""
>
<option value="" disabled># </option>
{linkTags.map(t => <option key={t.id} value={t.id}>{t.label}</option>)}
</select>
</div>
</>
)}
</div>
{showLinkInput && (
<div className="link-input-bar">
<input
type="url"
placeholder="输入链接地址..."
value={linkUrl}
onChange={e => setLinkUrl(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addLink()}
className="link-input"
/>
<button onClick={addLink} className="link-confirm" type="button"></button>
<button onClick={() => { editor.chain().focus().unsetLink().run(); setShowLinkInput(false) }} className="link-remove" type="button"></button>
</div>
)}
{(imageUploading || videoUploading) && (
<div className="upload-progress-bar">
<div className="upload-progress-track">
<div className="upload-progress-fill" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="upload-progress-text">{videoUploading ? '视频' : '图片'} {uploadProgress}%</span>
</div>
)}
<EditorContent editor={editor} />
</div>
)
})
RichEditor.displayName = 'RichEditor'
export default RichEditor

View File

@@ -0,0 +1,281 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
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 { Crown, Save, X } from 'lucide-react'
import { get, put } from '@/api/client'
interface SetVipModalProps {
open: boolean
onClose: () => void
userId: string | null
userNickname?: string
onSaved?: () => void
}
interface VipRole {
id: number
name: string
sort: number
}
interface VipForm {
isVip: boolean
vipExpireDate: string
vipSort: number | ''
vipRole: string
vipRoleCustom: string
vipName: string
vipProject: string
vipContact: string
vipBio: string
}
const DEFAULT_FORM: VipForm = {
isVip: false,
vipExpireDate: '',
vipSort: '',
vipRole: '',
vipRoleCustom: '',
vipName: '',
vipProject: '',
vipContact: '',
vipBio: '',
}
export function SetVipModal({
open,
onClose,
userId,
userNickname = '',
onSaved,
}: SetVipModalProps) {
const [form, setForm] = useState<VipForm>(DEFAULT_FORM)
const [roles, setRoles] = useState<VipRole[]>([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) {
setForm(DEFAULT_FORM)
return
}
let cancelled = false
setLoading(true)
Promise.all([
get<{ success?: boolean; data?: VipRole[]; roles?: VipRole[] }>('/api/db/vip-roles'),
userId ? get<{ success?: boolean; user?: Record<string, unknown> }>(`/api/db/users?id=${encodeURIComponent(userId)}`) : Promise.resolve(null),
]).then(([rolesRes, userRes]) => {
if (cancelled) return
const rolesList = rolesRes?.data || rolesRes?.roles || []
setRoles(rolesList as VipRole[])
const u = userRes?.user || null
if (u) {
const vipRole = String(u.vipRole ?? '')
const inRoles = rolesList.some((r: VipRole) => r.name === vipRole)
setForm({
isVip: !!(u.isVip ?? false),
vipExpireDate: u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '',
vipSort: typeof u.vipSort === 'number' ? u.vipSort : '',
vipRole: inRoles ? vipRole : (vipRole ? '__custom__' : ''),
vipRoleCustom: inRoles ? '' : vipRole,
vipName: String(u.vipName ?? ''),
vipProject: String(u.vipProject ?? ''),
vipContact: String(u.vipContact ?? ''),
vipBio: String(u.vipBio ?? ''),
})
} else {
setForm(DEFAULT_FORM)
}
}).catch((e) => {
if (!cancelled) console.error('Load error:', e)
}).finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [open, userId])
async function handleSave() {
if (!userId) return
if (form.isVip && !form.vipExpireDate.trim()) {
toast.error('开启 VIP 时请填写有效到期日')
return
}
if (form.isVip && form.vipExpireDate.trim()) {
const d = new Date(form.vipExpireDate)
if (isNaN(d.getTime())) {
toast.error('到期日格式无效,请使用 YYYY-MM-DD')
return
}
}
setSaving(true)
try {
const roleValue = form.vipRole === '__custom__' ? form.vipRoleCustom.trim() : form.vipRole
const payload: Record<string, unknown> = {
id: userId,
isVip: form.isVip,
vipExpireDate: form.isVip ? form.vipExpireDate : undefined,
vipSort: form.vipSort === '' ? undefined : form.vipSort,
vipRole: roleValue || undefined,
vipName: form.vipName || undefined,
vipProject: form.vipProject || undefined,
vipContact: form.vipContact || undefined,
vipBio: form.vipBio || undefined,
}
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
if (data?.success) {
toast.success('VIP 设置已保存')
onSaved?.()
onClose()
} else {
toast.error('保存失败: ' + (data as { error?: string })?.error)
}
} catch (e) {
console.error('Save VIP error:', e)
toast.error('保存失败')
} finally {
setSaving(false)
}
}
if (!open) return null
return (
<Dialog open={open} onOpenChange={() => onClose()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Crown className="w-5 h-5 text-amber-400" />
VIP - {userNickname || userId}
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="py-8 text-center text-gray-400">...</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label className="text-gray-300">VIP </Label>
<Switch
checked={form.isVip}
onCheckedChange={(checked) => setForm((f) => ({ ...f, isVip: checked }))}
/>
</div>
{form.isVip && (
<>
<div className="space-y-2">
<Label className="text-gray-300">
(YYYY-MM-DD) <span className="text-amber-400">*</span>
</Label>
<Input
type="date"
className="bg-[#0a1628] border-gray-700 text-white"
value={form.vipExpireDate}
onChange={(e) => setForm((f) => ({ ...f, vipExpireDate: 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"
placeholder="数字越小越靠前,留空按时间"
value={form.vipSort === '' ? '' : form.vipSort}
onChange={(e) => {
const v = e.target.value
setForm((f) => ({ ...f, vipSort: v === '' ? '' : parseInt(v, 10) || 0 }))
}}
/>
</div>
</>
)}
<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 px-3 py-2"
value={form.vipRole}
onChange={(e) => setForm((f) => ({ ...f, vipRole: e.target.value }))}
>
<option value=""></option>
{roles.map((r) => (
<option key={r.id} value={r.name}>{r.name}</option>
))}
<option value="__custom__"></option>
</select>
{form.vipRole === '__custom__' && (
<Input
className="bg-[#0a1628] border-gray-700 text-white mt-1"
placeholder="输入自定义角色"
value={form.vipRoleCustom}
onChange={(e) => setForm((f) => ({ ...f, vipRoleCustom: e.target.value }))}
/>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300">VIP </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="创业老板排行展示名"
value={form.vipName}
onChange={(e) => setForm((f) => ({ ...f, vipName: 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={form.vipProject}
onChange={(e) => setForm((f) => ({ ...f, vipProject: 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={form.vipContact}
onChange={(e) => setForm((f) => ({ ...f, vipContact: 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={form.vipBio}
onChange={(e) => setForm((f) => ({ ...f, vipBio: e.target.value }))}
/>
</div>
</div>
)}
<DialogFooter>
<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 || loading}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
interface PaginationProps {
page: number
totalPages: number
total: number
pageSize: number
onPageChange: (page: number) => void
onPageSizeChange?: (pageSize: number) => void
pageSizeOptions?: number[]
}
export function Pagination({
page,
totalPages,
total,
pageSize,
onPageChange,
onPageSizeChange,
pageSizeOptions = [10, 20, 50, 100],
}: PaginationProps) {
if (totalPages <= 1 && !onPageSizeChange) return null
return (
<div className="flex items-center justify-between gap-4 py-4 px-5 border-t border-gray-700/50">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span> {total} </span>
{onPageSizeChange && (
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="bg-[#0f2137] border border-gray-600 rounded px-2 py-1 text-gray-300 text-sm"
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{n} /
</option>
))}
</select>
)}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onPageChange(1)}
disabled={page <= 1}
className="px-2 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
>
</button>
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="px-3 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
>
</button>
<span className="px-3 py-1 text-gray-400 text-sm">
{page} / {totalPages}
</span>
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="px-3 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
>
</button>
<button
type="button"
onClick={() => onPageChange(totalPages)}
disabled={page >= totalPages}
className="px-2 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
>
</button>
</div>
)}
</div>
)
}

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,85 @@
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} />
}
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/50', className)}
{...props}
/>
))
DialogOverlay.displayName = 'DialogOverlay'
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
aria-describedby={undefined}
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',
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>
))
DialogContent.displayName = 'DialogContent'
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-gray-600 relative grow overflow-hidden rounded-full h-1.5 w-full">
<SliderPrimitive.Range className="bg-[#38bdac] absolute h-full rounded-full" />
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
key={index}
className="block size-4 shrink-0 rounded-full border-2 border-[#38bdac] bg-white shadow-sm focus-visible:ring-2 focus-visible:ring-[#38bdac] 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-[#38bdac] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a1628] disabled:cursor-not-allowed disabled:opacity-50 data-[state=unchecked]:bg-gray-600 data-[state=checked]:bg-[#38bdac]',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-white 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 }

View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
/**
* 防抖 hook用于搜索等输入场景
* @param value 原始值
* @param delay 延迟毫秒
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}

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,145 @@
import { useState, useEffect } from 'react'
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
import {
LayoutDashboard,
Users,
Settings,
LogOut,
Wallet,
BookOpen,
GitMerge,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { clearAdminToken, getAdminToken } from '@/api/auth'
// 主菜单5 项平铺,按 Mycontent-temp 新规范)
const primaryMenuItems = [
{ icon: LayoutDashboard, label: '数据概览', href: '/dashboard' },
{ icon: BookOpen, label: '内容管理', href: '/content' },
{ icon: Users, label: '用户管理', href: '/users' },
{ icon: GitMerge, label: '找伙伴', href: '/find-partner' },
{ icon: Wallet, label: '推广中心', href: '/distribution' },
]
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
const token = getAdminToken()
if (!token) {
navigate('/login', { replace: true, state: { from: location.pathname } })
return () => {
cancelled = true
}
}
get<{ success?: boolean }>('/api/admin')
.then((data) => {
if (cancelled) return
if (data?.success === true) {
setAuthChecked(true)
} else {
clearAdminToken()
navigate('/login', { replace: true })
}
})
.catch(() => {
if (!cancelled) {
clearAdminToken()
navigate('/login', { replace: true })
}
})
return () => {
cancelled = true
}
}, [location.pathname, mounted, navigate])
const handleLogout = async () => {
clearAdminToken()
try {
await post('/api/admin/logout', {})
} catch {
// 忽略登出接口失败,本地已清 token
}
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 overflow-y-auto">
{primaryMenuItems.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 shrink-0" />
<span className="text-sm">{item.label}</span>
</Link>
)
})}
</nav>
<div className="p-4 border-t border-gray-700/50 space-y-1">
<Link
to="/settings"
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
location.pathname === '/settings'
? 'bg-[#38bdac]/20 text-[#38bdac] font-medium'
: 'text-gray-400 hover:bg-gray-700/50 hover:text-white'
}`}
>
<Settings className="w-5 h-5 shrink-0" />
<span className="text-sm"></span>
</Link>
<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>
</div>
</div>
<div className="flex-1 overflow-auto bg-[#0a1628] min-w-0">
<div className="w-full min-w-[1024px] min-h-full">
<Outlet />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* 修复图片 URL 中 protocol 缺少冒号的问题(如 `https//` → `https://`)。
* 同时处理 OSS 签名 URL 中 objectKey 的 %2F 编码,保证浏览器能正确解析。
*/
export function normalizeImageUrl(url: string | null | undefined): string {
if (!url) return ''
let s = url.trim()
if (!s) return ''
// 修复 "https//..." 或 "http//..." → "https://..." / "http://..."
s = s.replace(/^(https?)\/\//, '$1://')
return s
}

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 future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
</BrowserRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,413 @@
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 { ShieldCheck, Plus, Edit3, Trash2, X, Save, RefreshCw } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
import { Pagination } from '@/components/ui/Pagination'
import { useDebounce } from '@/hooks/useDebounce'
interface AdminUser {
id: number
username: string
role: string
name: string
status: string
createdAt: string
updatedAt?: string
}
interface ListRes {
success?: boolean
records?: AdminUser[]
total?: number
page?: number
pageSize?: number
totalPages?: number
error?: string
}
export function AdminUsersPage() {
const [records, setRecords] = useState<AdminUser[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [totalPages, setTotalPages] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearch = useDebounce(searchTerm, 300)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
const [formUsername, setFormUsername] = useState('')
const [formPassword, setFormPassword] = useState('')
const [formName, setFormName] = useState('')
const [formRole, setFormRole] = useState<'super_admin' | 'admin'>('admin')
const [formStatus, setFormStatus] = useState<'active' | 'disabled'>('active')
const [saving, setSaving] = useState(false)
async function loadList() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
})
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim())
const data = await get<ListRes>(`/api/admin/admin-users?${params}`)
if (data?.success) {
setRecords((data as ListRes).records || [])
setTotal((data as ListRes).total ?? 0)
setTotalPages((data as ListRes).totalPages ?? 0)
} else {
setError((data as ListRes).error || '加载失败')
}
} catch (e: unknown) {
const err = e as { status?: number; data?: { error?: string } }
setError(err.status === 403 ? '无权限访问' : err?.data?.error || '加载失败')
setRecords([])
} finally {
setLoading(false)
}
}
useEffect(() => {
loadList()
}, [page, pageSize, debouncedSearch])
const handleAdd = () => {
setEditingUser(null)
setFormUsername('')
setFormPassword('')
setFormName('')
setFormRole('admin')
setFormStatus('active')
setShowModal(true)
}
const handleEdit = (u: AdminUser) => {
setEditingUser(u)
setFormUsername(u.username)
setFormPassword('')
setFormName(u.name || '')
setFormRole((u.role === 'super_admin' ? 'super_admin' : 'admin') as 'super_admin' | 'admin')
setFormStatus((u.status === 'disabled' ? 'disabled' : 'active') as 'active' | 'disabled')
setShowModal(true)
}
const handleSave = async () => {
if (!formUsername.trim()) {
setError('用户名不能为空')
return
}
if (!editingUser && !formPassword) {
setError('新建时密码必填,至少 6 位')
return
}
if (formPassword && formPassword.length < 6) {
setError('密码至少 6 位')
return
}
setError(null)
setSaving(true)
try {
if (editingUser) {
const data = await put<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
id: editingUser.id,
password: formPassword || undefined,
name: formName.trim(),
role: formRole,
status: formStatus,
})
if (data?.success) {
setShowModal(false)
loadList()
} else {
setError(data?.error || '保存失败')
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
username: formUsername.trim(),
password: formPassword,
name: formName.trim(),
role: formRole,
})
if (data?.success) {
setShowModal(false)
loadList()
} else {
setError(data?.error || '保存失败')
}
}
} catch (e: unknown) {
const err = e as { data?: { error?: string } }
setError(err?.data?.error || '保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该管理员?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/admin-users?id=${id}`)
if (data?.success) loadList()
else setError(data?.error || '删除失败')
} catch (e: unknown) {
const err = e as { data?: { error?: string } }
setError(err?.data?.error || '删除失败')
}
}
const formatDate = (s: string) => {
if (!s) return '-'
try {
const d = new Date(s)
return isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN')
} catch {
return s
}
}
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1"></p>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="搜索用户名/昵称"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-48 bg-[#0f2137] border-gray-700 text-white placeholder:text-gray-500"
/>
<Button
variant="outline"
size="sm"
onClick={loadList}
disabled={loading}
className="border-gray-600 text-gray-300"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm flex justify-between items-center">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
×
</button>
</div>
)}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<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>
{records.map((u) => (
<TableRow key={u.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{u.id}</TableCell>
<TableCell className="text-white font-medium">{u.username}</TableCell>
<TableCell className="text-gray-400">{u.name || '-'}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
u.role === 'super_admin'
? 'border-amber-500/50 text-amber-400'
: 'border-gray-600 text-gray-400'
}
>
{u.role === 'super_admin' ? '超级管理员' : '管理员'}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={
u.status === 'active'
? 'border-[#38bdac]/50 text-[#38bdac]'
: 'border-gray-500 text-gray-500'
}
>
{u.status === 'active' ? '正常' : '已禁用'}
</Badge>
</TableCell>
<TableCell className="text-gray-500 text-sm">{formatDate(u.createdAt)}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(u)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(u.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{records.length === 0 && !loading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500">
{error === '无权限访问' ? '仅超级管理员可查看' : '暂无管理员'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="p-4 border-t border-gray-700/50">
<Pagination
page={page}
pageSize={pageSize}
total={total}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
)}
</>
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
<DialogHeader>
<DialogTitle className="text-white">
{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={formUsername}
onChange={(e) => setFormUsername(e.target.value)}
disabled={!!editingUser}
/>
{editingUser && (
<p className="text-xs text-gray-500"></p>
)}
</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 ? '留空表示不修改' : '至少 6 位'}
value={formPassword}
onChange={(e) => setFormPassword(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={formName}
onChange={(e) => setFormName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<select
value={formRole}
onChange={(e) => setFormRole(e.target.value as 'super_admin' | 'admin')}
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
>
<option value="admin"></option>
<option value="super_admin"></option>
</select>
</div>
{editingUser && (
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<select
value={formStatus}
onChange={(e) => setFormStatus(e.target.value as 'active' | 'disabled')}
className="w-full h-10 px-3 rounded-md bg-[#0a1628] border border-gray-700 text-white"
>
<option value="active"></option>
<option value="disabled"></option>
</select>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,146 @@
/**
* API 接口文档页 - 解决 /api-doc 404
* 内容与 开发文档/5、接口/API接口完整文档.md 保持一致
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Link2 } from 'lucide-react'
export function ApiDocPage() {
return (
<div className="p-8 w-full">
<div className="flex items-center gap-2 mb-8">
<Link2 className="w-8 h-8 text-[#38bdac]" />
<h1 className="text-2xl font-bold text-white">API </h1>
</div>
<p className="text-gray-400 mb-6">
API RESTful · v1.0 · /api ·
</p>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">1. </CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<p className="text-gray-400 mb-2"></p>
<ul className="space-y-1 text-gray-300 font-mono">
<li>/api/book </li>
<li>/api/miniprogram/upload /</li>
<li>/api/admin/content/upload </li>
<li>/api/payment </li>
<li>/api/referral </li>
<li>/api/user </li>
<li>/api/match </li>
<li>/api/admin ///</li>
<li>/api/config </li>
</ul>
</div>
<div>
<p className="text-gray-400 mb-2"></p>
<p className="text-gray-300">Cookie session_id</p>
<p className="text-gray-300">Authorization: Bearer admin-token-secret</p>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>GET /api/book/all-chapters </p>
<p>GET /api/book/chapter/:id </p>
<p>POST /api/book/sync </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2.1 </CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<div>
<p className="text-gray-400 mb-1">POST /api/miniprogram/upload/image </p>
<p className="text-gray-500 text-xs mb-1">filefolder imagesquality 1-100 85</p>
<p className="text-gray-500 text-xs"> jpeg/png/gif 5MBJPEG quality </p>
<pre className="mt-2 p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`响应示例: { "success": true, "url": "/uploads/images/xxx.jpg", "data": { "url", "fileName", "size", "type", "quality" } }`}
</pre>
</div>
<div>
<p className="text-gray-400 mb-1">POST /api/miniprogram/upload/video </p>
<p className="text-gray-500 text-xs mb-1">filefolder videos</p>
<p className="text-gray-500 text-xs"> mp4/mov/avi 100MB</p>
<pre className="mt-2 p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`响应示例: { "success": true, "url": "/uploads/videos/xxx.mp4", "data": { "url", "fileName", "size", "type", "folder" } }`}
</pre>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">2.2 </CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="text-gray-400">POST /api/admin/content/upload AdminAuth</p>
<p className="text-gray-500 text-xs"> API </p>
<pre className="p-2 rounded bg-black/40 text-green-400 text-xs overflow-x-auto">
{`请求体: {
"action": "import",
"data": [{
"id": "ch-001",
"title": "章节标题",
"content": "正文内容",
"price": 1.0,
"isFree": false,
"partId": "part-1",
"partTitle": "第一篇",
"chapterId": "chapter-1",
"chapterTitle": "第1章"
}]
}`}
</pre>
<p className="text-gray-500 text-xs">{`{ "success": true, "message": "导入完成", "imported": N, "failed": M }`}</p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">3. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>POST /api/payment/create-order </p>
<p>POST /api/payment/alipay/notify </p>
<p>POST /api/payment/wechat/notify </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">4. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>/api/referral/* </p>
<p>/api/user/* </p>
<p>/api/match/* </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mb-6">
<CardHeader>
<CardTitle className="text-white">5. </CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-gray-300 font-mono">
<p>GET/POST /api/admin/referral-settings 广/ VIP </p>
<p>GET /api/db/users/api/db/book </p>
<p>GET /api/admin/orders </p>
</CardContent>
</Card>
<p className="text-gray-500 text-xs">
/5/API接口完整文档.md
</p>
</div>
)
}

View File

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

View File

@@ -0,0 +1,362 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect, useRef } 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 { Textarea } from '@/components/ui/textarea'
import { Save, User, Image, Plus, X, Upload } from 'lucide-react'
import { get, post, apiUrl } from '@/api/client'
import { getAdminToken } from '@/api/auth'
interface StatItem {
label: string
value: string
}
interface AuthorConfig {
name: string
avatar: string
avatarImg: string
title: string
bio: string
stats: StatItem[]
highlights: string[]
}
const DEFAULT: AuthorConfig = {
name: '卡若',
avatar: 'K',
avatarImg: '',
title: 'Soul派对房主理人 · 私域运营专家',
bio: '每天早上6点到9点在Soul派对房分享真实的创业故事。专注私域运营与项目变现用"云阿米巴"模式帮助创业者构建可持续的商业体系。',
stats: [
{ label: '商业案例', value: '62' },
{ label: '连续直播', value: '365天' },
{ label: '派对分享', value: '1000+' },
],
highlights: [
'5年私域运营经验',
'帮助100+品牌从0到1增长',
'连续创业者,擅长商业模式设计',
],
}
function parseStats(v: unknown): StatItem[] {
if (!Array.isArray(v)) return DEFAULT.stats
return v.map((x) => {
if (x && typeof x === 'object' && 'label' in x && 'value' in x) {
return { label: String(x.label), value: String(x.value) }
}
return { label: '', value: '' }
}).filter((s) => s.label || s.value)
}
function parseHighlights(v: unknown): string[] {
if (!Array.isArray(v)) return DEFAULT.highlights
return v.map((x) => (typeof x === 'string' ? x : String(x ?? ''))).filter(Boolean)
}
export function AuthorSettingsPage() {
const [config, setConfig] = useState<AuthorConfig>(DEFAULT)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
get<{ success?: boolean; data?: Record<string, unknown> }>('/api/admin/author-settings')
.then((res) => {
const d = (res as { data?: Record<string, unknown> })?.data
if (d && typeof d === 'object') {
setConfig({
name: String(d.name ?? DEFAULT.name),
avatar: String(d.avatar ?? DEFAULT.avatar),
avatarImg: String(d.avatarImg ?? ''),
title: String(d.title ?? DEFAULT.title),
bio: String(d.bio ?? DEFAULT.bio),
stats: parseStats(d.stats).length ? parseStats(d.stats) : DEFAULT.stats,
highlights: parseHighlights(d.highlights).length ? parseHighlights(d.highlights) : DEFAULT.highlights,
})
}
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const handleSave = async () => {
setSaving(true)
try {
const body = {
name: config.name,
avatar: config.avatar || 'K',
avatarImg: config.avatarImg,
title: config.title,
bio: config.bio,
stats: config.stats.filter((s) => s.label || s.value),
highlights: config.highlights.filter(Boolean),
}
const res = await post<{ success?: boolean; error?: string }>('/api/admin/author-settings', body)
if (!res || (res as { success?: boolean }).success === false) {
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
return
}
setSaving(false)
// 轻量反馈,不阻塞
const msg = document.createElement('div')
msg.className = 'fixed top-4 right-4 z-50 px-4 py-2 rounded-lg bg-[#38bdac] text-white text-sm shadow-lg'
msg.textContent = '作者设置已保存'
document.body.appendChild(msg)
setTimeout(() => msg.remove(), 2000)
} catch (e) {
console.error(e)
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingAvatar(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'avatars')
const token = getAdminToken()
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(apiUrl('/api/upload'), {
method: 'POST',
body: formData,
credentials: 'include',
headers,
})
const data = await res.json()
if (data?.success && data?.url) {
setConfig((prev) => ({ ...prev, avatarImg: data.url }))
} else {
toast.error('上传失败: ' + (data?.error || '未知错误'))
}
} catch (err) {
console.error(err)
toast.error('上传失败')
} finally {
setUploadingAvatar(false)
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
}
const addStat = () => setConfig((prev) => ({ ...prev, stats: [...prev.stats, { label: '', value: '' }] }))
const removeStat = (i: number) =>
setConfig((prev) => ({ ...prev, stats: prev.stats.filter((_, idx) => idx !== i) }))
const updateStat = (i: number, field: 'label' | 'value', val: string) =>
setConfig((prev) => ({
...prev,
stats: prev.stats.map((s, idx) => (idx === i ? { ...s, [field]: val } : s)),
}))
const addHighlight = () => setConfig((prev) => ({ ...prev, highlights: [...prev.highlights, ''] }))
const removeHighlight = (i: number) =>
setConfig((prev) => ({ ...prev, highlights: prev.highlights.filter((_, idx) => idx !== i) }))
const updateHighlight = (i: number, val: string) =>
setConfig((prev) => ({
...prev,
highlights: prev.highlights.map((h, idx) => (idx === i ? val : h)),
}))
if (loading) return <div className="p-8 text-gray-500">...</div>
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<User className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
</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">
<User className="w-4 h-4 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
className="bg-[#0a1628] border-gray-700 text-white"
value={config.name}
onChange={(e) => setConfig((prev) => ({ ...prev, name: e.target.value }))}
placeholder="卡若"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white w-20"
value={config.avatar}
onChange={(e) => setConfig((prev) => ({ ...prev, avatar: e.target.value.slice(0, 1) || 'K' }))}
placeholder="K"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300 flex items-center gap-2">
<Image className="w-3 h-3 text-[#38bdac]" />
</Label>
<div className="flex gap-3 items-center">
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={config.avatarImg}
onChange={(e) => setConfig((prev) => ({ ...prev, avatarImg: e.target.value }))}
placeholder="上传或粘贴 URL如 /uploads/avatars/xxx.png"
/>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-400 shrink-0"
disabled={uploadingAvatar}
onClick={() => avatarInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
{uploadingAvatar ? '上传中...' : '上传'}
</Button>
</div>
{config.avatarImg && (
<div className="mt-2">
<img
src={normalizeImageUrl(config.avatarImg.startsWith('http') ? config.avatarImg : apiUrl(config.avatarImg))}
alt="头像预览"
className="w-20 h-20 rounded-full object-cover border border-gray-600"
/>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
value={config.title}
onChange={(e) => setConfig((prev) => ({ ...prev, title: e.target.value }))}
placeholder="Soul派对房主理人 · 私域运营专家"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[120px]"
value={config.bio}
onChange={(e) => setConfig((prev) => ({ ...prev, bio: e.target.value }))}
placeholder="每天早上6点到9点..."
/>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white"></CardTitle>
<CardDescription className="text-gray-400">
62 365
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{config.stats.map((s, i) => (
<div key={i} className="flex gap-3 items-center">
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={s.label}
onChange={(e) => updateStat(i, 'label', e.target.value)}
placeholder="标签"
/>
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={s.value}
onChange={(e) => updateStat(i, 'value', e.target.value)}
placeholder="数值"
/>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-400"
onClick={() => removeStat(i)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addStat} className="border-gray-600 text-gray-400">
<Plus className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white"></CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{config.highlights.map((h, i) => (
<div key={i} className="flex gap-3 items-center">
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={h}
onChange={(e) => updateHighlight(i, e.target.value)}
placeholder="5年私域运营经验"
/>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-400"
onClick={() => removeHighlight(i)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addHighlight} className="border-gray-600 text-gray-400">
<Plus className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,338 @@
import toast from '@/utils/toast'
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 [error, setError] = useState<string | null>(null)
const [expandedParts, setExpandedParts] = useState<string[]>([])
const [editingSection, setEditingSection] = useState<string | null>(null)
const [editPrice, setEditPrice] = useState<number>(1)
async function loadChapters() {
setLoading(true)
setError(null)
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)
} else {
setError('加载章节失败')
}
} catch (e) {
console.error('加载章节失败:', e)
setError('加载失败,请检查网络后重试')
} 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) {
toast.success('价格更新成功')
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) {
toast.success('状态更新成功')
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="w-full min-w-[1024px] 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={loadChapters}
disabled={loading}
className="px-4 py-2 bg-white/10 rounded-lg hover:bg-white/20 text-white disabled:opacity-50"
>
</button>
<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="w-full min-w-[1024px] px-4 py-8">
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
×
</button>
</div>
)}
{/* 统计卡片 */}
{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>
)
}

View File

@@ -0,0 +1,924 @@
/**
* 章节树 - 仿照 catalog 设计,支持篇、章、节拖拽排序
* 整行可拖拽;节和章可跨篇
*/
import { useCallback, useState } from 'react'
import { ChevronRight, ChevronDown, BookOpen, Edit3, Trash2, GripVertical, Plus, Star } from 'lucide-react'
import { Button } from '@/components/ui/button'
const PART_LABELS = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']
export interface SectionItem {
id: string
title: string
price: number
isFree?: boolean
isNew?: boolean
clickCount?: number
payCount?: number
hotScore?: number
hotRank?: number
}
export interface ChapterItem {
id: string
title: string
sections: SectionItem[]
}
export interface PartItem {
id: string
title: string
chapters: ChapterItem[]
}
type DragType = 'part' | 'chapter' | 'section'
function parseDragData(data: string): { type: DragType; id: string } | null {
if (data.startsWith('part:')) return { type: 'part', id: data.slice(5) }
if (data.startsWith('chapter:')) return { type: 'chapter', id: data.slice(8) }
if (data.startsWith('section:')) return { type: 'section', id: data.slice(8) }
return null
}
interface ChapterTreeProps {
parts: PartItem[]
expandedParts: string[]
onTogglePart: (partId: string) => void
onReorder: (items: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[]) => Promise<void>
onReadSection: (s: SectionItem) => void
onDeleteSection: (s: SectionItem) => void
onAddSectionInPart?: (part: PartItem) => void
onAddChapterInPart?: (part: PartItem) => void
onDeleteChapter?: (part: PartItem, chapter: ChapterItem) => void
onEditPart?: (part: PartItem) => void
onDeletePart?: (part: PartItem) => void
onEditChapter?: (part: PartItem, chapter: ChapterItem) => void
/** 批量移动:勾选章节 */
selectedSectionIds?: string[]
onToggleSectionSelect?: (sectionId: string) => void
/** 查看某节的付款记录 */
onShowSectionOrders?: (s: SectionItem) => void
/** 置顶章节ID列表 */
pinnedSectionIds?: string[]
}
export function ChapterTree({
parts,
expandedParts,
onTogglePart,
onReorder,
onReadSection,
onDeleteSection,
onAddSectionInPart,
onAddChapterInPart,
onDeleteChapter,
onEditPart,
onDeletePart,
onEditChapter,
selectedSectionIds = [],
onToggleSectionSelect,
onShowSectionOrders,
pinnedSectionIds = [],
}: ChapterTreeProps) {
const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null)
const isDragging = (type: DragType, id: string) => draggingItem?.type === type && draggingItem?.id === id
const isDragOver = (type: DragType, id: string) => dragOverTarget?.type === type && dragOverTarget?.id === id
const buildSectionsList = useCallback(
(): { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] => {
const list: { id: string; partId: string; partTitle: string; chapterId: string; chapterTitle: string }[] = []
for (const part of parts) {
for (const ch of part.chapters) {
for (const s of ch.sections) {
list.push({
id: s.id,
partId: part.id,
partTitle: part.title,
chapterId: ch.id,
chapterTitle: ch.title,
})
}
}
}
return list
},
[parts],
)
const handleDrop = useCallback(
async (e: React.DragEvent, toType: DragType, toId: string, toContext?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => {
e.preventDefault()
e.stopPropagation()
const data = e.dataTransfer.getData('text/plain')
const from = parseDragData(data)
if (!from) return
if (from.type === toType && from.id === toId) return
const sections = buildSectionsList()
const sectionMap = new Map(sections.map((x) => [x.id, x]))
// 所有篇/章/节均可拖拽与作为落点(与后台顺序一致)
if (from.type === 'part' && toType === 'part') {
const partOrder = parts.map((p) => p.id)
const fromIdx = partOrder.indexOf(from.id)
const toIdx = partOrder.indexOf(toId)
if (fromIdx === -1 || toIdx === -1) return
const next = [...partOrder]
next.splice(fromIdx, 1)
next.splice(fromIdx < toIdx ? toIdx - 1 : toIdx, 0, from.id)
const newList: typeof sections = []
for (const pid of next) {
const p = parts.find((x) => x.id === pid)
if (!p) continue
for (const ch of p.chapters) {
for (const s of ch.sections) {
const ctx = sectionMap.get(s.id)
if (ctx) newList.push(ctx)
}
}
}
await onReorder(newList)
return
}
if (from.type === 'chapter' && (toType === 'chapter' || toType === 'section' || toType === 'part')) {
const srcPart = parts.find((p) => p.chapters.some((c) => c.id === from.id))
const srcCh = srcPart?.chapters.find((c) => c.id === from.id)
if (!srcPart || !srcCh) return
let targetPartId: string
let targetPartTitle: string
let insertAfterId: string | null = null
if (toType === 'section') {
const ctx = sectionMap.get(toId)
if (!ctx) return
targetPartId = ctx.partId
targetPartTitle = ctx.partTitle
insertAfterId = toId
} else if (toType === 'chapter') {
const part = parts.find((p) => p.chapters.some((c) => c.id === toId))
const ch = part?.chapters.find((c) => c.id === toId)
if (!part || !ch) return
targetPartId = part.id
targetPartTitle = part.title
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
insertAfterId = lastInCh?.id ?? null
} else {
const part = parts.find((p) => p.id === toId)
if (!part || !part.chapters[0]) return
targetPartId = part.id
targetPartTitle = part.title
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
insertAfterId = firstChSections[firstChSections.length - 1]?.id ?? null
}
const movingIds = srcCh.sections.map((s) => s.id)
const rest = sections.filter((s) => !movingIds.includes(s.id))
let targetIdx = rest.length
if (insertAfterId) {
const idx = rest.findIndex((s) => s.id === insertAfterId)
if (idx >= 0) targetIdx = idx + 1
}
const moving = movingIds.map((id) => {
const s = sectionMap.get(id)!
return {
...s,
partId: targetPartId,
partTitle: targetPartTitle,
chapterId: srcCh.id,
chapterTitle: srcCh.title,
}
})
await onReorder([...rest.slice(0, targetIdx), ...moving, ...rest.slice(targetIdx)])
return
}
if (from.type === 'section' && (toType === 'section' || toType === 'chapter' || toType === 'part')) {
if (!toContext) return
const { partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle } = toContext
let toIdx: number
if (toType === 'section') {
toIdx = sections.findIndex((s) => s.id === toId)
} else if (toType === 'chapter') {
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
toIdx = lastInCh ? sections.findIndex((s) => s.id === lastInCh.id) + 1 : sections.length
} else {
const part = parts.find((p) => p.id === toId)
if (!part?.chapters[0]) return
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
const last = firstChSections[firstChSections.length - 1]
toIdx = last ? sections.findIndex((s) => s.id === last.id) + 1 : 0
}
const fromIdx = sections.findIndex((s) => s.id === from.id)
if (fromIdx === -1) return
const next = sections.filter((s) => s.id !== from.id)
const insertIdx = fromIdx < toIdx ? toIdx - 1 : toIdx
const moved = sections[fromIdx]
const newItem = { ...moved, partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle }
next.splice(insertIdx, 0, newItem)
await onReorder(next)
}
},
[parts, buildSectionsList, onReorder],
)
const droppableHandlers = (type: DragType, id: string, ctx?: { partId: string; partTitle: string; chapterId: string; chapterTitle: string }) => ({
onDragEnter: (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type, id })
},
onDragOver: (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type, id })
},
onDragLeave: () => setDragOverTarget(null),
onDrop: (e: React.DragEvent) => {
setDragOverTarget(null)
const from = parseDragData(e.dataTransfer.getData('text/plain'))
if (!from) return
if (type === 'section' && from.type === 'section' && from.id === id) return
if (type === 'part') {
if (from.type === 'part') handleDrop(e, 'part', id)
else {
const part = parts.find((p) => p.id === id)
const firstCh = part?.chapters[0]
if (firstCh && ctx) handleDrop(e, 'part', id, ctx)
}
} else if (type === 'chapter' && ctx) {
if (from.type === 'section' || from.type === 'chapter') handleDrop(e, 'chapter', id, ctx)
} else if (type === 'section' && ctx) {
handleDrop(e, 'section', id, ctx)
}
},
})
const partLabel = (i: number) => PART_LABELS[i] ?? String(i + 1)
return (
<div className="space-y-3">
{parts.map((part, partIndex) => {
const isXuYan = part.title === '序言' || part.title.includes('序言')
const isWeiSheng = part.title === '尾声' || part.title.includes('尾声')
const isFuLu = part.title === '附录' || part.title.includes('附录')
const partDragOver = isDragOver('part', part.id)
const isExpanded = expandedParts.includes(part.id)
const chapterCount = part.chapters.length
const sectionCount = part.chapters.reduce((s, ch) => s + ch.sections.length, 0)
// 序言:单行卡片,带六点拖拽、可拖可放
if (isXuYan && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
const sec = part.chapters[0].sections[0]
const secDragOver = isDragOver('section', sec.id)
const ctx = { partId: part.id, partTitle: part.title, chapterId: part.chapters[0].id, chapterTitle: part.chapters[0].title }
return (
<div
key={part.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors cursor-grab active:cursor-grabbing select-none min-h-[40px] ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, ctx)}
>
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
<BookOpen className="w-4 h-4 text-gray-400" />
</div>
<span className="font-medium text-gray-200 truncate">
{part.chapters[0].title} | {sec.title}
</span>
{pinnedSectionIds.includes(sec.id) && <span title="已置顶"><Star className="w-3.5 h-3.5 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{sec.price === 0 || sec.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
)
}
// 2026每日派对干货独立篇章带六点拖拽、可拖可放
const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货')
if (is2026Daily) {
const partDragOver = isDragOver('part', part.id)
return (
<div
key={part.id}
className={`rounded-xl border overflow-hidden transition-all duration-200 ${partDragOver ? 'border-[#38bdac] ring-2 ring-[#38bdac]/40 bg-[#38bdac]/5' : 'border-gray-700/50 bg-[#1C1C1E]'}`}
{...droppableHandlers('part', part.id, {
partId: part.id,
partTitle: part.title,
chapterId: part.chapters[0]?.id ?? '',
chapterTitle: part.chapters[0]?.title ?? '',
})}
>
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'part:' + part.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'part', id: part.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between p-4 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${isDragging('part', part.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : 'hover:bg-[#162840]/50'}`}
onClick={() => onTogglePart(part.id)}
>
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac]/80 flex items-center justify-center text-white font-bold shrink-0">
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
</div>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{onAddSectionInPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onAddSectionInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="在本篇下新增章节">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onEditPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onEditPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑篇名">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onDeletePart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeletePart(part)} className="text-gray-500 hover:text-red-400 h-7 px-2" title="删除本篇">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
</div>
</div>
{isExpanded && part.chapters.length > 0 && (
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter) => (
<div key={chapter.id} className="space-y-2">
<div className="flex items-center gap-2 w-full">
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{onEditChapter && (
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onAddChapterInPart && (
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onDeleteChapter && (
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
<div className="space-y-1 pl-2">
{chapter.sections.map((section) => {
const secDragOver = isDragOver('section', section.id)
return (
<div
key={section.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + section.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: section.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
onClick={() => onReadSection(section)}
className={`flex items-center justify-between py-2 px-3 rounded-lg min-h-[40px] cursor-pointer select-none transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', section.id, {
partId: part.id,
partTitle: part.title,
chapterId: chapter.id,
chapterTitle: chapter.title,
})}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(section.id)}
onChange={() => onToggleSectionSelect(section.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<span className="text-sm text-gray-200 truncate">{section.id} {section.title}</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
<span className="text-[10px] text-gray-500"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</div>
)
}
// 附录:平铺章节列表,每节带六点拖拽、可拖可放
if (isFuLu) {
return (
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
<h3 className="text-sm font-medium text-gray-400 mb-4"></h3>
<div className="space-y-3">
{part.chapters.map((ch, chIdx) =>
ch.sections.length > 0
? ch.sections.map((sec) => {
const secDragOver = isDragOver('section', sec.id)
return (
<div
key={sec.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex justify-between items-center py-2 select-none rounded px-2 -mx-2 group cursor-grab active:cursor-grabbing min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, {
partId: part.id,
partTitle: part.title,
chapterId: ch.id,
chapterTitle: ch.title,
})}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<span className="text-sm text-gray-300 truncate">{chIdx + 1} | {ch.title} | {sec.title}</span>
{pinnedSectionIds.includes(sec.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
</div>
)
})
: (
<div key={ch.id} className="flex justify-between items-center py-2 select-none hover:bg-[#162840]/50 rounded px-2 -mx-2">
<span className="text-sm text-gray-500">{chIdx + 1} | {ch.title}</span>
<ChevronRight className="w-4 h-4 text-gray-500 shrink-0" />
</div>
),
)}
</div>
</div>
)
}
// 尾声:单节,带六点拖拽、可拖可放
if (isWeiSheng && part.chapters.length === 1 && part.chapters[0].sections.length === 1) {
const sec = part.chapters[0].sections[0]
const secDragOver = isDragOver('section', sec.id)
const ctx = { partId: part.id, partTitle: part.title, chapterId: part.chapters[0].id, chapterTitle: part.chapters[0].title }
return (
<div
key={part.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-4 flex items-center justify-between hover:border-[#38bdac]/30 transition-colors cursor-grab active:cursor-grabbing select-none min-h-[40px] ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, ctx)}
>
<div className="flex items-center gap-3 flex-1 min-w-0 select-none">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<div className="w-8 h-8 rounded-lg bg-gray-600/50 flex items-center justify-center shrink-0">
<BookOpen className="w-4 h-4 text-gray-400" />
</div>
<span className="font-medium text-gray-200 truncate">
{part.chapters[0].title} | {sec.title}
</span>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{sec.price === 0 || sec.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{sec.price}</span>
)}
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
)
}
if (isWeiSheng) {
// 尾声多章节:平铺展示,每节带六点拖拽、可拖可放
return (
<div key={part.id} className="rounded-xl border border-gray-700/50 bg-[#1C1C1E] p-5">
<h3 className="text-sm font-medium text-gray-400 mb-4"></h3>
<div className="space-y-3">
{part.chapters.map((ch) =>
ch.sections.map((sec) => {
const secDragOver = isDragOver('section', sec.id)
return (
<div
key={sec.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + sec.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: sec.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex justify-between items-center py-2 select-none rounded px-2 -mx-2 cursor-grab active:cursor-grabbing min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : 'hover:bg-[#162840]/50'} ${isDragging('section', sec.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : ''}`}
{...droppableHandlers('section', sec.id, {
partId: part.id,
partTitle: part.title,
chapterId: ch.id,
chapterTitle: ch.title,
})}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(sec.id)}
onChange={() => onToggleSectionSelect(sec.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<span className="text-sm text-gray-300">{ch.title} | {sec.title}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] text-gray-500"> {(sec.clickCount ?? 0)} · {(sec.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(sec.hotScore ?? 0).toFixed(1)} · {(sec.hotRank && sec.hotRank > 0 ? sec.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onShowSectionOrders(sec)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5">
</Button>
)}
<div className="flex gap-1">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(sec)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(sec)} className="text-gray-500 hover:text-red-400 h-7 px-2">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
)
}),
)}
</div>
</div>
)
}
// 普通篇:卡片 + 章/节
return (
<div
key={part.id}
className={`rounded-xl border bg-[#1C1C1E] overflow-hidden transition-all duration-200 ${partDragOver ? 'border-[#38bdac] ring-2 ring-[#38bdac]/40 bg-[#38bdac]/5' : 'border-gray-700/50'}`}
{...droppableHandlers('part', part.id, {
partId: part.id,
partTitle: part.title,
chapterId: part.chapters[0]?.id ?? '',
chapterTitle: part.chapters[0]?.title ?? '',
})}
>
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'part:' + part.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'part', id: part.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between p-4 cursor-grab active:cursor-grabbing select-none transition-all duration-200 ${isDragging('part', part.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac] rounded-xl shadow-xl shadow-[#38bdac]/20' : 'hover:bg-[#162840]/50'}`}
onClick={() => onTogglePart(part.id)}
>
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac] flex items-center justify-center text-white font-bold shadow-lg shadow-[#38bdac]/30 shrink-0">
{partLabel(partIndex)}
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
</div>
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{onAddSectionInPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onAddSectionInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="在本篇下新增章节">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onEditPart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onEditPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-2" title="编辑篇名">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onDeletePart && (
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeletePart(part)} className="text-gray-500 hover:text-red-400 h-7 px-2" title="删除本篇">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
</div>
</div>
{isExpanded && (
<div className="border-t border-gray-700/50 pl-4 pr-4 pb-4 pt-3 space-y-4">
{part.chapters.map((chapter) => {
const chDragOver = isDragOver('chapter', chapter.id)
return (
<div key={chapter.id} className="space-y-2">
<div className="flex items-center gap-2 w-full">
<div
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'chapter:' + chapter.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'chapter', id: chapter.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
onDragEnter={(e) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type: 'chapter', id: chapter.id })
}}
onDragOver={(e) => {
e.preventDefault()
e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
setDragOverTarget({ type: 'chapter', id: chapter.id })
}}
onDragLeave={() => setDragOverTarget(null)}
onDrop={(e) => {
setDragOverTarget(null)
const from = parseDragData(e.dataTransfer.getData('text/plain'))
if (!from) return
const ctx = { partId: part.id, partTitle: part.title, chapterId: chapter.id, chapterTitle: chapter.title }
if (from.type === 'section') handleDrop(e, 'chapter', chapter.id, ctx)
else if (from.type === 'chapter') handleDrop(e, 'chapter', chapter.id, ctx)
}}
className={`flex-1 min-w-0 py-2 px-2 rounded cursor-grab active:cursor-grabbing select-none -mx-2 transition-all duration-200 flex items-center gap-2 ${chDragOver ? 'bg-[#38bdac]/15 ring-1 ring-[#38bdac]/50' : ''} ${isDragging('chapter', chapter.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac]' : 'hover:bg-[#162840]/30'}`}
>
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
<p className="text-xs text-gray-500 pb-1 flex-1">{chapter.title}</p>
</div>
<div className="flex gap-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{onEditChapter && (
<Button variant="ghost" size="sm" onClick={() => onEditChapter(part, chapter)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑章节名称">
<Edit3 className="w-3.5 h-3.5" />
</Button>
)}
{onAddChapterInPart && (
<Button variant="ghost" size="sm" onClick={() => onAddChapterInPart(part)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="新增第X章">
<Plus className="w-3.5 h-3.5" />
</Button>
)}
{onDeleteChapter && (
<Button variant="ghost" size="sm" onClick={() => onDeleteChapter(part, chapter)} className="text-gray-500 hover:text-red-400 h-7 px-1.5" title="删除本章">
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</div>
</div>
<div className="space-y-1 pl-2">
{chapter.sections.map((section) => {
const secDragOver = isDragOver('section', section.id)
return (
<div
key={section.id}
draggable
onDragStart={(e) => {
e.stopPropagation()
e.dataTransfer.setData('text/plain', 'section:' + section.id)
e.dataTransfer.effectAllowed = 'move'
setDraggingItem({ type: 'section', id: section.id })
}}
onDragEnd={() => { setDraggingItem(null); setDragOverTarget(null) }}
className={`flex items-center justify-between py-2 px-3 rounded-lg group cursor-grab active:cursor-grabbing select-none min-h-[40px] transition-all duration-200 ${secDragOver ? 'bg-[#38bdac]/15 ring-2 ring-[#38bdac]/50' : ''} ${isDragging('section', section.id) ? 'opacity-60 scale-[0.98] ring-2 ring-[#38bdac] shadow-lg' : 'hover:bg-[#162840]/50'}`}
{...droppableHandlers('section', section.id, {
partId: part.id,
partTitle: part.title,
chapterId: chapter.id,
chapterTitle: chapter.title,
})}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{onToggleSectionSelect && (
<label className="shrink-0 flex items-center" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedSectionIds.includes(section.id)}
onChange={() => onToggleSectionSelect(section.id)}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac] focus:ring-[#38bdac]"
/>
</label>
)}
<GripVertical className="w-4 h-4 text-gray-500 shrink-0 opacity-50" />
<div
className={`w-2 h-2 rounded-full shrink-0 ${section.price === 0 || section.isFree ? 'border-2 border-[#38bdac] bg-transparent' : 'bg-gray-500'}`}
/>
<span className="text-sm text-gray-200 truncate">
{section.id} {section.title}
</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div
className="flex items-center gap-2 shrink-0"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
{section.isNew && (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded">NEW</span>
)}
{section.price === 0 || section.isFree ? (
<span className="px-2 py-1 bg-[#38bdac]/20 text-[#38bdac] text-[10px] font-medium rounded"></span>
) : (
<span className="text-xs text-gray-500">¥{section.price}</span>
)}
<span className="text-[10px] text-gray-500" title="点击次数 · 付款笔数"> {(section.clickCount ?? 0)} · {(section.payCount ?? 0)}</span>
<span className="text-[10px] text-amber-400/90" title="热度积分与排名"> {(section.hotScore ?? 0).toFixed(1)} · {(section.hotRank && section.hotRank > 0 ? section.hotRank : '-')}</span>
{onShowSectionOrders && (
<Button variant="ghost" size="sm" onClick={() => onShowSectionOrders(section)} className="text-[10px] text-gray-500 hover:text-[#38bdac] h-7 px-1.5 shrink-0">
</Button>
)}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Button draggable={false} variant="ghost" size="sm" onClick={() => onReadSection(section)} className="text-gray-500 hover:text-[#38bdac] h-7 px-1.5" title="编辑">
<Edit3 className="w-3.5 h-3.5" />
</Button>
<Button draggable={false} variant="ghost" size="sm" onClick={() => onDeleteSection(section)} className="text-gray-500 hover:text-red-400 h-7 px-1.5">
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,650 @@
/**
* 链接人与事 — 添加/编辑弹窗
* 配置与存客宝 API 获客一致(参考 Cunkebao FriendRequestSettings
*/
import { useEffect, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import toast from '@/utils/toast'
import { getCkbDevices, getCkbPlans, type CkbDevice, type CkbPlan } from '@/api/ckb'
export interface PersonFormData {
personId: string
name: string
aliases: string
label: string
sceneId: number
ckbApiKey: string
greeting: string
tips: string
remarkType: string
remarkFormat: string
addFriendInterval: number
startTime: string
endTime: string
deviceGroups: string // 逗号分隔的设备ID如 "1,2,3"
}
const SCENE_ID_API = 11 // API获客固定
const defaultForm: PersonFormData = {
personId: '',
name: '',
aliases: '',
label: '',
sceneId: SCENE_ID_API,
ckbApiKey: '',
greeting: '你好,请通过',
tips: '请注意消息,稍后加你微信',
remarkType: 'phone',
remarkFormat: '',
addFriendInterval: 1,
startTime: '09:00',
endTime: '18:00',
deviceGroups: '',
}
interface PersonAddEditModalProps {
open: boolean
onOpenChange: (open: boolean) => void
editingPerson?: {
personId?: string
name: string
aliases?: string
label?: string
ckbApiKey?: string
remarkType?: string
remarkFormat?: string
addFriendInterval?: number
startTime?: string
endTime?: string
deviceGroups?: string
} | null
onSubmit: (data: PersonFormData) => Promise<void>
}
export function PersonAddEditModal({
open,
onOpenChange,
editingPerson,
onSubmit,
}: PersonAddEditModalProps) {
const isEdit = !!editingPerson
const [form, setForm] = useState<PersonFormData>(defaultForm)
const [saving, setSaving] = useState(false)
const [deviceSelectorOpen, setDeviceSelectorOpen] = useState(false)
const [deviceOptions, setDeviceOptions] = useState<CkbDevice[]>([])
const [deviceLoading, setDeviceLoading] = useState(false)
const [deviceKeyword, setDeviceKeyword] = useState('')
const [planOptions, setPlanOptions] = useState<CkbPlan[]>([])
const [planLoading, setPlanLoading] = useState(false)
const [planKeyword, setPlanKeyword] = useState('')
const [planDropdownOpen, setPlanDropdownOpen] = useState(false)
/** 必填项校验错误,用于红色边框与提示 */
const [errors, setErrors] = useState<{
name?: string
addFriendInterval?: string
deviceGroups?: string
}>({})
useEffect(() => {
if (open) {
// 每次打开时重置设备搜索关键字
setDeviceKeyword('')
if (editingPerson) {
setForm({
personId: editingPerson.personId ?? editingPerson.name ?? '',
name: editingPerson.name ?? '',
aliases: editingPerson.aliases ?? '',
label: editingPerson.label ?? '',
sceneId: SCENE_ID_API,
ckbApiKey: editingPerson.ckbApiKey ?? '',
greeting: '你好,请通过',
tips: '请注意消息,稍后加你微信',
remarkType: editingPerson.remarkType ?? 'phone',
remarkFormat: editingPerson.remarkFormat ?? '',
addFriendInterval: editingPerson.addFriendInterval ?? 1,
startTime: editingPerson.startTime ?? '09:00',
endTime: editingPerson.endTime ?? '18:00',
deviceGroups: editingPerson.deviceGroups ?? '',
})
} else {
setForm({ ...defaultForm })
}
setErrors({})
// 懒加载设备列表和计划列表
if (deviceOptions.length === 0) {
void loadDevices('')
}
if (planOptions.length === 0) {
void loadPlans('')
}
}
}, [open, editingPerson])
const loadDevices = async (keyword: string) => {
setDeviceLoading(true)
try {
const res = await getCkbDevices({ page: 1, limit: 50, keyword })
if (res?.success && Array.isArray(res.devices)) {
setDeviceOptions(res.devices)
} else if (res?.error) {
toast.error(res.error)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载设备列表失败')
} finally {
setDeviceLoading(false)
}
}
const loadPlans = async (keyword: string) => {
setPlanLoading(true)
try {
const res = await getCkbPlans({ page: 1, limit: 100, keyword })
if (res?.success && Array.isArray(res.plans)) {
setPlanOptions(res.plans)
} else if (res?.error) {
toast.error(res.error)
}
} catch {
toast.error('加载计划列表失败')
} finally {
setPlanLoading(false)
}
}
const selectPlan = (plan: CkbPlan) => {
const dg = Array.isArray(plan.deviceGroups) ? plan.deviceGroups.map(String).join(',') : ''
setForm(f => ({
...f,
ckbApiKey: plan.apiKey || '',
greeting: plan.greeting || f.greeting,
tips: plan.tips || f.tips,
remarkType: plan.remarkType || f.remarkType,
remarkFormat: plan.remarkFormat || f.remarkFormat,
addFriendInterval: plan.addInterval || f.addFriendInterval,
startTime: plan.startTime || f.startTime,
endTime: plan.endTime || f.endTime,
deviceGroups: dg || f.deviceGroups,
}))
setPlanDropdownOpen(false)
toast.success(`已选择计划「${plan.name}」,参数已覆盖`)
}
const filteredPlans = planKeyword.trim()
? planOptions.filter(p => (p.name || '').includes(planKeyword.trim()) || String(p.id).includes(planKeyword.trim()))
: planOptions
const handleSubmit = async () => {
const nextErrors: {
name?: string
addFriendInterval?: string
deviceGroups?: string
} = {}
if (!form.name || !String(form.name).trim()) {
nextErrors.name = '请填写名称'
}
const interval = form.addFriendInterval
if (typeof interval !== 'number' || interval < 1) {
nextErrors.addFriendInterval = '添加间隔至少为 1 分钟'
}
const deviceIds = form.deviceGroups?.split(',').map((s) => s.trim()).filter(Boolean) ?? []
if (deviceIds.length === 0) {
nextErrors.deviceGroups = '请至少选择 1 台设备'
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error(
nextErrors.name || nextErrors.addFriendInterval || nextErrors.deviceGroups || '请完善必填项'
)
return
}
setSaving(true)
try {
await onSubmit(form)
onOpenChange(false)
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败')
// 不关闭弹窗,让用户看到错误后可修改再提交
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-[#38bdac]">
{isEdit ? '编辑人物' : '添加人物 — 存客宝 API 获客'}
</DialogTitle>
<DialogDescription className="text-gray-400 text-sm">
{isEdit
? '修改后同步到存客宝计划'
: '添加时自动生成 token并同步创建存客宝场景获客计划'}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-2">
{/* 基础信息 — 单行三列 */}
<div>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3"></p>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs">
<span className="text-red-400">*</span>
</Label>
<Input
className={`bg-[#0a1628] text-white ${errors.name ? 'border-red-500 focus-visible:ring-red-500' : 'border-gray-700'}`}
placeholder="如 卡若"
value={form.name}
onChange={(e) => {
setForm((f) => ({ ...f, name: e.target.value }))
if (errors.name) setErrors((e) => ({ ...e, name: undefined }))
}}
/>
{errors.name && <p className="text-xs text-red-400">{errors.name}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs">ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="自动生成"
value={form.personId}
onChange={(e) => setForm((f) => ({ ...f, personId: e.target.value }))}
disabled={isEdit}
/>
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs">/</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如 超级个体"
value={form.label}
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs">@ </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如 卡卡, 若若"
value={form.aliases}
onChange={(e) => setForm((f) => ({ ...f, aliases: e.target.value }))}
/>
</div>
</div>
</div>
{/* 存客宝 API 获客配置 — 两列布局 */}
<div className="border-t border-gray-700/50 pt-5">
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-4"> API </p>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
{/* 左列:计划密钥与设备 */}
<div className="space-y-4">
<div className="space-y-1.5 relative">
<Label className="text-gray-400 text-xs"></Label>
<div className="flex gap-2">
<div
className="flex-1 flex items-center bg-[#0a1628] border border-gray-700 rounded-md px-3 py-2 cursor-pointer hover:border-[#38bdac]/60 text-sm"
onClick={() => setPlanDropdownOpen(!planDropdownOpen)}
>
{form.ckbApiKey ? (
<span className="text-white truncate">
{planOptions.find(p => p.apiKey === form.ckbApiKey)?.name || `密钥: ${form.ckbApiKey.slice(0, 20)}...`}
</span>
) : (
<span className="text-gray-500"> / </span>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-200 shrink-0"
onClick={() => { void loadPlans(planKeyword); setPlanDropdownOpen(true) }}
disabled={planLoading}
>
{planLoading ? '加载...' : '刷新'}
</Button>
</div>
{planDropdownOpen && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-[#0b1828] border border-gray-700 rounded-lg shadow-xl max-h-64 flex flex-col">
<div className="p-2 border-b border-gray-700/60">
<Input
className="bg-[#050c18] border-gray-700 text-white h-8 text-xs"
placeholder="搜索计划名称..."
value={planKeyword}
onChange={e => setPlanKeyword(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void loadPlans(planKeyword) }}
autoFocus
/>
</div>
<div className="flex-1 overflow-y-auto">
{filteredPlans.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-xs">
{planLoading ? '加载中...' : '暂无计划'}
</div>
) : filteredPlans.map(plan => (
<div
key={String(plan.id)}
className={`px-3 py-2 cursor-pointer hover:bg-[#38bdac]/10 text-sm flex items-center justify-between ${form.ckbApiKey === plan.apiKey ? 'bg-[#38bdac]/20 text-[#38bdac]' : 'text-white'}`}
onClick={() => selectPlan(plan)}
>
<div className="truncate">
<span className="font-medium">{plan.name}</span>
<span className="text-xs text-gray-500 ml-2">ID:{String(plan.id)}</span>
</div>
{plan.enabled ? (
<span className="text-[10px] text-green-400 bg-green-400/10 px-1.5 rounded shrink-0 ml-2"></span>
) : (
<span className="text-[10px] text-gray-500 bg-gray-500/10 px-1.5 rounded shrink-0 ml-2"></span>
)}
</div>
))}
</div>
<div className="p-2 border-t border-gray-700/60 flex justify-end">
<Button type="button" size="sm" variant="ghost" className="text-gray-400 h-7 text-xs" onClick={() => setPlanDropdownOpen(false)}></Button>
</div>
</div>
)}
<p className="text-xs text-gray-500">
</p>
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs">
<span className="text-red-400">*</span>
</Label>
<div
className={`flex gap-2 rounded-md border ${errors.deviceGroups ? 'border-red-500' : 'border-gray-700'}`}
>
<Input
className="bg-[#0a1628] border-0 text-white focus-visible:ring-0 focus-visible:ring-offset-0"
placeholder="未选择设备"
readOnly
value={
form.deviceGroups
? `已选择 ${form.deviceGroups.split(',').filter(Boolean).length} 个设备`
: ''
}
onClick={() => setDeviceSelectorOpen(true)}
/>
<Button
type="button"
variant="outline"
className="border-0 border-l border-inherit rounded-r-md text-gray-200"
onClick={() => setDeviceSelectorOpen(true)}
>
</Button>
</div>
{errors.deviceGroups ? (
<p className="text-xs text-red-400">{errors.deviceGroups}</p>
) : (
<p className="text-xs text-gray-500">
1
</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"></Label>
<Select value={form.remarkType} onValueChange={(v) => setForm((f) => ({ ...f, remarkType: v }))}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white">
<SelectValue placeholder="选择备注类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="phone"></SelectItem>
<SelectItem value="nickname"></SelectItem>
<SelectItem value="source"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如 手机号+SOUL链接人与事-{名称},留空用默认"
value={form.remarkFormat}
onChange={(e) => setForm((f) => ({ ...f, remarkFormat: e.target.value }))}
/>
</div>
</div>
{/* 右列:消息与时间 */}
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="你好,请通过"
value={form.greeting}
onChange={(e) => setForm((f) => ({ ...f, greeting: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"></Label>
<Input
type="number"
min={1}
className={`bg-[#0a1628] text-white ${errors.addFriendInterval ? 'border-red-500 focus-visible:ring-red-500' : 'border-gray-700'}`}
value={form.addFriendInterval}
onChange={(e) => {
setForm((f) => ({ ...f, addFriendInterval: Number(e.target.value) || 1 }))
if (errors.addFriendInterval) setErrors((e) => ({ ...e, addFriendInterval: undefined }))
}}
/>
{errors.addFriendInterval && (
<p className="text-xs text-red-400">{errors.addFriendInterval}</p>
)}
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"></Label>
<div className="flex items-center gap-2">
<Input
type="time"
className="bg-[#0a1628] border-gray-700 text-white w-24"
value={form.startTime}
onChange={(e) => setForm((f) => ({ ...f, startTime: e.target.value }))}
/>
<span className="text-gray-500 text-sm shrink-0"></span>
<Input
type="time"
className="bg-[#0a1628] border-gray-700 text-white w-24"
value={form.endTime}
onChange={(e) => setForm((f) => ({ ...f, endTime: e.target.value }))}
/>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"></Label>
<Textarea
className="bg-[#0a1628] border-gray-700 text-white min-h-[72px] resize-none"
placeholder="请注意消息,稍后加你微信"
value={form.tips}
onChange={(e) => setForm((f) => ({ ...f, tips: e.target.value }))}
/>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-3 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)} className="border-gray-600 text-gray-300">
</Button>
<Button
onClick={handleSubmit}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{saving ? '保存中...' : isEdit ? '保存' : '添加'}
</Button>
</DialogFooter>
{/* 设备多选面板 */}
{deviceSelectorOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-3xl max-h-[80vh] bg-[#0b1828] border border-gray-700 rounded-xl shadow-xl flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-700/60">
<div>
<h3 className="text-sm font-medium text-white"></h3>
<p className="text-xs text-gray-400 mt-0.5"></p>
</div>
<div className="flex items-center gap-2">
<Input
className="bg-[#050c18] border-gray-700 text-white h-8 w-52"
placeholder="搜索备注/微信号/IMEI"
value={deviceKeyword}
onChange={(e) => setDeviceKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
void loadDevices(deviceKeyword)
}
}}
/>
<Button
type="button"
size="sm"
variant="outline"
className="border-gray-600 text-gray-200 h-8"
onClick={() => loadDevices(deviceKeyword)}
disabled={deviceLoading}
>
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="border-gray-600 text-gray-300 h-8 w-8"
onClick={() => setDeviceSelectorOpen(false)}
>
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{deviceLoading ? (
<div className="flex h-full items-center justify-center text-gray-400 text-sm">
</div>
) : deviceOptions.length === 0 ? (
<div className="flex h-full items-center justify-center text-gray-500 text-sm">
API
</div>
) : (
<div className="p-4 space-y-2">
{deviceOptions.map((d) => {
const idStr = String(d.id ?? '')
const selectedIds = form.deviceGroups
? form.deviceGroups.split(',').map((s) => s.trim()).filter(Boolean)
: []
const checked = selectedIds.includes(idStr)
const handleToggle = () => {
let next: string[]
if (checked) {
next = selectedIds.filter((x) => x !== idStr)
} else {
next = [...selectedIds, idStr]
}
setForm((f) => ({ ...f, deviceGroups: next.join(',') }))
if (next.length > 0) {
setErrors((e) => ({ ...e, deviceGroups: undefined }))
}
}
return (
<label
key={idStr}
className="flex items-center gap-3 rounded-lg border border-gray-700/60 bg-[#050c18] px-3 py-2 cursor-pointer hover:border-[#38bdac]/70"
>
<input
type="checkbox"
className="h-4 w-4 accent-[#38bdac]"
checked={checked}
onChange={handleToggle}
/>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-white truncate max-w-xs">
{d.memo || d.wechatId || `设备 ${idStr}`}
</span>
{d.status === 'online' && (
<span className="rounded-full bg-emerald-500/20 text-emerald-400 text-[11px] px-2 py-0.5">
线
</span>
)}
{d.status === 'offline' && (
<span className="rounded-full bg-gray-600/20 text-gray-400 text-[11px] px-2 py-0.5">
线
</span>
)}
</div>
<div className="text-[11px] text-gray-400 mt-0.5">
<span className="mr-3">ID: {idStr}</span>
{d.wechatId && <span className="mr-3">: {d.wechatId}</span>}
{typeof d.totalFriend === 'number' && (
<span>: {d.totalFriend}</span>
)}
</div>
</div>
</label>
)
})}
</div>
)}
</div>
<div className="flex justify-between items-center px-5 py-3 border-t border-gray-700/60">
<span className="text-xs text-gray-400">
{' '}
{form.deviceGroups
? form.deviceGroups.split(',').filter(Boolean).length
: 0}{' '}
</span>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="border-gray-600 text-gray-200 h-8 px-4"
onClick={() => setDeviceSelectorOpen(false)}
>
</Button>
<Button
type="button"
className="bg-[#38bdac] hover:bg-[#2da396] text-white h-8 px-4"
onClick={() => setDeviceSelectorOpen(false)}
>
</Button>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,626 @@
import { useState, useEffect } from 'react'
import { normalizeImageUrl } from '@/lib/utils'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Users, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
import { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
interface OrderRow {
id: string
amount?: number
status?: string
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
referralCode?: string
createdAt?: string
}
interface DashboardStatsRes {
success?: boolean
totalUsers?: number
paidOrderCount?: number
paidUserCount?: number
totalRevenue?: number
conversionRate?: number
}
interface DashboardOverviewRes {
success?: boolean
totalUsers?: number
paidOrderCount?: number
paidUserCount?: number
totalRevenue?: number
conversionRate?: number
recentOrders?: OrderRow[]
newUsers?: UserRow[]
}
interface UsersRes {
success?: boolean
users?: UserRow[]
total?: number
}
interface OrdersRes {
success?: boolean
orders?: OrderRow[]
total?: number
}
function maskPhone(phone?: string) {
if (!phone) return ''
const digits = phone.replace(/\s+/g, '')
if (digits.length < 7) return digits
return `${digits.slice(0, 3)}****${digits.slice(-4)}`
}
export function DashboardPage() {
const navigate = useNavigate()
const [statsLoading, setStatsLoading] = useState(true)
const [ordersLoading, setOrdersLoading] = useState(true)
const [usersLoading, setUsersLoading] = useState(true)
const [users, setUsers] = useState<UserRow[]>([])
const [purchases, setPurchases] = useState<OrderRow[]>([])
const [totalUsersCount, setTotalUsersCount] = useState(0)
const [paidOrderCount, setPaidOrderCount] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [conversionRate, setConversionRate] = useState(0)
const [giftedTotal, setGiftedTotal] = useState(0)
const [loadError, setLoadError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false)
const [trackPeriod, setTrackPeriod] = useState<string>('today')
const [trackStats, setTrackStats] = useState<{
total: number
byModule: Record<string, { action: string; target: string; module: string; page: string; count: number }[]>
} | null>(null)
const [trackLoading, setTrackLoading] = useState(false)
const showError = (err: unknown) => {
const e = err as Error & { status?: number; name?: string }
if (e?.status === 401) setLoadError('登录已过期,请重新登录')
else if (e?.name === 'AbortError') return
else setLoadError('加载失败,请检查网络或联系管理员')
}
async function loadAll(signal?: AbortSignal) {
const init = signal ? { signal } : undefined
// 1. 优先加载统计(轻量)
setStatsLoading(true)
setLoadError(null)
try {
const stats = await get<DashboardStatsRes>('/api/admin/dashboard/stats', init)
if (stats?.success) {
setTotalUsersCount(stats.totalUsers ?? 0)
setPaidOrderCount(stats.paidOrderCount ?? 0)
setTotalRevenue(stats.totalRevenue ?? 0)
setConversionRate(stats.conversionRate ?? 0)
}
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
console.error('stats 失败,尝试 overview 降级', e)
try {
const overview = await get<DashboardOverviewRes>('/api/admin/dashboard/overview', init)
if (overview?.success) {
setTotalUsersCount(overview.totalUsers ?? 0)
setPaidOrderCount(overview.paidOrderCount ?? 0)
setTotalRevenue(overview.totalRevenue ?? 0)
setConversionRate(overview.conversionRate ?? 0)
}
} catch (e2) {
showError(e2)
}
}
} finally {
setStatsLoading(false)
}
// 加载代付总额(仅用于收入标签展示)
try {
const balRes = await get<{ success?: boolean; data?: { totalGifted?: number } }>('/api/admin/balance/summary', init)
if (balRes?.success && balRes.data) {
setGiftedTotal(balRes.data.totalGifted ?? 0)
}
} catch {
// 不影响主面板
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
const loadOrders = async () => {
try {
const res = await get<{ success?: boolean; recentOrders?: OrderRow[] }>(
'/api/admin/dashboard/recent-orders',
init
)
if (res?.success && res.recentOrders) setPurchases(res.recentOrders)
else throw new Error('no data')
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
try {
const ordersData = await get<OrdersRes>('/api/admin/orders?page=1&pageSize=20&status=paid', init)
const orders = ordersData?.orders ?? []
const paid = orders.filter((p) => ['paid', 'completed', 'success'].includes(p.status || ''))
setPurchases(paid.slice(0, 5))
} catch {
setPurchases([])
}
}
} finally {
setOrdersLoading(false)
}
}
const loadUsers = async () => {
try {
const res = await get<{ success?: boolean; newUsers?: UserRow[] }>(
'/api/admin/dashboard/new-users',
init
)
if (res?.success && res.newUsers) setUsers(res.newUsers)
else throw new Error('no data')
} catch (e) {
if ((e as Error)?.name !== 'AbortError') {
try {
const usersData = await get<UsersRes>('/api/db/users?page=1&pageSize=10', init)
setUsers(usersData?.users ?? [])
} catch {
setUsers([])
}
}
} finally {
setUsersLoading(false)
}
}
await Promise.all([loadOrders(), loadUsers()])
}
async function loadTrackStats(period?: string) {
const p = period || trackPeriod
setTrackLoading(true)
try {
const res = await get<{ success?: boolean; total?: number; byModule?: Record<string, { action: string; target: string; module: string; page: string; count: number }[]> }>(
`/api/admin/track/stats?period=${p}`
)
if (res?.success) {
setTrackStats({ total: res.total ?? 0, byModule: res.byModule ?? {} })
}
} catch {
setTrackStats(null)
} finally {
setTrackLoading(false)
}
}
useEffect(() => {
const ctrl = new AbortController()
loadAll(ctrl.signal)
loadTrackStats()
const timer = setInterval(() => { loadAll(); loadTrackStats() }, 30000)
return () => {
ctrl.abort()
clearInterval(timer)
}
}, [])
const totalUsers = totalUsersCount
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: statsLoading ? null : totalUsers,
sub: null as string | null,
icon: Users,
color: 'text-blue-400',
bg: 'bg-blue-500/20',
link: '/users',
},
{
title: '总收入',
value: statsLoading ? null : `¥${(totalRevenue ?? 0).toFixed(2)}`,
sub: giftedTotal > 0 ? `含代付 ¥${giftedTotal.toFixed(2)}` : null,
icon: TrendingUp,
color: 'text-[#38bdac]',
bg: 'bg-[#38bdac]/20',
link: '/orders',
},
{
title: '订单数',
value: statsLoading ? null : paidOrderCount,
sub: null as string | null,
icon: ShoppingBag,
color: 'text-purple-400',
bg: 'bg-purple-500/20',
link: '/orders',
},
{
title: '转化率',
value: statsLoading ? null : `${conversionRate.toFixed(1)}%`,
sub: null as string | null,
icon: TrendingUp,
color: 'text-amber-400',
bg: 'bg-amber-500/20',
link: '/users',
},
]
return (
<div className="p-8 w-full">
<h1 className="text-2xl font-bold mb-8 text-white"></h1>
{loadError && (
<div className="mb-6 px-4 py-3 rounded-lg bg-amber-500/20 border border-amber-500/50 text-amber-200 text-sm flex items-center justify-between">
<span>{loadError}</span>
<button
type="button"
onClick={() => loadAll()}
className="text-amber-400 hover:text-amber-300 underline"
>
</button>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{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>
<div className="text-2xl font-bold text-white min-h-8 flex items-center">
{stat.value != null ? (
stat.value
) : (
<span className="inline-flex items-center gap-2 text-gray-500">
<RefreshCw className="w-4 h-4 animate-spin" />
</span>
)}
</div>
{stat.sub && (
<p className="text-xs text-gray-500 mt-1">{stat.sub}</p>
)}
</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 className="flex flex-row items-center justify-between">
<CardTitle className="text-white"></CardTitle>
<button
type="button"
onClick={() => loadAll()}
disabled={ordersLoading || usersLoading}
className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50"
title="刷新"
>
{(ordersLoading || usersLoading) ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</CardHeader>
<CardContent>
<div className="space-y-3">
{ordersLoading && purchases.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
<span className="text-sm">...</span>
</div>
) : (
<>
{purchases
.slice(0, 5)
.map((p) => {
const referrer: UserRow | undefined = p.referrerId
? users.find((u) => u.id === p.referrerId)
: undefined
const inviteCode =
p.referralCode ||
referrer?.referralCode ||
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 ||
maskPhone(users.find((u) => u.id === p.userId)?.phone) ||
'匿名用户'
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={normalizeImageUrl(p.userAvatar)}
alt={buyer}
className="w-9 h-9 rounded-full object-cover 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] 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">
<button
type="button"
onClick={() => { if (p.userId) { setDetailUserId(p.userId); setShowDetailModal(true) } }}
className="text-sm text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
>
{buyer}
</button>
<span className="text-gray-600">·</span>
<span className="text-sm font-medium text-white truncate" title={product.title}>
{product.title}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-500">
{product.subtitle && product.subtitle !== '章节购买' && (
<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 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 && !ordersLoading && (
<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">
{usersLoading && users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-500">
<RefreshCw className="w-8 h-8 animate-spin mb-2" />
<span className="text-sm">...</span>
</div>
) : (
<>
{users
.slice(0, 5)
.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 || maskPhone(u.phone) || '?').charAt(0)}
</div>
<div>
<button
type="button"
onClick={() => { setDetailUserId(u.id); setShowDetailModal(true) }}
className="text-sm font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
>
{u.nickname || maskPhone(u.phone) || '匿名用户'}
</button>
<p className="text-xs text-gray-500">{maskPhone(u.phone) || '未填写手机号'}</p>
</div>
</div>
<p className="text-xs text-gray-400">
{u.createdAt
? new Date(u.createdAt).toLocaleDateString()
: '-'}
</p>
</div>
))}
{users.length === 0 && !usersLoading && (
<p className="text-gray-500 text-center py-8"></p>
)}
</>
)}
</div>
</CardContent>
</Card>
</div>
{/* 分类标签点击统计 */}
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl mt-8">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-white flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<div className="flex items-center gap-2">
{(['today', 'week', 'month', 'all'] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => { setTrackPeriod(p); loadTrackStats(p) }}
className={`px-3 py-1 text-xs rounded-full transition-colors ${
trackPeriod === p
? 'bg-[#38bdac] text-white'
: 'bg-gray-700/50 text-gray-400 hover:bg-gray-700'
}`}
>
{{ today: '今日', week: '本周', month: '本月', all: '全部' }[p]}
</button>
))}
</div>
</CardHeader>
<CardContent>
{trackLoading && !trackStats ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<RefreshCw className="w-6 h-6 animate-spin mr-2" />
<span>...</span>
</div>
) : trackStats && Object.keys(trackStats.byModule).length > 0 ? (
<div className="space-y-6">
<p className="text-sm text-gray-400">
<span className="text-white font-bold text-lg">{trackStats.total}</span>
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(trackStats.byModule)
.sort((a, b) => b[1].reduce((s, i) => s + i.count, 0) - a[1].reduce((s, i) => s + i.count, 0))
.map(([mod, items]) => {
const moduleTotal = items.reduce((s, i) => s + i.count, 0)
const moduleLabels: Record<string, string> = {
home: '首页', chapters: '目录', read: '阅读', my: '我的',
vip: 'VIP', wallet: '钱包', match: '找伙伴', referral: '推广',
search: '搜索', settings: '设置', about: '关于', other: '其他',
}
return (
<div key={mod} className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-[#38bdac]">
{moduleLabels[mod] || mod}
</span>
<span className="text-xs text-gray-500">{moduleTotal} </span>
</div>
<div className="space-y-2">
{items
.sort((a, b) => b.count - a.count)
.slice(0, 8)
.map((item, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
{item.target || item.action}
</span>
<div className="flex items-center gap-2 shrink-0">
<div className="w-16 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-[#38bdac] rounded-full"
style={{ width: `${moduleTotal > 0 ? (item.count / moduleTotal) * 100 : 0}%` }}
/>
</div>
<span className="text-gray-400 w-8 text-right">{item.count}</span>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
) : (
<div className="text-center py-12">
<BarChart3 className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-500"></p>
<p className="text-gray-600 text-xs mt-1"></p>
</div>
)}
</CardContent>
</Card>
<UserDetailModal
open={showDetailModal}
onClose={() => { setShowDetailModal(false); setDetailUserId(null) }}
userId={detailUserId}
onUserUpdated={() => loadAll()}
/>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,87 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Users, Handshake, GraduationCap, UserPlus, BarChart3, Link2 } from 'lucide-react'
import { FindPartnerTab } from './tabs/FindPartnerTab'
import { ResourceDockingTab } from './tabs/ResourceDockingTab'
import { MentorTab } from './tabs/MentorTab'
import { TeamRecruitTab } from './tabs/TeamRecruitTab'
import { CKBStatsTab } from './tabs/CKBStatsTab'
import { CKBConfigPanel } from './tabs/CKBConfigPanel'
const TABS = [
{ id: 'stats', label: '数据统计', icon: BarChart3 },
{ id: 'partner', label: '找伙伴', icon: Users },
{ id: 'resource', label: '资源对接', icon: Handshake },
{ id: 'mentor', label: '导师预约', icon: GraduationCap },
{ id: 'team', label: '团队招募', icon: UserPlus },
] as const
type TabId = (typeof TABS)[number]['id']
export function FindPartnerPage() {
const [activeTab, setActiveTab] = useState<TabId>('stats')
const [showCKBPanel, setShowCKBPanel] = useState(false)
const [ckbPanelTab, setCkbPanelTab] = useState<'overview' | 'submitted' | 'contact' | 'config' | 'test' | 'doc'>('overview')
return (
<div className="p-8 w-full">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="w-6 h-6 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
</p>
</div>
<Button
type="button"
variant="outline"
onClick={() => setShowCKBPanel((v) => !v)}
className="border-orange-500/40 text-orange-300 hover:bg-orange-500/10 bg-transparent"
>
<Link2 className="w-4 h-4 mr-2" />
</Button>
</div>
{showCKBPanel && <CKBConfigPanel initialTab={ckbPanelTab} />}
<div className="flex flex-wrap gap-1 mb-6 bg-[#0f2137] rounded-lg p-1 border border-gray-700/50">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-md text-sm font-medium transition-all ${
isActive
? 'bg-[#38bdac] text-white shadow-lg'
: 'text-gray-400 hover:text-white hover:bg-gray-700/50'
}`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
)
})}
</div>
{activeTab === 'stats' && (
<CKBStatsTab
onSwitchTab={(id) => setActiveTab(id as TabId)}
onOpenCKB={(tab) => {
setCkbPanelTab((tab as typeof ckbPanelTab) || 'overview')
setShowCKBPanel(true)
}}
/>
)}
{activeTab === 'partner' && <FindPartnerTab />}
{activeTab === 'resource' && <ResourceDockingTab />}
{activeTab === 'mentor' && <MentorTab />}
{activeTab === 'team' && <TeamRecruitTab />}
</div>
)
}

View File

@@ -0,0 +1,381 @@
import toast from '@/utils/toast'
import { useEffect, useMemo, useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { RefreshCw, Zap, CheckCircle2, XCircle, Smartphone, ExternalLink, Save } from 'lucide-react'
import { get, post } from '@/api/client'
type WorkspaceTab = 'overview' | 'submitted' | 'contact' | 'config' | 'test' | 'doc'
interface TestResult {
endpoint: string
label: string
method: 'GET' | 'POST'
status: 'idle' | 'testing' | 'success' | 'error'
message?: string
responseTime?: number
}
interface LeadRow {
id: string
userId: string
userNickname?: string
matchType: string
phone?: string
wechatId?: string
createdAt: string
}
interface RouteConfig {
apiUrl: string
apiKey: string
source: string
tags: string
siteTags: string
notes: string
}
const typeMap = ['partner', 'investor', 'mentor', 'team']
const routeDefs = [
{ key: 'join_partner', label: '找伙伴场景' },
{ key: 'join_investor', label: '资源对接场景' },
{ key: 'join_mentor', label: '导师顾问场景' },
{ key: 'join_team', label: '团队招募场景' },
{ key: 'match', label: '匹配上报' },
{ key: 'lead', label: '链接卡若' },
] as const
const defaultDoc = `# 场景获客接口摘要
- 地址POST /v1/api/scenarios
- 必填apiKey、sign、timestamp
- 主标识phone 或 wechatId 至少一项
- 可选name、source、remark、tags、siteTags、portrait
- 签名:排除 sign/apiKey/portrait键名升序拼接值后双重 MD5
- 成功code=200message=新增成功 或 已存在`
export function CKBConfigPanel({ initialTab = 'overview' }: { initialTab?: WorkspaceTab }) {
const [activeTab, setActiveTab] = useState<WorkspaceTab>(initialTab)
const [testPhone, setTestPhone] = useState('13800000000')
const [testWechat, setTestWechat] = useState('')
const [docNotes, setDocNotes] = useState('')
const [docContent, setDocContent] = useState(defaultDoc)
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [submittedLeads, setSubmittedLeads] = useState<LeadRow[]>([])
const [contactLeads, setContactLeads] = useState<LeadRow[]>([])
const [routes, setRoutes] = useState<Record<string, RouteConfig>>({})
const [tests, setTests] = useState<TestResult[]>([
{ endpoint: '/api/ckb/join', label: '找伙伴', method: 'POST', status: 'idle' },
{ endpoint: '/api/ckb/join', label: '资源对接', method: 'POST', status: 'idle' },
{ endpoint: '/api/ckb/join', label: '导师顾问', method: 'POST', status: 'idle' },
{ endpoint: '/api/ckb/join', label: '团队招募', method: 'POST', status: 'idle' },
{ endpoint: '/api/ckb/match', label: '匹配上报', method: 'POST', status: 'idle' },
{ endpoint: '/api/miniprogram/ckb/lead', label: '链接卡若', method: 'POST', status: 'idle' },
{ endpoint: '/api/match/config', label: '匹配配置', method: 'GET', status: 'idle' },
])
const routeMap = useMemo(() => {
const m: Record<string, RouteConfig> = {}
routeDefs.forEach((item) => {
m[item.key] = routes[item.key] || {
apiUrl: 'https://ckbapi.quwanzhi.com/v1/api/scenarios',
apiKey: 'fyngh-ecy9h-qkdae-epwd5-rz6kd',
source: '',
tags: '',
siteTags: '创业实验APP',
notes: '',
}
})
return m
}, [routes])
const getBody = (idx: number) => {
const phone = testPhone.trim()
const wechat = testWechat.trim()
if (idx <= 3) return { type: typeMap[idx], phone: phone || undefined, wechat: wechat || undefined, userId: 'admin_test', name: '后台测试' }
if (idx === 4) return { matchType: 'partner', phone: phone || undefined, wechat: wechat || undefined, userId: 'admin_test', nickname: '后台测试', matchedUser: { id: 'test', nickname: '测试', matchScore: 88 } }
if (idx === 5) return { phone: phone || undefined, wechatId: wechat || undefined, userId: 'admin_test', name: '后台测试' }
return {}
}
async function loadWorkspace() {
setLoading(true)
try {
const [cfgRes, submittedRes, contactRes] = await Promise.all([
get<{ data?: { routes?: Record<string, RouteConfig>; docNotes?: string; docContent?: string } }>('/api/db/config/full?key=ckb_config'),
get<{ success?: boolean; records?: LeadRow[] }>('/api/db/ckb-leads?mode=submitted&page=1&pageSize=50'),
get<{ success?: boolean; records?: LeadRow[] }>('/api/db/ckb-leads?mode=contact&page=1&pageSize=50'),
])
const c = cfgRes?.data
if (c?.routes) setRoutes(c.routes)
if (c?.docNotes) setDocNotes(c.docNotes)
if (c?.docContent) setDocContent(c.docContent)
if (submittedRes?.success) setSubmittedLeads(submittedRes.records || [])
if (contactRes?.success) setContactLeads(contactRes.records || [])
} finally {
setLoading(false)
}
}
useEffect(() => { setActiveTab(initialTab) }, [initialTab])
useEffect(() => { void loadWorkspace() }, [])
async function saveConfig() {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'ckb_config',
value: { routes: routeMap, docNotes, docContent },
description: '存客宝接口配置',
})
toast.error(res?.success !== false ? '存客宝配置已保存' : `保存失败: ${res?.error || '未知错误'}`)
} catch (e) {
toast.error(`保存失败: ${e instanceof Error ? e.message : '网络错误'}`)
} finally {
setIsSaving(false)
}
}
const updateRoute = (key: string, patch: Partial<RouteConfig>) => {
setRoutes((prev) => ({ ...prev, [key]: { ...routeMap[key], ...patch } }))
}
const testOne = async (idx: number) => {
const t = tests[idx]
if (t.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { toast.error('请填写测试手机号'); return }
const next = [...tests]
next[idx] = { ...t, status: 'testing', message: undefined, responseTime: undefined }
setTests(next)
const start = performance.now()
try {
const res = t.method === 'GET'
? await get<{ success?: boolean; message?: string }>(t.endpoint)
: await post<{ success?: boolean; message?: string }>(t.endpoint, getBody(idx))
const elapsed = Math.round(performance.now() - start)
const msg = res?.message || ''
const ok = res?.success === true || msg.includes('已存在') || msg.includes('已加入') || msg.includes('已提交')
const out = [...tests]
out[idx] = { ...t, status: ok ? 'success' : 'error', message: msg || (ok ? '正常' : '异常'), responseTime: elapsed }
setTests(out)
await loadWorkspace()
} catch (e: unknown) {
const elapsed = Math.round(performance.now() - start)
const out = [...tests]
out[idx] = { ...t, status: 'error', message: e instanceof Error ? e.message : '失败', responseTime: elapsed }
setTests(out)
}
}
const testAll = async () => {
if (!testPhone.trim() && !testWechat.trim()) { toast.error('请填写测试手机号'); return }
for (let i = 0; i < tests.length; i++) await testOne(i)
}
const renderLeadTable = (rows: LeadRow[], emptyText: string) => (
<div className="overflow-auto rounded-lg border border-gray-700/30">
<table className="w-full text-sm">
<thead className="bg-[#0a1628] text-gray-400">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={5} className="text-center py-10 text-gray-500">{emptyText}</td></tr>
) : rows.map((row) => (
<tr key={row.id} className="border-t border-gray-700/30">
<td className="px-4 py-3 text-white">{row.userNickname || row.userId}</td>
<td className="px-4 py-3 text-gray-300">{row.matchType}</td>
<td className="px-4 py-3 text-green-400">{row.phone || '—'}</td>
<td className="px-4 py-3 text-blue-400">{row.wechatId || '—'}</td>
<td className="px-4 py-3 text-gray-400">{row.createdAt ? new Date(row.createdAt).toLocaleString() : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
return (
<Card className="bg-[#0f2137] border-orange-500/30 mb-6">
<CardContent className="p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="text-white font-semibold"></h3>
<Badge className="bg-orange-500/20 text-orange-400 border-0 text-xs">CKB</Badge>
<button type="button" onClick={() => setActiveTab('doc')} className="text-orange-400/60 text-xs hover:text-orange-400 flex items-center gap-1">
<ExternalLink className="w-3 h-3" /> API
</button>
</div>
<div className="flex items-center gap-2">
<Button onClick={() => loadWorkspace()} variant="outline" size="sm" className="border-gray-700 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />
</Button>
<Button onClick={saveConfig} disabled={isSaving} size="sm" className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Save className="w-3.5 h-3.5 mr-1" /> {isSaving ? '保存中...' : '保存配置'}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-5">
{[
['overview', '概览'],
['submitted', '已提交线索'],
['contact', '有联系方式'],
['config', '场景配置'],
['test', '接口测试'],
['doc', 'API 文档'],
].map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => setActiveTab(id as WorkspaceTab)}
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
activeTab === id ? 'bg-orange-500 text-white' : 'bg-[#0a1628] text-gray-400 hover:text-white'
}`}
>
{label}
</button>
))}
</div>
{activeTab === 'overview' && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
<p className="text-gray-400 text-xs mb-2">线</p>
<p className="text-3xl font-bold text-white">{submittedLeads.length}</p>
</div>
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
<p className="text-gray-400 text-xs mb-2"></p>
<p className="text-3xl font-bold text-white">{contactLeads.length}</p>
</div>
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
<p className="text-gray-400 text-xs mb-2"></p>
<p className="text-3xl font-bold text-white">{routeDefs.length}</p>
</div>
<div className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-5">
<p className="text-gray-400 text-xs mb-2"></p>
<p className="text-sm text-gray-300 line-clamp-3">{docNotes || '未填写'}</p>
</div>
</div>
)}
{activeTab === 'submitted' && renderLeadTable(submittedLeads, '暂无已提交线索')}
{activeTab === 'contact' && renderLeadTable(contactLeads, '暂无有联系方式线索')}
{activeTab === 'config' && (
<div className="space-y-4">
{routeDefs.map((item) => (
<div key={item.key} className="bg-[#0a1628] border border-gray-700/30 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-white font-medium">{item.label}</h4>
<Badge className="bg-orange-500/20 text-orange-300 border-0 text-xs">{item.key}</Badge>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-gray-500 text-xs">API </Label>
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].apiUrl} onChange={(e) => updateRoute(item.key, { apiUrl: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-500 text-xs">API Key</Label>
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].apiKey} onChange={(e) => updateRoute(item.key, { apiKey: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-500 text-xs">Source</Label>
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].source} onChange={(e) => updateRoute(item.key, { source: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-500 text-xs">Tags</Label>
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].tags} onChange={(e) => updateRoute(item.key, { tags: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-500 text-xs">SiteTags</Label>
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].siteTags} onChange={(e) => updateRoute(item.key, { siteTags: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-gray-500 text-xs"></Label>
<Input className="bg-[#0f2137] border-gray-700 text-white h-9 text-sm" value={routeMap[item.key].notes} onChange={(e) => updateRoute(item.key, { notes: e.target.value })} />
</div>
</div>
</div>
))}
</div>
)}
{activeTab === 'test' && (
<>
<div className="flex gap-3 mb-4">
<div className="flex items-center gap-2 flex-1">
<Smartphone className="w-4 h-4 text-gray-500 shrink-0" />
<div className="flex-1">
<Label className="text-gray-500 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm mt-0.5" value={testPhone} onChange={(e) => setTestPhone(e.target.value)} />
</div>
</div>
<div className="flex items-center gap-2 flex-1">
<span className="text-gray-500 text-sm shrink-0">💬</span>
<div className="flex-1">
<Label className="text-gray-500 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm mt-0.5" value={testWechat} onChange={(e) => setTestWechat(e.target.value)} />
</div>
</div>
<div className="flex items-end">
<Button onClick={testAll} className="bg-orange-500 hover:bg-orange-600 text-white">
<Zap className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2">
{tests.map((t, idx) => (
<div key={`${t.endpoint}-${idx}`} className="flex items-center justify-between bg-[#0a1628] rounded-lg px-3 py-2 border border-gray-700/30">
<div className="flex items-center gap-2 min-w-0">
{t.status === 'idle' && <div className="w-2 h-2 rounded-full bg-gray-600 shrink-0" />}
{t.status === 'testing' && <RefreshCw className="w-3 h-3 text-yellow-400 animate-spin shrink-0" />}
{t.status === 'success' && <CheckCircle2 className="w-3 h-3 text-green-400 shrink-0" />}
{t.status === 'error' && <XCircle className="w-3 h-3 text-red-400 shrink-0" />}
<span className="text-white text-xs truncate">{t.label}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{t.responseTime !== undefined && <span className="text-gray-600 text-[10px]">{t.responseTime}ms</span>}
<button type="button" onClick={() => testOne(idx)} disabled={t.status === 'testing'} className="text-orange-400/60 hover:text-orange-400 text-[10px] disabled:opacity-50"></button>
</div>
</div>
))}
</div>
</>
)}
{activeTab === 'doc' && (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-white text-sm font-medium"> API </h4>
<a href="https://ckbapi.quwanzhi.com/v1/api/scenarios" target="_blank" rel="noreferrer" className="text-orange-400/70 hover:text-orange-400 text-xs flex items-center gap-1">
<ExternalLink className="w-3 h-3" />
</a>
</div>
<pre className="whitespace-pre-wrap text-xs text-gray-400 leading-6">{docContent || defaultDoc}</pre>
</div>
<div className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<h4 className="text-white text-sm font-medium mb-3"></h4>
<textarea
className="w-full min-h-[260px] bg-[#0f2137] border border-gray-700 rounded-md text-sm text-gray-300 p-3 outline-none focus:border-orange-500/50 resize-y"
value={docNotes}
onChange={(e) => setDocNotes(e.target.value)}
placeholder="记录 Token、入口差异、回复率统计规则、对接约定等。"
/>
</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Card, CardContent } from '@/components/ui/card'
import {
Users, Zap, Link2, ExternalLink,
} from 'lucide-react'
import { get } from '@/api/client'
interface MatchStats {
totalMatches: number
todayMatches: number
byType: { matchType: string; count: number }[]
uniqueUsers: number
matchRevenue?: number
paidMatchCount?: number
}
interface CKBPlanStats {
ckbTotal: number
withContact: number
byType: { matchType: string; total: number }[]
ckbApiKey: string
ckbApiUrl: string
}
const typeLabels: Record<string, string> = { partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募' }
const typeIcons: Record<string, string> = { partner: '⭐', investor: '👥', mentor: '❤️', team: '🎮' }
interface Props {
onSwitchTab?: (tabId: string) => void
onOpenCKB?: (tab?: string) => void
}
export function CKBStatsTab({ onSwitchTab, onOpenCKB }: Props = {}) {
const navigate = useNavigate()
const [stats, setStats] = useState<MatchStats | null>(null)
const [ckbStats, setCkbStats] = useState<CKBPlanStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const loadStats = useCallback(async () => {
setIsLoading(true)
try {
const [statsRes, ckbRes] = await Promise.allSettled([
get<{ success?: boolean; data?: MatchStats }>('/api/db/match-records?stats=true'),
get<{ success?: boolean; data?: CKBPlanStats }>('/api/db/ckb-plan-stats'),
])
if (statsRes.status === 'fulfilled' && statsRes.value?.success && statsRes.value.data) {
let result = statsRes.value.data
if (result.totalMatches > 0 && (!result.uniqueUsers || result.uniqueUsers === 0)) {
try {
const allRec = await get<{ success?: boolean; records?: { userId: string }[]; total?: number }>('/api/db/match-records?page=1&pageSize=200')
if (allRec?.success && allRec.records) {
const userSet = new Set(allRec.records.map(r => r.userId).filter(Boolean))
result = { ...result, uniqueUsers: userSet.size }
}
} catch { /* fallback */ }
}
setStats(result)
}
if (ckbRes.status === 'fulfilled' && ckbRes.value?.success && ckbRes.value.data) {
setCkbStats(ckbRes.value.data)
}
} catch (e) { console.error('加载统计失败:', e) }
finally { setIsLoading(false) }
}, [])
useEffect(() => { loadStats() }, [loadStats])
const v = (n: number | undefined) => isLoading ? '—' : String(n ?? 0)
return (
<div className="space-y-8">
{/* ===== 区块一:找伙伴核心数据 ===== */}
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5">
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-[#38bdac]/60 transition-all" onClick={() => onSwitchTab?.('partner')}>
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-4xl font-bold text-white">{v(stats?.totalMatches)}</p>
<p className="text-[#38bdac] text-xs mt-3 flex items-center gap-1"><ExternalLink className="w-3 h-3" /> </p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-yellow-500/60 transition-all" onClick={() => onSwitchTab?.('partner')}>
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-4xl font-bold text-white">{v(stats?.todayMatches)}</p>
<p className="text-yellow-400/60 text-xs mt-3 flex items-center gap-1"><Zap className="w-3 h-3" /> </p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-[#0f2137] to-[#162d4a] border-gray-700/40 cursor-pointer hover:border-blue-500/60 transition-all" onClick={() => navigate('/users')}>
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-4xl font-bold text-white">{v(stats?.uniqueUsers)}</p>
<p className="text-blue-400/60 text-xs mt-3 flex items-center gap-1"><ExternalLink className="w-3 h-3" /> </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/40">
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (stats?.uniqueUsers ? (stats.totalMatches / stats.uniqueUsers).toFixed(1) : '0')}</p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/40">
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-3xl font-bold text-white">{v(stats?.paidMatchCount)}</p>
</CardContent>
</Card>
</div>
</div>
{/* 类型分布 */}
{stats?.byType && stats.byType.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-white mb-4"></h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.byType.map(item => {
const pct = stats.totalMatches > 0 ? ((item.count / stats.totalMatches) * 100) : 0
return (
<div key={item.matchType} className="bg-[#0f2137] border border-gray-700/40 rounded-xl p-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-2xl">{typeIcons[item.matchType] || '📊'}</span>
<span className="text-gray-300 font-medium">{typeLabels[item.matchType] || item.matchType}</span>
</div>
<p className="text-3xl font-bold text-white mb-2">{item.count}</p>
<div className="w-full h-2 bg-gray-700/50 rounded-full overflow-hidden">
<div className="h-full bg-[#38bdac] rounded-full transition-all" style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<p className="text-gray-500 text-xs mt-1.5">{pct.toFixed(1)}%</p>
</div>
)
})}
</div>
</div>
)}
{/* ===== 区块二AI 获客数据 ===== */}
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Link2 className="w-5 h-5 text-orange-400" /> AI
</h3>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-5 mb-6">
<Card className="bg-[#0f2137] border-orange-500/20 cursor-pointer hover:border-orange-500/50 transition-colors" onClick={() => onOpenCKB?.('submitted')}>
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2">线</p>
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (ckbStats?.ckbTotal ?? 0)}</p>
<p className="text-orange-400/60 text-xs mt-2"> </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-orange-500/20 cursor-pointer hover:border-orange-500/50 transition-colors" onClick={() => onOpenCKB?.('contact')}>
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2"></p>
<p className="text-3xl font-bold text-white">{isLoading ? '—' : (ckbStats?.withContact ?? 0)}</p>
<p className="text-orange-400/60 text-xs mt-2"> </p>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-orange-500/20 cursor-pointer hover:border-orange-500/50 transition-colors" onClick={() => onOpenCKB?.('test')}>
<CardContent className="p-6">
<p className="text-gray-400 text-sm mb-2">AI </p>
<p className="text-xl font-bold text-orange-400"> </p>
<p className="text-gray-500 text-xs mt-2"> · · API </p>
</CardContent>
</Card>
</div>
{/* CKB 各类型提交统计 */}
{ckbStats?.byType && ckbStats.byType.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{ckbStats.byType.map(item => (
<div key={item.matchType} className="bg-[#0a1628] border border-gray-700/30 rounded-lg p-4 flex items-center gap-3">
<span className="text-xl">{typeIcons[item.matchType] || '📋'}</span>
<div>
<p className="text-gray-400 text-xs">{typeLabels[item.matchType] || item.matchType}</p>
<p className="text-xl font-bold text-white">{item.total}</p>
</div>
</div>
))}
</div>
)}
</div>
{/* 接口联通测试已移到右上角「存客宝」按钮面板 */}
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useState } from 'react'
import { MatchPoolTab } from './MatchPoolTab'
import { MatchRecordsTab } from './MatchRecordsTab'
export function FindPartnerTab() {
const [subTab, setSubTab] = useState<'records' | 'pool'>('records')
return (
<div className="space-y-4">
<div className="flex gap-2">
<button type="button" onClick={() => setSubTab('records')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'records' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
</button>
<button type="button" onClick={() => setSubTab('pool')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'pool' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
</button>
</div>
{subTab === 'records' && <MatchRecordsTab />}
{subTab === 'pool' && <MatchPoolTab />}
</div>
)
}

View File

@@ -0,0 +1,359 @@
import toast from '@/utils/toast'
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 { Save, RefreshCw, Edit3, Plus, Trash2, Users, Zap, Filter } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
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 PoolSettings {
poolSource: string[]
requirePhone: boolean
requireNickname: boolean
requireAvatar: boolean
requireBusiness: boolean
}
interface MatchConfig {
matchTypes: MatchType[]; freeMatchLimit: number; matchPrice: number
settings: { enableFreeMatches: boolean; enablePaidMatches: boolean; maxMatchesPerDay: number }
poolSettings?: PoolSettings
}
const DEFAULT_POOL: PoolSettings = {
poolSource: ['vip'], requirePhone: true, requireNickname: true, requireAvatar: false, requireBusiness: false,
}
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 },
poolSettings: DEFAULT_POOL,
}
const ICONS = ['⭐', '👥', '❤️', '🎮', '💼', '🚀', '💡', '🎯', '🔥', '✨']
interface PoolCounts {
vip: number; complete: number; all: number
}
export function MatchPoolTab() {
const navigate = useNavigate()
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 [poolCounts, setPoolCounts] = useState<PoolCounts | null>(null)
const [poolCountsLoading, setPoolCountsLoading] = useState(false)
const loadPoolCounts = async () => {
setPoolCountsLoading(true)
try {
const data = await get<{ success?: boolean; data?: PoolCounts }>('/api/db/match-pool-counts')
if (data?.success && data.data) setPoolCounts(data.data)
} catch (e) { console.error('加载池子人数失败:', e) }
finally { setPoolCountsLoading(false) }
}
const loadConfig = async () => {
setIsLoading(true)
try {
const data = await get<{ success?: boolean; data?: MatchConfig; config?: MatchConfig }>('/api/db/config/full?key=match_config')
const c = (data as { data?: MatchConfig })?.data ?? (data as { config?: MatchConfig })?.config
if (c) {
let ps = c.poolSettings ?? DEFAULT_POOL
if (ps.poolSource && !Array.isArray(ps.poolSource)) {
ps = { ...ps, poolSource: [ps.poolSource as unknown as string] }
}
setConfig({ ...DEFAULT_CONFIG, ...c, poolSettings: ps })
}
} catch (e) { console.error('加载匹配配置失败:', e) }
finally { setIsLoading(false) }
}
useEffect(() => { loadConfig(); loadPoolCounts() }, [])
const handleSave = async () => {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', { key: 'match_config', value: config, description: '匹配功能配置' })
toast.error(res?.success !== false ? '配置保存成功!' : '保存失败: ' + (res?.error || '未知错误'))
} catch (e) { console.error(e); toast.error('保存失败') }
finally { setIsSaving(false) }
}
const handleEditType = (type: MatchType) => {
setEditingType(type)
setFormData({ ...type })
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) { toast.error('请填写类型ID和名称'); return }
const newTypes = [...config.matchTypes]
if (editingType) {
const idx = newTypes.findIndex(t => t.id === editingType.id)
if (idx !== -1) newTypes[idx] = { ...formData }
} else {
if (newTypes.some(t => t.id === formData.id)) { toast.error('类型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="space-y-6">
<div className="flex justify-end 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>
{/* 匹配池选择 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2"><Filter className="w-5 h-5 text-blue-400" /> </CardTitle>
<CardDescription className="text-gray-400"></CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-3">
<Label className="text-gray-300"></Label>
<p className="text-gray-500 text-xs"></p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{([
{ value: 'vip', label: '超级个体VIP会员', desc: '付费 ¥1980 的VIP会员', icon: '👑', countKey: 'vip' as const },
{ value: 'complete', label: '完善资料用户', desc: '符合下方完善度要求的用户', icon: '✅', countKey: 'complete' as const },
{ value: 'all', label: '全部用户', desc: '所有已注册用户', icon: '👥', countKey: 'all' as const },
]).map(opt => {
const ps = config.poolSettings ?? DEFAULT_POOL
const sources = Array.isArray(ps.poolSource) ? ps.poolSource : [ps.poolSource]
const selected = sources.includes(opt.value)
const count = poolCounts?.[opt.countKey]
const toggleSource = () => {
const cur = Array.isArray(ps.poolSource) ? [...ps.poolSource] : [ps.poolSource as string]
const next = selected ? cur.filter(v => v !== opt.value) : [...cur, opt.value]
if (next.length === 0) next.push(opt.value)
setConfig({ ...config, poolSettings: { ...ps, poolSource: next } })
}
return (
<button key={opt.value} type="button" onClick={toggleSource}
className={`p-4 rounded-lg border text-left transition-all ${selected ? 'border-[#38bdac] bg-[#38bdac]/10' : 'border-gray-700 bg-[#0a1628] hover:border-gray-600'}`}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center text-xs ${selected ? 'border-[#38bdac] bg-[#38bdac] text-white' : 'border-gray-600'}`}>
{selected && '✓'}
</div>
<span className="text-xl">{opt.icon}</span>
<span className={`text-sm font-medium ${selected ? 'text-[#38bdac]' : 'text-gray-300'}`}>{opt.label}</span>
</div>
<span className="text-lg font-bold text-white">
{poolCountsLoading ? '...' : (count ?? '-')}
<span className="text-xs text-gray-500 font-normal ml-1"></span>
</span>
</div>
<p className="text-gray-500 text-xs mt-2">{opt.desc}</p>
<span role="link" tabIndex={0}
onClick={e => { e.stopPropagation(); navigate(`/users?pool=${opt.value}`) }}
onKeyDown={e => { if (e.key === 'Enter') { e.stopPropagation(); navigate(`/users?pool=${opt.value}`) } }}
className="text-[#38bdac] text-xs mt-2 inline-block hover:underline cursor-pointer">
</span>
</button>
)
})}
</div>
</div>
<div className="space-y-3 pt-4 border-t border-gray-700/50">
<Label className="text-gray-300"></Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{([
{ key: 'requirePhone' as const, label: '有手机号', icon: '📱' },
{ key: 'requireNickname' as const, label: '有昵称', icon: '👤' },
{ key: 'requireAvatar' as const, label: '有头像', icon: '🖼️' },
{ key: 'requireBusiness' as const, label: '有业务需求', icon: '💼' },
]).map(item => {
const ps = config.poolSettings ?? DEFAULT_POOL
const checked = ps[item.key]
return (
<div key={item.key} className="flex items-center gap-3 bg-[#0a1628] rounded-lg p-3">
<Switch checked={checked} onCheckedChange={v => setConfig({ ...config, poolSettings: { ...(config.poolSettings ?? DEFAULT_POOL), [item.key]: v } })} />
<div className="flex items-center gap-1.5">
<span>{item.icon}</span>
<Label className="text-gray-300 text-sm">{item.label}</Label>
</div>
</div>
)
})}
</div>
</div>
</CardContent>
</Card>
<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 })} />
</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 })} />
</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 } })} />
</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,141 @@
import { useState, useEffect } from 'react'
import { normalizeImageUrl } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card'
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
userAvatar?: string; matchedUserAvatar?: string; matchScore?: number; createdAt: string
}
const matchTypeLabels: Record<string, string> = {
partner: '找伙伴', investor: '资源对接', mentor: '导师顾问', team: '团队招募',
}
export function MatchRecordsTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [matchTypeFilter, setMatchTypeFilter] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [detailUserId, setDetailUserId] = useState<string | null>(null)
async function loadRecords() {
setIsLoading(true); setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (matchTypeFilter) params.set('matchType', matchTypeFilter)
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
else setError('加载匹配记录失败')
} catch { setError('加载失败,请检查网络后重试') }
finally { setIsLoading(false) }
}
useEffect(() => { loadRecords() }, [page, matchTypeFilter])
const totalPages = Math.ceil(total / pageSize) || 1
const UserCell = ({ userId, nickname, avatar }: { userId: string; nickname?: string; avatar?: string }) => (
<div className="flex items-center gap-3 cursor-pointer group" onClick={() => setDetailUserId(userId)}>
<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 overflow-hidden">
{avatar ? <img src={normalizeImageUrl(avatar)} alt="" className="w-full h-full object-cover" onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none' }} /> : null}
<span className={avatar ? 'hidden' : ''}>{(nickname || userId || '?').charAt(0)}</span>
</div>
<div>
<div className="text-white group-hover:text-[#38bdac] transition-colors">{nickname || userId}</div>
<div className="text-xs text-gray-500 font-mono">{userId?.slice(0, 16)}{userId?.length > 16 ? '...' : ''}</div>
</div>
</div>
)
return (
<div>
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">×</button>
</div>
)}
<div className="flex justify-between items-center mb-4">
<p className="text-gray-400"> {total} · </p>
<div className="flex items-center gap-4">
<select value={matchTypeFilter} onChange={e => { setMatchTypeFilter(e.target.value); setPage(1) }}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm">
<option value=""></option>
{Object.entries(matchTypeLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<button type="button" onClick={loadRecords} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex 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>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<UserCell userId={r.userId} nickname={r.userNickname} avatar={r.userAvatar} />
</TableCell>
<TableCell>
{r.matchedUserId ? (
<UserCell userId={r.matchedUserId} nickname={r.matchedNickname} avatar={r.matchedUserAvatar} />
) : (
<span className="text-gray-500"></span>
)}
</TableCell>
<TableCell><Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">{matchTypeLabels[r.matchType] || r.matchType}</Badge></TableCell>
<TableCell className="text-sm">
{r.phone && <div className="text-green-400">📱 {r.phone}</div>}
{r.wechatId && <div className="text-blue-400">💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && <span className="text-gray-600">-</span>}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={5} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize}
onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
<UserDetailModal
open={!!detailUserId}
onClose={() => setDetailUserId(null)}
userId={detailUserId}
onUserUpdated={loadRecords}
/>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { RefreshCw } from 'lucide-react'
import { get } from '@/api/client'
interface Consultation {
id: number; userId: number; mentorId: number; consultationType: string
amount: number; status: string; createdAt: string
}
const statusMap: Record<string, string> = { created: '已创建', pending_pay: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消' }
const typeMap: Record<string, string> = { single: '单次', half_year: '半年', year: '年度' }
export function MentorBookingTab() {
const [list, setList] = useState<Consultation[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
async function load() {
setLoading(true)
try {
const url = statusFilter ? `/api/db/mentor-consultations?status=${statusFilter}` : '/api/db/mentor-consultations'
const data = await get<{ success?: boolean; data?: Consultation[] }>(url)
if (data?.success && data.data) setList(data.data)
} catch (e) { console.error(e) }
finally { setLoading(false) }
}
useEffect(() => { load() }, [statusFilter])
return (
<div>
<div className="flex justify-between items-center mb-4">
<p className="text-gray-400"></p>
<div className="flex items-center gap-2">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="bg-[#0f2137] border border-gray-700 rounded-lg px-3 py-2 text-gray-300 text-sm">
<option value=""></option>
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<Button onClick={load} disabled={loading} variant="outline" 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>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</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>
</TableRow>
</TableHeader>
<TableBody>
{list.map(r => (
<TableRow key={r.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{r.id}</TableCell>
<TableCell className="text-gray-400">{r.userId}</TableCell>
<TableCell className="text-gray-400">{r.mentorId}</TableCell>
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
<TableCell className="text-white">¥{r.amount}</TableCell>
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
<TableCell className="text-gray-500 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{list.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,28 @@
import { useState } from 'react'
import { MentorBookingTab } from './MentorBookingTab'
import { MentorsPage } from '@/pages/mentors/MentorsPage'
export function MentorTab() {
const [subTab, setSubTab] = useState<'booking' | 'manage'>('booking')
return (
<div className="space-y-4">
<div className="flex gap-2">
<button type="button" onClick={() => setSubTab('booking')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'booking' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
</button>
<button type="button" onClick={() => setSubTab('manage')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${subTab === 'manage' ? 'bg-[#38bdac]/20 text-[#38bdac] border border-[#38bdac]/50' : 'bg-[#0a1628] text-gray-400 border border-gray-700 hover:text-white'}`}>
</button>
</div>
{subTab === 'booking' && <MentorBookingTab />}
{subTab === 'manage' && (
<div className="-mx-8">
<MentorsPage embedded />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,140 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { RefreshCw, Send } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get, post } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
createdAt: string; ckbStatus?: string
}
const typeLabels: Record<string, string> = {
investor: '资源对接', mentor: '导师顾问', team: '团队招募',
}
export function ResourceDockingTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [isLoading, setIsLoading] = useState(true)
const [typeFilter, setTypeFilter] = useState('investor')
const [pushingId, setPushingId] = useState<string | null>(null)
async function load() {
setIsLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: typeFilter })
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
} catch (e) { console.error(e) }
finally { setIsLoading(false) }
}
useEffect(() => { load() }, [page, typeFilter])
const pushToCKB = async (record: MatchRecord) => {
if (!record.phone && !record.wechatId) {
toast.info('该记录无联系方式,无法推送到存客宝')
return
}
setPushingId(record.id)
try {
const res = await post<{ success?: boolean; message?: string }>('/api/ckb/join', {
type: record.matchType || 'investor',
phone: record.phone || '',
wechat: record.wechatId || '',
userId: record.userId,
name: record.userNickname || '',
})
toast.error(res?.message || (res?.success ? '推送成功' : '推送失败'))
} catch (e) {
toast.error('推送失败: ' + (e instanceof Error ? e.message : '网络错误'))
} finally {
setPushingId(null)
}
}
const totalPages = Math.ceil(total / pageSize) || 1
const hasContact = (r: MatchRecord) => !!(r.phone || r.wechatId)
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-gray-400">/</p>
<p className="text-gray-500 text-xs mt-1"> {total} </p>
</div>
<div className="flex items-center gap-2">
<select value={typeFilter} onChange={e => { setTypeFilter(e.target.value); setPage(1) }}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm">
{Object.entries(typeLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<Button onClick={load} disabled={isLoading} variant="outline" 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>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex 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 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className={`border-gray-700/50 ${hasContact(r) ? 'hover:bg-[#0a1628]' : 'opacity-60'}`}>
<TableCell className="text-white">{r.userNickname || r.userId?.slice(0, 12)}</TableCell>
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId?.slice(0, 12)}</TableCell>
<TableCell>
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">{typeLabels[r.matchType] || r.matchType}</Badge>
</TableCell>
<TableCell className="text-sm">
{r.phone && <div className="text-green-400">📱 {r.phone}</div>}
{r.wechatId && <div className="text-blue-400">💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && <span className="text-gray-600"></span>}
</TableCell>
<TableCell className="text-gray-400 text-sm">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
<TableCell className="text-right">
{hasContact(r) ? (
<Button size="sm" onClick={() => pushToCKB(r)} disabled={pushingId === r.id}
className="bg-[#38bdac] hover:bg-[#2da396] text-white text-xs h-7 px-3">
<Send className="w-3 h-3 mr-1" />
{pushingId === r.id ? '推送中...' : '推送CKB'}
</Button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={6} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string; userId: string; matchedUserId: string; matchType: string
phone?: string; wechatId?: string; userNickname?: string; matchedNickname?: string
createdAt: string
}
export function TeamRecruitTab() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [isLoading, setIsLoading] = useState(true)
async function load() {
setIsLoading(true)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), matchType: 'team' })
const data = await get<{ success?: boolean; records?: MatchRecord[]; total?: number }>(`/api/db/match-records?${params}`)
if (data?.success) { setRecords(data.records || []); setTotal(data.total ?? 0) }
} catch (e) { console.error(e) }
finally { setIsLoading(false) }
}
useEffect(() => { load() }, [page])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div>
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-gray-400"> {total} </p>
<p className="text-gray-500 text-xs mt-1"></p>
</div>
<button type="button" onClick={load} disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50">
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex 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>
</TableRow>
</TableHeader>
<TableBody>
{records.map(r => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell className="text-white">{r.userNickname || r.userId}</TableCell>
<TableCell className="text-white">{r.matchedNickname || r.matchedUserId}</TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{records.length === 0 && <TableRow><TableCell colSpan={4} className="text-center py-12 text-gray-500"></TableCell></TableRow>}
</TableBody>
</Table>
<Pagination page={page} totalPages={totalPages} total={total} pageSize={pageSize} onPageChange={setPage} onPageSizeChange={n => { setPageSize(n); setPage(1) }} />
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,282 @@
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Smartphone, Plus, Pencil, Trash2 } from 'lucide-react'
import { get, post, put, del } from '@/api/client'
interface LinkedMpItem {
key: string
name: string
appId: string
path?: string
sort?: number
}
export function LinkedMpPage() {
const [list, setList] = useState<LinkedMpItem[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<LinkedMpItem | null>(null)
const [form, setForm] = useState({ name: '', appId: '', path: '', sort: 0 })
const [saving, setSaving] = useState(false)
async function loadList() {
setLoading(true)
try {
const res = await get<{ success?: boolean; data?: LinkedMpItem[] }>(
'/api/admin/linked-miniprograms',
)
if (res?.success && Array.isArray(res.data)) {
const sorted = [...res.data].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
setList(sorted)
}
} catch (e) {
console.error('Load linked miniprograms error:', e)
toast.error('加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadList()
}, [])
function openAdd() {
setEditing(null)
setForm({ name: '', appId: '', path: '', sort: list.length })
setModalOpen(true)
}
function openEdit(item: LinkedMpItem) {
setEditing(item)
setForm({
name: item.name,
appId: item.appId,
path: item.path ?? '',
sort: item.sort ?? 0,
})
setModalOpen(true)
}
async function handleSave() {
const name = form.name.trim()
const appId = form.appId.trim()
if (!name || !appId) {
toast.error('请填写小程序名称和 AppID')
return
}
setSaving(true)
try {
if (editing) {
const res = await put<{ success?: boolean; error?: string }>(
'/api/admin/linked-miniprograms',
{ key: editing.key, name, appId, path: form.path.trim(), sort: form.sort },
)
if (res?.success) {
toast.success('已更新')
setModalOpen(false)
loadList()
} else {
toast.error(res?.error ?? '更新失败')
}
} else {
const res = await post<{ success?: boolean; error?: string }>(
'/api/admin/linked-miniprograms',
{ name, appId, path: form.path.trim(), sort: form.sort },
)
if (res?.success) {
toast.success('已添加')
setModalOpen(false)
loadList()
} else {
toast.error(res?.error ?? '添加失败')
}
}
} catch (e) {
toast.error('操作失败')
} finally {
setSaving(false)
}
}
async function handleDelete(item: LinkedMpItem) {
if (!confirm(`确定要删除「${item.name}」吗?`)) return
try {
const res = await del<{ success?: boolean; error?: string }>(
`/api/admin/linked-miniprograms/${item.key}`,
)
if (res?.success) {
toast.success('已删除')
loadList()
} else {
toast.error(res?.error ?? '删除失败')
}
} catch (e) {
toast.error('删除失败')
}
}
return (
<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">
<Smartphone className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
32 # appId app.json navigateToMiniProgramAppIdList AppID
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<Button
onClick={openAdd}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">AppID</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 w-24"></TableHead>
<TableHead className="text-gray-400 w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{list.map((item) => (
<TableRow key={item.key} className="border-gray-700/50">
<TableCell className="text-white">{item.name}</TableCell>
<TableCell className="text-gray-300 font-mono text-xs">{item.key}</TableCell>
<TableCell className="text-gray-300 font-mono text-sm">{item.appId}</TableCell>
<TableCell className="text-gray-400 text-sm">{item.path || '—'}</TableCell>
<TableCell className="text-gray-300">{item.sort ?? 0}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="text-[#38bdac] hover:bg-[#38bdac]/20"
onClick={() => openEdit(item)}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:bg-red-500/20"
onClick={() => handleDelete(item)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{list.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md p-4 gap-3">
<DialogHeader className="gap-1">
<DialogTitle className="text-base">{editing ? '编辑关联小程序' : '添加关联小程序'}</DialogTitle>
<DialogDescription className="text-gray-400 text-xs">
AppID
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
placeholder="例如Soul 创业派对"
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-300 text-sm">AppID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white font-mono h-8 text-sm"
placeholder="例如wxb8bbb2b10dec74aa"
value={form.appId}
onChange={(e) => setForm((p) => ({ ...p, appId: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
placeholder="例如pages/index/index"
value={form.path}
onChange={(e) => setForm((p) => ({ ...p, path: e.target.value }))}
/>
</div>
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm w-20"
value={form.sort}
onChange={(e) =>
setForm((p) => ({ ...p, sort: parseInt(e.target.value, 10) || 0 }))
}
/>
</div>
</div>
<DialogFooter className="gap-2 pt-1">
<Button variant="outline" onClick={() => setModalOpen(false)} className="border-gray-600">
</Button>
<Button
onClick={handleSave}
disabled={saving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,123 @@
import { useEffect, 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'
import { getAdminToken, setAdminToken } from '@/api/auth'
export function LoginPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (getAdminToken()) {
navigate('/dashboard', { replace: true })
}
}, [navigate])
const handleLogin = async () => {
setError('')
setLoading(true)
try {
const data = await post<{
success?: boolean
error?: string
token?: string
}>('/api/admin', {
username: username.trim(),
password,
})
if (data?.success !== false && data?.token) {
setAdminToken(data.token)
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)
if (error) setError('')
}}
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)
if (error) setError('')
}}
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,235 @@
import { useState, useEffect } from 'react'
import { normalizeImageUrl } from '@/lib/utils'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { RefreshCw } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get } from '@/api/client'
interface MatchRecord {
id: string
userId: string
matchedUserId: string
matchType: string
phone?: string
wechatId?: string
userNickname?: string
matchedNickname?: string
userAvatar?: string
matchedUserAvatar?: string
matchScore?: number
createdAt: string
}
const matchTypeLabels: Record<string, string> = {
partner: '找伙伴',
investor: '资源对接',
mentor: '导师顾问',
team: '团队招募',
}
export function MatchRecordsPage() {
const [records, setRecords] = useState<MatchRecord[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [matchTypeFilter, setMatchTypeFilter] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadRecords() {
setIsLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (matchTypeFilter) params.set('matchType', matchTypeFilter)
const data = await get<{
success?: boolean
records?: MatchRecord[]
total?: number
}>(`/api/db/match-records?${params}`)
if (data?.success) {
setRecords(data.records || [])
setTotal(data.total ?? 0)
} else {
setError('加载匹配记录失败')
}
} catch (e) {
console.error('加载匹配记录失败', e)
setError('加载失败,请检查网络后重试')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadRecords()
}, [page, matchTypeFilter])
const totalPages = Math.ceil(total / pageSize) || 1
return (
<div className="p-8 w-full">
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
×
</button>
</div>
)}
<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"> {total} </p>
</div>
<div className="flex items-center gap-4">
<select
value={matchTypeFilter}
onChange={(e) => {
setMatchTypeFilter(e.target.value)
setPage(1)
}}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
{Object.entries(matchTypeLabels).map(([k, v]) => (
<option key={k} value={k}>
{v}
</option>
))}
</select>
<button
type="button"
onClick={loadRecords}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-600 text-gray-300 hover:bg-gray-700/50 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{isLoading ? (
<div className="flex 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>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell>
<div className="flex items-center gap-3">
<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 overflow-hidden">
{r.userAvatar ? (
<img
src={normalizeImageUrl(r.userAvatar)}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
const fallback = e.currentTarget.nextElementSibling as HTMLElement
if (fallback) fallback.classList.remove('hidden')
}}
/>
) : null}
<span className={r.userAvatar ? 'hidden' : ''}>
{(r.userNickname || r.userId || '?').charAt(0)}
</span>
</div>
<div>
<div className="text-white">{r.userNickname || r.userId}</div>
<div className="text-xs text-gray-500 font-mono">{r.userId.slice(0, 16)}...</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<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 overflow-hidden">
{r.matchedUserAvatar ? (
<img
src={normalizeImageUrl(r.matchedUserAvatar)}
alt=""
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
const fallback = e.currentTarget.nextElementSibling as HTMLElement
if (fallback) fallback.classList.remove('hidden')
}}
/>
) : null}
<span className={r.matchedUserAvatar ? 'hidden' : ''}>
{(r.matchedNickname || r.matchedUserId || '?').charAt(0)}
</span>
</div>
<div>
<div className="text-white">{r.matchedNickname || r.matchedUserId}</div>
<div className="text-xs text-gray-500 font-mono">{r.matchedUserId.slice(0, 16)}...</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">
{matchTypeLabels[r.matchType] || r.matchType}
</Badge>
</TableCell>
<TableCell className="text-gray-400 text-sm">
{r.phone && <div>📱 {r.phone}</div>}
{r.wechatId && <div>💬 {r.wechatId}</div>}
{!r.phone && !r.wechatId && '-'}
</TableCell>
<TableCell className="text-gray-400">
{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}
</TableCell>
</TableRow>
))}
{records.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
}}
/>
</>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,576 @@
import toast from '@/utils/toast'
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/full?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) {
toast.success('配置保存成功!')
} else {
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
} catch (e) {
console.error('保存配置失败:', e)
toast.error('保存失败')
} 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) {
toast.error('请填写类型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)) {
toast.error('类型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 w-full 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,133 @@
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Calendar, RefreshCw } from 'lucide-react'
import { get } from '@/api/client'
import { Button } from '@/components/ui/button'
interface Consultation {
id: number
userId: number
mentorId: number
consultationType: string
amount: number
status: string
createdAt: string
}
export function MentorConsultationsPage() {
const [list, setList] = useState<Consultation[]>([])
const [loading, setLoading] = useState(true)
const [statusFilter, setStatusFilter] = useState('')
async function load() {
setLoading(true)
try {
const url = statusFilter ? `/api/db/mentor-consultations?status=${statusFilter}` : '/api/db/mentor-consultations'
const data = await get<{ success?: boolean; data?: Consultation[] }>(url)
if (data?.success && data.data) setList(data.data)
} catch (e) {
console.error('Load consultations error:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [statusFilter])
const statusMap: Record<string, string> = {
created: '已创建',
pending_pay: '待支付',
paid: '已支付',
completed: '已完成',
cancelled: '已取消',
}
const typeMap: Record<string, string> = {
single: '单次',
half_year: '半年',
year: '年度',
}
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
stitch_soul
</p>
</div>
<div className="flex items-center gap-2">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-[#0f2137] border border-gray-700 rounded-lg px-3 py-2 text-gray-300 text-sm"
>
<option value=""></option>
{Object.entries(statusMap).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<Button onClick={load} disabled={loading} variant="outline" className="border-gray-600 text-gray-300">
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400">ID</TableHead>
<TableHead className="text-gray-400">ID</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>
</TableRow>
</TableHeader>
<TableBody>
{list.map((r) => (
<TableRow key={r.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{r.id}</TableCell>
<TableCell className="text-gray-400">{r.userId}</TableCell>
<TableCell className="text-gray-400">{r.mentorId}</TableCell>
<TableCell className="text-gray-400">{typeMap[r.consultationType] || r.consultationType}</TableCell>
<TableCell className="text-white">¥{r.amount}</TableCell>
<TableCell className="text-gray-400">{statusMap[r.status] || r.status}</TableCell>
<TableCell className="text-gray-500 text-sm">{r.createdAt}</TableCell>
</TableRow>
))}
{list.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,499 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect, useRef } 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Users, Plus, Edit3, Trash2, X, Save, Upload } from 'lucide-react'
import { get, post, put, del, apiUrl } from '@/api/client'
import { getAdminToken } from '@/api/auth'
interface MentorsPageProps {
embedded?: boolean
}
interface Mentor {
id: number
name: string
avatar?: string
intro?: string
tags?: string
priceSingle?: number
priceHalfYear?: number
priceYear?: number
quote?: string
whyFind?: string
offering?: string
judgmentStyle?: string
sort: number
enabled?: boolean
}
export function MentorsPage(_props?: MentorsPageProps & { embedded?: boolean }) {
const [mentors, setMentors] = useState<Mentor[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState<Mentor | null>(null)
const [form, setForm] = useState({
name: '',
avatar: '',
intro: '',
tags: '',
priceSingle: '',
priceHalfYear: '',
priceYear: '',
quote: '',
whyFind: '',
offering: '',
judgmentStyle: '',
sort: 0,
enabled: true,
})
const [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const avatarInputRef = useRef<HTMLInputElement>(null)
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploadingAvatar(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('folder', 'mentors')
const token = getAdminToken()
const headers: HeadersInit = {}
if (token) headers['Authorization'] = `Bearer ${token}`
const res = await fetch(apiUrl('/api/upload'), {
method: 'POST',
body: formData,
credentials: 'include',
headers,
})
const data = await res.json()
if (data?.success && data?.url) {
setForm((f) => ({ ...f, avatar: data.url }))
} else {
toast.error('上传失败: ' + (data?.error || '未知错误'))
}
} catch (err) {
console.error(err)
toast.error('上传失败')
} finally {
setUploadingAvatar(false)
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
}
async function load() {
setLoading(true)
try {
const data = await get<{ success?: boolean; data?: Mentor[] }>('/api/db/mentors')
if (data?.success && data.data) setMentors(data.data)
} catch (e) {
console.error('Load mentors error:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const resetForm = () => {
setForm({
name: '',
avatar: '',
intro: '',
tags: '',
priceSingle: '',
priceHalfYear: '',
priceYear: '',
quote: '',
whyFind: '',
offering: '',
judgmentStyle: '',
sort: mentors.length > 0 ? Math.max(...mentors.map((m) => m.sort)) + 1 : 0,
enabled: true,
})
}
const handleAdd = () => {
setEditing(null)
resetForm()
setShowModal(true)
}
const handleEdit = (m: Mentor) => {
setEditing(m)
setForm({
name: m.name,
avatar: m.avatar || '',
intro: m.intro || '',
tags: m.tags || '',
priceSingle: m.priceSingle != null ? String(m.priceSingle) : '',
priceHalfYear: m.priceHalfYear != null ? String(m.priceHalfYear) : '',
priceYear: m.priceYear != null ? String(m.priceYear) : '',
quote: m.quote || '',
whyFind: m.whyFind || '',
offering: m.offering || '',
judgmentStyle: m.judgmentStyle || '',
sort: m.sort,
enabled: m.enabled ?? true,
})
setShowModal(true)
}
const handleSave = async () => {
if (!form.name.trim()) {
toast.error('导师姓名不能为空')
return
}
setSaving(true)
try {
const num = (s: string) => (s === '' ? undefined : parseFloat(s))
const payload = {
name: form.name.trim(),
avatar: form.avatar.trim() || undefined,
intro: form.intro.trim() || undefined,
tags: form.tags.trim() || undefined,
priceSingle: num(form.priceSingle),
priceHalfYear: num(form.priceHalfYear),
priceYear: num(form.priceYear),
quote: form.quote.trim() || undefined,
whyFind: form.whyFind.trim() || undefined,
offering: form.offering.trim() || undefined,
judgmentStyle: form.judgmentStyle.trim() || undefined,
sort: form.sort,
enabled: form.enabled,
}
if (editing) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/mentors', {
id: editing.id,
...payload,
})
if (data?.success) {
setShowModal(false)
load()
} else {
toast.error('更新失败: ' + (data as { error?: string })?.error)
}
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/mentors', payload)
if (data?.success) {
setShowModal(false)
load()
} else {
toast.error('新增失败: ' + (data as { error?: string })?.error)
}
}
} catch (e) {
console.error('Save error:', e)
toast.error('保存失败')
} finally {
setSaving(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除该导师?')) return
try {
const data = await del<{ success?: boolean; error?: string }>(`/api/db/mentors?id=${id}`)
if (data?.success) load()
else toast.error('删除失败: ' + (data as { error?: string })?.error)
} catch (e) {
console.error('Delete error:', e)
toast.error('删除失败')
}
}
const fmt = (v?: number) => (v != null ? `¥${v}` : '-')
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
</h2>
<p className="text-gray-400 mt-1">
stitch_soul //
</p>
</div>
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<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-gray-400"></TableHead>
<TableHead className="text-right text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mentors.map((m) => (
<TableRow key={m.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{m.id}</TableCell>
<TableCell className="text-white">{m.name}</TableCell>
<TableCell className="text-gray-400 max-w-[200px] truncate">{m.intro || '-'}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceSingle)}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceHalfYear)}</TableCell>
<TableCell className="text-gray-400">{fmt(m.priceYear)}</TableCell>
<TableCell className="text-gray-400">{m.sort}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(m)}
className="text-gray-400 hover:text-[#38bdac]"
>
<Edit3 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(m.id)}
className="text-gray-400 hover:text-red-400"
>
<Trash2 className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
{mentors.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
{editing ? '编辑导师' : '新增导师'}
</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"> *</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:卡若"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: 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={form.sort}
onChange={(e) => setForm((f) => ({ ...f, sort: parseInt(e.target.value, 10) || 0 }))}
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<div className="flex gap-3 items-center">
<Input
className="flex-1 bg-[#0a1628] border-gray-700 text-white"
value={form.avatar}
onChange={(e) => setForm((f) => ({ ...f, avatar: e.target.value }))}
placeholder="点击上传或粘贴图片地址"
/>
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
/>
<Button
type="button"
variant="outline"
size="sm"
className="border-gray-600 text-gray-400 shrink-0"
disabled={uploadingAvatar}
onClick={() => avatarInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
{uploadingAvatar ? '上传中...' : '上传'}
</Button>
</div>
{form.avatar && (
<div className="mt-2">
<img
src={normalizeImageUrl(form.avatar.startsWith('http') ? form.avatar : apiUrl(form.avatar))}
alt="头像预览"
className="w-20 h-20 rounded-full object-cover border border-gray-600"
/>
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:结构判断型咨询 · Decision > Execution"
value={form.intro}
onChange={(e) => setForm((f) => ({ ...f, intro: 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={form.tags}
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
/>
</div>
<div className="border-t border-gray-700 pt-4">
<Label className="text-gray-300 block mb-2"></Label>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="980"
value={form.priceSingle}
onChange={(e) => setForm((f) => ({ ...f, priceSingle: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="19800"
value={form.priceHalfYear}
onChange={(e) => setForm((f) => ({ ...f, priceHalfYear: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-500 text-xs"> ¥</Label>
<Input
type="number"
step="0.01"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="29800"
value={form.priceYear}
onChange={(e) => setForm((f) => ({ ...f, priceYear: e.target.value }))}
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如:大多数人失败,不是因为不努力..."
value={form.quote}
onChange={(e) => setForm((f) => ({ ...f, quote: 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={form.whyFind}
onChange={(e) => setForm((f) => ({ ...f, whyFind: 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={form.offering}
onChange={(e) => setForm((f) => ({ ...f, offering: 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={form.judgmentStyle}
onChange={(e) => setForm((f) => ({ ...f, judgmentStyle: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
className="rounded border-gray-600 bg-[#0a1628]"
/>
<Label htmlFor="enabled" className="text-gray-300 cursor-pointer"></Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowModal(false)}
className="border-gray-600 text-gray-300"
>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,29 @@
import { Link, useLocation } from 'react-router-dom'
import { Home, AlertCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
const location = useLocation()
return (
<div className="min-h-screen bg-[#0a1628] flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full bg-red-500/20 text-red-400 mb-6">
<AlertCircle className="w-10 h-10" />
</div>
<h1 className="text-4xl font-bold text-white mb-2">404</h1>
<p className="text-gray-400 mb-1"></p>
<p className="text-sm text-gray-500 font-mono mb-8 break-all">{location.pathname}</p>
<Button
asChild
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Link to="/">
<Home className="w-4 h-4 mr-2" />
</Link>
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,459 @@
import toast from '@/utils/toast'
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 {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
} from '@/components/ui/dialog'
import { Search, RefreshCw, Download, Filter, Undo2 } from 'lucide-react'
import { useDebounce } from '@/hooks/useDebounce'
import { Pagination } from '@/components/ui/Pagination'
import { get, put } from '@/api/client'
interface Purchase {
id: string
userId: string
type?: 'section' | 'fullbook' | 'match' | 'vip'
sectionId?: string
sectionTitle?: string
productId?: string
amount: number
status: 'pending' | 'completed' | 'failed' | 'paid' | 'created' | 'refunded'
paymentMethod?: string
referrerEarnings?: number
createdAt: string
orderSn?: string
userNickname?: string
productType?: string
description?: string
refundReason?: string
}
interface UsersItem {
id: string
nickname?: string
phone?: string
}
export function OrdersPage() {
const [purchases, setPurchases] = useState<Purchase[]>([])
const [users, setUsers] = useState<UsersItem[]>([])
const [total, setTotal] = useState(0)
const [totalRevenue, setTotalRevenue] = useState(0)
const [todayRevenue, setTodayRevenue] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [searchTerm, setSearchTerm] = useState('')
const debouncedSearch = useDebounce(searchTerm, 300)
const [statusFilter, setStatusFilter] = useState<string>('all')
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refundOrder, setRefundOrder] = useState<Purchase | null>(null)
const [refundReason, setRefundReason] = useState('')
const [refundLoading, setRefundLoading] = useState(false)
async function loadOrders() {
setIsLoading(true)
setError(null)
try {
const statusParam = statusFilter === 'all' ? '' : statusFilter === 'completed' ? 'completed' : statusFilter
const ordersParams = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
...(statusParam && { status: statusParam }),
...(debouncedSearch && { search: debouncedSearch }),
})
const [ordersData, usersData] = await Promise.all([
get<{ success?: boolean; orders?: Purchase[]; total?: number; totalRevenue?: number; todayRevenue?: number }>(
`/api/admin/orders?${ordersParams}`,
),
get<{ success?: boolean; users?: UsersItem[] }>('/api/db/users?page=1&pageSize=500'),
])
if (ordersData?.success) {
setPurchases(ordersData.orders || [])
setTotal(ordersData.total ?? 0)
setTotalRevenue(ordersData.totalRevenue ?? 0)
setTodayRevenue(ordersData.todayRevenue ?? 0)
}
if (usersData?.success && usersData.users) setUsers(usersData.users)
} catch (e) {
console.error('加载订单失败', e)
setError('加载订单失败,请检查网络后重试')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setPage(1)
}, [debouncedSearch, statusFilter])
useEffect(() => {
loadOrders()
}, [page, pageSize, debouncedSearch, statusFilter])
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 === 'vip' || desc.includes('VIP')) {
return { name: 'VIP年度会员', type: 'VIP' }
}
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 === 'vip') return { name: 'VIP年度会员', type: 'VIP' }
if (type === 'match') return { name: '找伙伴匹配', type: '功能' }
return { name: '未知商品', type: type || '其他' }
}
const totalPages = Math.ceil(total / pageSize) || 1
async function handleRefund() {
if (!refundOrder?.orderSn && !refundOrder?.id) return
setRefundLoading(true)
setError(null)
try {
const res = await put<{ success?: boolean; error?: string }>('/api/admin/orders/refund', {
orderSn: refundOrder.orderSn || refundOrder.id,
reason: refundReason || undefined,
})
if (res?.success) {
setRefundOrder(null)
setRefundReason('')
loadOrders()
} else {
setError(res?.error || '退款失败')
}
} catch (e) {
const err = e as Error & { data?: { error?: string } }
setError(err?.data?.error || '退款失败,请检查网络后重试')
} finally {
setRefundLoading(false)
}
}
function handleExport() {
if (purchases.length === 0) {
toast.info('暂无数据可导出')
return
}
const headers = ['订单号', '用户', '手机号', '商品', '金额', '支付方式', '状态', '退款原因', '分销佣金', '下单时间']
const rows = purchases.map((p) => {
const product = formatProduct(p)
return [
p.orderSn || p.id || '',
getUserNickname(p),
getUserPhone(p.userId),
product.name,
Number(p.amount || 0).toFixed(2),
p.paymentMethod === 'wechat' ? '微信支付' : p.paymentMethod === 'alipay' ? '支付宝' : p.paymentMethod || '微信支付',
p.status === 'refunded' ? '已退款' : p.status === 'paid' || p.status === 'completed' ? '已完成' : p.status === 'pending' || p.status === 'created' ? '待支付' : '已失败',
p.status === 'refunded' && p.refundReason ? p.refundReason : '-',
p.referrerEarnings ? Number(p.referrerEarnings).toFixed(2) : '-',
p.createdAt ? new Date(p.createdAt).toLocaleString('zh-CN') : '',
].join(',')
})
const csv = '\uFEFF' + [headers.join(','), ...rows].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `订单列表_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="p-8 w-full">
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
×
</button>
</div>
)}
<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">
<Button
variant="outline"
onClick={loadOrders}
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="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>
<option value="refunded">退</option>
</select>
</div>
<Button
variant="outline"
onClick={handleExport}
disabled={purchases.length === 0}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<Download className="w-4 h-4 mr-2" />
CSV
</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>
) : (
<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>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchases.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 flex items-center gap-2">
{product.name}
{(purchase.productType || purchase.type) === 'vip' && (
<Badge className="bg-amber-500/20 text-amber-400 hover:bg-amber-500/20 border-0 text-xs">
VIP
</Badge>
)}
</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 === 'refunded' ? (
<Badge className="bg-gray-500/20 text-gray-400 hover:bg-gray-500/20 border-0">
退
</Badge>
) : 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-gray-400 text-sm max-w-[120px] truncate" title={purchase.refundReason}>
{purchase.status === 'refunded' && purchase.refundReason ? purchase.refundReason : '-'}
</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>
<TableCell>
{(purchase.status === 'paid' || purchase.status === 'completed') && (
<Button
variant="outline"
size="sm"
className="border-orange-500/50 text-orange-400 hover:bg-orange-500/20"
onClick={() => {
setRefundOrder(purchase)
setRefundReason('')
}}
>
<Undo2 className="w-3 h-3 mr-1" />
退
</Button>
)}
</TableCell>
</TableRow>
)
})}
{purchases.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
}}
/>
</div>
)}
</CardContent>
</Card>
<Dialog open={!!refundOrder} onOpenChange={(open) => !open && setRefundOrder(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white">退</DialogTitle>
</DialogHeader>
{refundOrder && (
<div className="space-y-4">
<p className="text-gray-400 text-sm">
{refundOrder.orderSn || refundOrder.id}
</p>
<p className="text-gray-400 text-sm">
退¥{Number(refundOrder.amount || 0).toFixed(2)}
</p>
<div>
<label className="text-sm text-gray-400 block mb-2">退</label>
<div className="form-input">
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
placeholder="如:用户申请退款"
value={refundReason}
onChange={(e) => setRefundReason(e.target.value)}
/>
</div>
</div>
<p className="text-orange-400/80 text-xs">
退退
</p>
</div>
)}
<DialogFooter>
<Button
variant="outline"
className="border-gray-600 text-gray-300"
onClick={() => setRefundOrder(null)}
disabled={refundLoading}
>
</Button>
<Button
className="bg-orange-500 hover:bg-orange-600 text-white"
onClick={handleRefund}
disabled={refundLoading}
>
{refundLoading ? '退款中...' : '确认退款'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,422 @@
import toast from '@/utils/toast'
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/db/config', {
key: 'payment_methods',
value: localSettings,
description: '支付方式配置',
})
toast.success('配置已保存!')
} catch (error) {
console.error('保存失败:', error)
toast.error('保存失败: ' + (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 w-full">
<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,249 @@
import toast from '@/utils/toast'
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/db/config', {
key: 'live_qr_codes',
value: updatedLiveQRCodes,
description: '群活码配置',
})
toast.success('群活码配置已保存!')
await loadConfig()
} catch (e) {
console.error(e)
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
}
}
const handleSaveWechatGroup = async () => {
try {
await post('/api/db/config', {
key: 'payment_methods',
value: {
...(config.paymentMethods || {}),
wechat: {
...(config.paymentMethods?.wechat || {}),
groupQrCode: wechatGroupUrl,
},
},
description: '支付方式配置',
})
toast.success('微信群链接已保存!用户支付成功后将自动跳转')
await loadConfig()
} catch (e) {
console.error(e)
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
}
}
const handleTestJump = () => {
if (wechatGroupUrl) window.open(wechatGroupUrl, '_blank')
else toast.error('请先配置微信群链接')
}
return (
<div className="p-8 w-full">
<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,320 @@
import toast from '@/utils/toast'
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
vipOrderShareVip: number
vipOrderShareNonVip: number
}
const DEFAULT: ReferralConfig = {
distributorShare: 90,
minWithdrawAmount: 10,
bindingDays: 30,
userDiscount: 5,
enableAutoWithdraw: false,
vipOrderShareVip: 20,
vipOrderShareNonVip: 10,
}
interface ReferralSettingsPageProps {
embedded?: boolean
}
export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embedded?: boolean }) {
const [config, setConfig] = useState<ReferralConfig>(DEFAULT)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
get<{ success?: boolean; data?: ReferralConfig }>('/api/admin/referral-settings')
.then((res) => {
const c = (res as { data?: ReferralConfig })?.data
if (c && typeof c === 'object') {
setConfig({
distributorShare: c.distributorShare ?? 90,
minWithdrawAmount: c.minWithdrawAmount ?? 10,
bindingDays: c.bindingDays ?? 30,
userDiscount: c.userDiscount ?? 5,
enableAutoWithdraw: c.enableAutoWithdraw ?? false,
vipOrderShareVip: c.vipOrderShareVip ?? 20,
vipOrderShareNonVip: c.vipOrderShareNonVip ?? 10,
})
}
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const handleSave = async () => {
setSaving(true)
try {
const body = {
distributorShare: Number(config.distributorShare) || 0,
minWithdrawAmount: Number(config.minWithdrawAmount) || 0,
bindingDays: Number(config.bindingDays) || 0,
userDiscount: Number(config.userDiscount) || 0,
enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
vipOrderShareVip: Number(config.vipOrderShareVip) || 20,
vipOrderShareNonVip: Number(config.vipOrderShareNonVip) || 10,
}
const res = await post<{ success?: boolean; error?: string }>('/api/admin/referral-settings', body)
if (!res || (res as { success?: boolean }).success === false) {
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : ''))
return
}
toast.success(
'✅ 分销配置已保存成功!\n\n• 小程序与网站的推广规则会一起生效\n• 绑定关系会使用新的天数配置\n• 佣金比例会立即应用到新订单\n\n如有缓存请刷新前台/小程序页面。',
)
} catch (e) {
console.error(e)
toast.error('保存失败: ' + (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 w-full">
<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">
<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.vipOrderShareVip}
onChange={handleNumberChange('vipOrderShareVip')}
/>
<p className="text-xs text-gray-500">
广 20%
</p>
</div>
<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.vipOrderShareNonVip}
onChange={handleNumberChange('vipOrderShareNonVip')}
/>
<p className="text-xs text-gray-500">
广 10%
</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,788 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
Save,
Settings,
Users,
DollarSign,
UserCircle,
Calendar,
MapPin,
BookOpen,
Gift,
Smartphone,
ShieldCheck,
Link2,
Cloud,
} from 'lucide-react'
import { get, post } from '@/api/client'
import { AuthorSettingsPage } from '@/pages/author-settings/AuthorSettingsPage'
import { AdminUsersPage } from '@/pages/admin-users/AdminUsersPage'
import { ApiDocsPage } from '@/pages/api-docs/ApiDocsPage'
interface AuthorInfo {
name?: string
startDate?: string
bio?: string
liveTime?: string
platform?: string
description?: string
}
interface LocalSettings {
sectionPrice: number
baseBookPrice: number
distributorShare: number
authorInfo: AuthorInfo
ckbLeadApiKey: string
}
interface FeatureConfig {
matchEnabled: boolean
referralEnabled: boolean
searchEnabled: boolean
aboutEnabled: boolean
}
interface MpConfig {
appId?: string
withdrawSubscribeTmplId?: string
mchId?: string
minWithdraw?: number
}
interface OssConfig {
endpoint?: string
accessKeyId?: string
accessKeySecret?: string
bucket?: string
region?: string
}
const defaultMpConfig: MpConfig = {
appId: 'wxb8bbb2b10dec74aa',
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
mchId: '1318592501',
minWithdraw: 10,
}
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 },
ckbLeadApiKey: '',
}
const defaultOssConfig: OssConfig = {
endpoint: '',
accessKeyId: '',
accessKeySecret: '',
bucket: '',
region: '',
}
const defaultFeatures: FeatureConfig = {
matchEnabled: true,
referralEnabled: true,
searchEnabled: true,
aboutEnabled: true,
}
const TAB_KEYS = ['system', 'author', 'admin', 'api-docs'] as const
type TabKey = (typeof TAB_KEYS)[number]
export function SettingsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const tabParam = searchParams.get('tab') ?? 'system'
const activeTab = TAB_KEYS.includes(tabParam as TabKey) ? (tabParam as TabKey) : 'system'
const [localSettings, setLocalSettings] = useState<LocalSettings>(defaultSettings)
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [ossConfig, setOssConfig] = useState<OssConfig>(defaultOssConfig)
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogTitle, setDialogTitle] = useState('')
const [dialogMessage, setDialogMessage] = useState('')
const [dialogIsError, setDialogIsError] = useState(false)
const [featureSwitchSaving, setFeatureSwitchSaving] = useState(false)
const showResult = (title: string, message: string, isError = false) => {
setDialogTitle(title)
setDialogMessage(message)
setDialogIsError(isError)
setDialogOpen(true)
}
useEffect(() => {
const load = async () => {
try {
const res = await get<{
success?: boolean
featureConfig?: Partial<FeatureConfig>
siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo; ckbLeadApiKey?: string }
mpConfig?: Partial<MpConfig>
ossConfig?: Partial<OssConfig>
}>('/api/admin/settings')
if (!res || (res as { success?: boolean }).success === false) return
if (res.featureConfig && Object.keys(res.featureConfig).length)
setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig }))
if (res.mpConfig && typeof res.mpConfig === 'object')
setMpConfig((prev) => ({ ...prev, ...res.mpConfig }))
if (res.ossConfig && typeof res.ossConfig === 'object')
setOssConfig((prev) => ({ ...prev, ...res.ossConfig }))
if (res.siteSettings && typeof res.siteSettings === 'object') {
const s = res.siteSettings
setLocalSettings((prev) => ({
...prev,
...(typeof s.sectionPrice === 'number' && { sectionPrice: s.sectionPrice }),
...(typeof s.baseBookPrice === 'number' && { baseBookPrice: s.baseBookPrice }),
...(typeof s.distributorShare === 'number' && { distributorShare: s.distributorShare }),
...(s.authorInfo && typeof s.authorInfo === 'object' && { authorInfo: { ...prev.authorInfo, ...s.authorInfo } }),
...(typeof s.ckbLeadApiKey === 'string' && { ckbLeadApiKey: s.ckbLeadApiKey }),
}))
}
} catch (e) {
console.error('Load settings error:', e)
} finally {
setLoading(false)
}
}
load()
}, [])
const saveFeatureConfigOnly = async (
nextConfig: FeatureConfig,
onFailRevert: () => void,
) => {
setFeatureSwitchSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
featureConfig: nextConfig,
})
if (!res || (res as { success?: boolean }).success === false) {
onFailRevert()
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
return
}
showResult('已保存', '功能开关已更新,相关入口将随之显示或隐藏。')
} catch (error) {
console.error('Save feature config error:', error)
onFailRevert()
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
} finally {
setFeatureSwitchSaving(false)
}
}
const handleFeatureSwitch = (field: keyof FeatureConfig, checked: boolean) => {
const prev = featureConfig
const next = { ...prev, [field]: checked }
setFeatureConfig(next)
saveFeatureConfigOnly(next, () => setFeatureConfig(prev))
}
const handleSave = async () => {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
featureConfig,
siteSettings: {
sectionPrice: localSettings.sectionPrice,
baseBookPrice: localSettings.baseBookPrice,
distributorShare: localSettings.distributorShare,
authorInfo: localSettings.authorInfo,
ckbLeadApiKey: localSettings.ckbLeadApiKey || undefined,
},
mpConfig: {
...mpConfig,
appId: mpConfig.appId || '',
withdrawSubscribeTmplId: mpConfig.withdrawSubscribeTmplId || '',
mchId: mpConfig.mchId || '',
minWithdraw: typeof mpConfig.minWithdraw === 'number' ? mpConfig.minWithdraw : 10,
},
ossConfig: {
endpoint: ossConfig.endpoint || '',
accessKeyId: ossConfig.accessKeyId || '',
accessKeySecret: ossConfig.accessKeySecret || '',
bucket: ossConfig.bucket || '',
region: ossConfig.region || '',
},
})
if (!res || (res as { success?: boolean }).success === false) {
showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true)
return
}
showResult('已保存', '设置已保存成功。')
} catch (error) {
console.error('Save settings error:', error)
showResult('保存失败', error instanceof Error ? error.message : String(error), true)
} finally {
setIsSaving(false)
}
}
const handleTabChange = (v: string) => {
setSearchParams(v === 'system' ? {} : { tab: v })
}
if (loading) return <div className="p-8 text-gray-500">...</div>
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1"></p>
</div>
{activeTab === 'system' && (
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
>
<Save className="w-4 h-4 mr-2" />
{isSaving ? '保存中...' : '保存设置'}
</Button>
)}
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="mb-6 bg-[#0f2137] border border-gray-700/50 p-1">
<TabsTrigger
value="system"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<Settings className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="author"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<UserCircle className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="admin"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<ShieldCheck className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger
value="api-docs"
className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] text-gray-400 data-[state=active]:font-medium"
>
<BookOpen className="w-4 h-4 mr-2" />
API
</TabsTrigger>
</TabsList>
<TabsContent value="system" className="mt-0">
<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="space-y-2">
<Label htmlFor="ckb-lead-api-key" className="text-gray-300 flex items-center gap-1">
<Link2 className="w-3 h-3" />
</Label>
<Input
id="ckb-lead-api-key"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="如 xxxxx-xxxxx-xxxxx-xxxxx留空则用 .env 默认)"
value={localSettings.ckbLeadApiKey ?? ''}
onChange={(e) =>
setLocalSettings((prev) => ({
...prev,
ckbLeadApiKey: e.target.value,
}))
}
/>
<p className="text-xs text-gray-500">使 API Key .env </p>
</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">
<Smartphone className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
<CardDescription className="text-gray-400">
/api/miniprogram/config API app.js baseUrl
</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"
placeholder="wxb8bbb2b10dec74aa"
value={mpConfig.appId ?? ''}
onChange={(e) =>
setMpConfig((prev) => ({ ...prev, appId: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"> ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="用户申请提现时需授权"
value={mpConfig.withdrawSubscribeTmplId ?? ''}
onChange={(e) =>
setMpConfig((prev) => ({ ...prev, withdrawSubscribeTmplId: 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="1318592501"
value={mpConfig.mchId ?? ''}
onChange={(e) =>
setMpConfig((prev) => ({ ...prev, mchId: 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 ?? 10}
onChange={(e) =>
setMpConfig((prev) => ({
...prev,
minWithdraw: Number.parseFloat(e.target.value) || 10,
}))
}
/>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Cloud className="w-5 h-5 text-[#38bdac]" />
OSS
</CardTitle>
<CardDescription className="text-gray-400">
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300">Endpoint</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou.aliyuncs.com"
value={ossConfig.endpoint ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, endpoint: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">Region</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="oss-cn-hangzhou"
value={ossConfig.region ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, region: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="LTAI5t..."
value={ossConfig.accessKeyId ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeyId: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300">AccessKey Secret</Label>
<Input
type="password"
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="********"
value={ossConfig.accessKeySecret ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, accessKeySecret: e.target.value }))
}
/>
</div>
<div className="space-y-2 col-span-2">
<Label className="text-gray-300">Bucket </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="my-soul-bucket"
value={ossConfig.bucket ?? ''}
onChange={(e) =>
setOssConfig((prev) => ({ ...prev, bucket: e.target.value }))
}
/>
</div>
</div>
<div className={`p-3 rounded-lg ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'bg-green-500/10 border border-green-500/30' : 'bg-amber-500/10 border border-amber-500/30'}`}>
<p className={`text-xs ${ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId ? 'text-green-300' : 'text-amber-300'}`}>
{ossConfig.endpoint && ossConfig.bucket && ossConfig.accessKeyId
? `✅ OSS 已配置(${ossConfig.bucket}.${ossConfig.endpoint}),上传将自动使用云端存储`
: '⚠ 未配置 OSS当前上传存储在本地服务器。填写以上信息并保存后自动启用云端存储'}
</p>
</div>
</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}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('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}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('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}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-lg bg-[#0a1628] border border-gray-700/50">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-[#38bdac]" />
<Label htmlFor="about-enabled" className="text-white font-medium cursor-pointer">
</Label>
</div>
<p className="text-xs text-gray-400 ml-6">访</p>
</div>
<Switch
id="about-enabled"
checked={featureConfig.aboutEnabled}
disabled={featureSwitchSaving}
onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)}
/>
</div>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-500/30">
<p className="text-xs text-blue-300">
💡
</p>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="author" className="mt-0">
<AuthorSettingsPage />
</TabsContent>
<TabsContent value="admin" className="mt-0">
<AdminUsersPage />
</TabsContent>
<TabsContent value="api-docs" className="mt-0">
<ApiDocsPage />
</TabsContent>
</Tabs>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent
className="bg-[#0f2137] border-gray-700 text-white"
showCloseButton={true}
>
<DialogHeader>
<DialogTitle className={dialogIsError ? 'text-red-400' : 'text-[#38bdac]'}>
{dialogTitle}
</DialogTitle>
<DialogDescription className="text-gray-400 whitespace-pre-wrap pt-2">
{dialogMessage}
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button
onClick={() => setDialogOpen(false)}
className={dialogIsError ? 'bg-gray-600 hover:bg-gray-500' : 'bg-[#38bdac] hover:bg-[#2da396]'}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,406 @@
import toast from '@/utils/toast'
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, post } 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)
const [saving, setSaving] = 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 = async () => {
setSaving(true)
try {
await post('/api/db/config', {
key: 'site_config',
value: localSettings.siteConfig,
description: '网站基础配置',
})
await post('/api/db/config', {
key: 'menu_config',
value: localSettings.menuConfig,
description: '底部菜单配置',
})
await post('/api/db/config', {
key: 'page_config',
value: localSettings.pageConfig,
description: '页面标题配置',
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
toast.success('配置已保存')
} catch (e) {
console.error(e)
toast.error('保存失败: ' + (e instanceof Error ? e.message : String(e)))
} finally {
setSaving(false)
}
}
const sc = localSettings.siteConfig
const menu = localSettings.menuConfig
const page = localSettings.pageConfig
return (
<div className="p-8 w-full">
<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={saving}
className={`${saved ? 'bg-green-500' : 'bg-[#00CED1]'} hover:bg-[#20B2AA] text-white transition-colors`}
>
<Save className="w-4 h-4 mr-2" />
{saving ? '保存中...' : 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>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Crown } from 'lucide-react'
import { get } from '@/api/client'
interface VipMember {
id: string
name: string
avatar?: string
vipRole?: string
vipSort?: number
}
export function VipRolesPage() {
const [members, setMembers] = useState<VipMember[]>([])
const [loading, setLoading] = useState(true)
async function loadMembers() {
setLoading(true)
try {
const data = await get<{ success?: boolean; data?: VipMember[] }>(
'/api/db/vip-members?limit=100',
)
if (data?.success && data.data) {
const list = [...data.data].map((m, idx) => ({
...m,
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
}))
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
setMembers(list)
}
} catch (e) {
console.error('Load VIP members error:', e)
toast.error('加载 VIP 成员失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadMembers()
}, [])
return (
<div className="p-8 w-full">
<div className="flex justify-between items-center mb-8">
<div>
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
<Crown className="w-5 h-5 text-amber-400" />
/
</h2>
<p className="text-gray-400 mt-1">
</p>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
{loading ? (
<div className="py-12 text-center text-gray-400">...</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400 w-20"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400 w-40"></TableHead>
<TableHead className="text-gray-400 w-28"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((m, index) => (
<TableRow key={m.id} className="border-gray-700/50">
<TableCell className="text-gray-300">{index + 1}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{m.avatar ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img
src={normalizeImageUrl(m.avatar)}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
/>
) : (
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
{m.name?.[0] || '创'}
</div>
)}
<div className="min-w-0">
<div className="text-white text-sm truncate">{m.name}</div>
</div>
</div>
</TableCell>
<TableCell className="text-gray-300">
{m.vipRole || <span className="text-gray-500"></span>}
</TableCell>
<TableCell className="text-gray-300">{m.vipSort ?? index + 1}</TableCell>
</TableRow>
))}
{members.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,556 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
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 { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Check, X, RefreshCw, Wallet, DollarSign } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get, put } from '@/api/client'
interface Withdrawal {
id: string
userId?: string
userName?: 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
processedAt?: string
method?: 'wechat' | 'alipay'
account?: string
name?: string
userConfirmedAt?: string | null
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 [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'pending' | 'success' | 'failed'>('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [processing, setProcessing] = useState<string | null>(null)
const [rejectWithdrawalId, setRejectWithdrawalId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [rejectLoading, setRejectLoading] = useState(false)
async function loadWithdrawals() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({
status: filter,
page: String(page),
pageSize: String(pageSize),
})
const data = await get<{
success?: boolean
withdrawals?: Withdrawal[]
stats?: Partial<Stats>
total?: number
}>(`/api/admin/withdrawals?${params}`)
if (data?.success) {
const list = data.withdrawals || []
setWithdrawals(list)
setTotal(data.total ?? data.stats?.total ?? list.length)
setStats({
total: data.stats?.total ?? data.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,
})
} else {
setError('加载提现记录失败')
}
} catch (err) {
console.error('Load withdrawals error:', err)
setError('加载失败,请检查网络后重试')
} finally {
setLoading(false)
}
}
useEffect(() => {
setPage(1)
}, [filter])
useEffect(() => {
loadWithdrawals()
}, [filter, page, pageSize])
const totalPages = Math.ceil(total / pageSize) || 1
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 toast.error('操作失败: ' + (data?.error ?? ''))
} catch {
toast.error('操作失败')
} finally {
setProcessing(null)
}
}
function openRejectDialog(id: string) {
setRejectWithdrawalId(id)
setRejectReason('')
}
async function submitRejectWithdrawal() {
const id = rejectWithdrawalId
if (!id) return
const reason = rejectReason.trim()
if (!reason) {
toast.error('请填写拒绝原因')
return
}
setRejectLoading(true)
try {
const data = await put<{ success?: boolean; error?: string }>(
'/api/admin/withdrawals',
{ id, action: 'reject', errorMessage: reason },
)
if (data?.success) {
toast.success('已拒绝该提现申请')
setRejectWithdrawalId(null)
setRejectReason('')
loadWithdrawals()
} else {
toast.error('操作失败: ' + (data?.error ?? ''))
}
} catch {
toast.error('操作失败')
} finally {
setRejectLoading(false)
}
}
function closeRejectDialog() {
if (rejectWithdrawalId) toast.info('已取消操作')
setRejectWithdrawalId(null)
setRejectReason('')
}
function getStatusBadge(status: string) {
switch (status) {
case 'pending':
return (
<Badge className="bg-orange-500/20 text-orange-400 hover:bg-orange-500/20 border-0">
</Badge>
)
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 w-full">
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-400 text-sm flex items-center justify-between">
<span>{error}</span>
<button type="button" onClick={() => setError(null)} className="hover:text-red-300">
×
</button>
</div>
)}
<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-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.createdAt ?? '').toLocaleString()}
</td>
<td className="p-4">
<div className="flex items-center gap-2">
{w.userAvatar ? (
<img
src={normalizeImageUrl(w.userAvatar)}
alt={w.userName ?? ''}
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.userName ?? '?').charAt(0)}
</div>
)}
<div>
<p className="font-medium text-white">
{w.userName ?? '未知'}
</p>
<p className="text-xs text-gray-500">
{w.userPhone ?? w.referralCode ?? (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 ? new Date(w.processedAt).toLocaleString() : '-'}
</td>
<td className="p-4 text-gray-400">
{w.userConfirmedAt ? (
<span className="text-green-400" title={w.userConfirmedAt}>
{new Date(w.userConfirmedAt).toLocaleString()}
</span>
) : (
'-'
)}
</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={() => openRejectDialog(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>
<Pagination
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={(n) => {
setPageSize(n)
setPage(1)
}}
/>
</>
)}
</CardContent>
</Card>
<Dialog open={!!rejectWithdrawalId} onOpenChange={(open) => !open && closeRejectDialog()}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white"></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-gray-400 text-sm"></p>
<div>
<label className="text-sm text-gray-400 block mb-2"></label>
<div className="form-input">
<Input
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
placeholder="请输入拒绝原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
className="border-gray-600 text-gray-300"
onClick={closeRejectDialog}
disabled={rejectLoading}
>
</Button>
<Button
className="bg-red-600 hover:bg-red-700 text-white"
onClick={submitRejectWithdrawal}
disabled={rejectLoading || !rejectReason.trim()}
>
{rejectLoading ? '提交中...' : '确认拒绝'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,93 @@
/**
* 轻量 toast 工具,无需第三方库
* 用法toast.success('保存成功') | toast.error('保存失败') | toast.info('...')
*/
type ToastType = 'success' | 'error' | 'info'
const COLORS: Record<ToastType, { bg: string; border: string; icon: string }> = {
success: { bg: '#f0fdf4', border: '#22c55e', icon: '✓' },
error: { bg: '#fef2f2', border: '#ef4444', icon: '✕' },
info: { bg: '#eff6ff', border: '#3b82f6', icon: '' },
}
function show(message: string, type: ToastType = 'info', duration = 3000) {
const id = `toast-${Date.now()}`
const c = COLORS[type]
const el = document.createElement('div')
el.id = id
el.setAttribute('role', 'alert')
Object.assign(el.style, {
position: 'fixed',
top: '24px',
right: '24px',
zIndex: '9999',
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '12px 18px',
borderRadius: '10px',
background: c.bg,
border: `1.5px solid ${c.border}`,
boxShadow: '0 4px 20px rgba(0,0,0,.12)',
fontSize: '14px',
color: '#1a1a1a',
fontWeight: '500',
maxWidth: '380px',
lineHeight: '1.5',
opacity: '0',
transform: 'translateY(-8px)',
transition: 'opacity .22s ease, transform .22s ease',
pointerEvents:'none',
})
const iconEl = document.createElement('span')
Object.assign(iconEl.style, {
width: '20px',
height: '20px',
borderRadius: '50%',
background: c.border,
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent:'center',
fontSize: '12px',
fontWeight: '700',
flexShrink: '0',
})
iconEl.textContent = c.icon
const textEl = document.createElement('span')
textEl.textContent = message
el.appendChild(iconEl)
el.appendChild(textEl)
document.body.appendChild(el)
// 入场动画
requestAnimationFrame(() => {
el.style.opacity = '1'
el.style.transform = 'translateY(0)'
})
// 自动消失
const timer = setTimeout(() => dismiss(id), duration)
function dismiss(elId: string) {
clearTimeout(timer)
const target = document.getElementById(elId)
if (!target) return
target.style.opacity = '0'
target.style.transform = 'translateY(-8px)'
setTimeout(() => target.parentNode?.removeChild(target), 250)
}
}
const toast = {
success: (msg: string, duration?: number) => show(msg, 'success', duration),
error: (msg: string, duration?: number) => show(msg, 'error', duration),
info: (msg: string, duration?: number) => show(msg, 'info', duration),
}
export default toast

9
new-soul/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
}

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/auth.ts","./src/api/ckb.ts","./src/api/client.ts","./src/components/richeditor.tsx","./src/components/modules/user/setvipmodal.tsx","./src/components/modules/user/userdetailmodal.tsx","./src/components/ui/pagination.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/hooks/usedebounce.ts","./src/layouts/adminlayout.tsx","./src/lib/utils.ts","./src/pages/admin-users/adminuserspage.tsx","./src/pages/api-doc/apidocpage.tsx","./src/pages/api-docs/apidocspage.tsx","./src/pages/author-settings/authorsettingspage.tsx","./src/pages/chapters/chapterspage.tsx","./src/pages/content/chaptertree.tsx","./src/pages/content/contentpage.tsx","./src/pages/content/personaddeditmodal.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/distribution/distributionpage.tsx","./src/pages/find-partner/findpartnerpage.tsx","./src/pages/find-partner/tabs/ckbconfigpanel.tsx","./src/pages/find-partner/tabs/ckbstatstab.tsx","./src/pages/find-partner/tabs/findpartnertab.tsx","./src/pages/find-partner/tabs/matchpooltab.tsx","./src/pages/find-partner/tabs/matchrecordstab.tsx","./src/pages/find-partner/tabs/mentorbookingtab.tsx","./src/pages/find-partner/tabs/mentortab.tsx","./src/pages/find-partner/tabs/resourcedockingtab.tsx","./src/pages/find-partner/tabs/teamrecruittab.tsx","./src/pages/linked-mp/linkedmppage.tsx","./src/pages/login/loginpage.tsx","./src/pages/match/matchpage.tsx","./src/pages/match-records/matchrecordspage.tsx","./src/pages/mentor-consultations/mentorconsultationspage.tsx","./src/pages/mentors/mentorspage.tsx","./src/pages/not-found/notfoundpage.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/vip-roles/viprolespage.tsx","./src/pages/withdrawals/withdrawalspage.tsx","./src/utils/toast.ts"],"version":"5.6.3"}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5174,
},
})

View File

@@ -37457,3 +37457,5 @@
{"level":"debug","timestamp":"2026-03-18T15:43:09+08:00","caller":"kernel/accessToken.go:383","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 174\r\nConnection: keep-alive\r\nContent-Type: application/json; encoding=utf-8\r\nDate: Wed, 18 Mar 2026 07:43:09 GMT\r\n\r\n{\"access_token\":\"102_W92JqCHafxmMPmqerkx_H3KLFbch7Gn0EQESDosCLzzg3wIoVw5D6dwdO_n8t8-rC1JXBEB9niZwtll6b0CENMM8vPydT8k7-gk0pg15AMl_vTZPbq_ju0ELnJsSRLcADAGFZ\",\"expires_in\":7200}"}
{"level":"debug","timestamp":"2026-03-18T15:43:09+08:00","caller":"kernel/baseClient.go:457","content":"GET https://api.weixin.qq.com/sns/jscode2session?access_token=102_W92JqCHafxmMPmqerkx_H3KLFbch7Gn0EQESDosCLzzg3wIoVw5D6dwdO_n8t8-rC1JXBEB9niZwtll6b0CENMM8vPydT8k7-gk0pg15AMl_vTZPbq_ju0ELnJsSRLcADAGFZ&appid=wxb8bbb2b10dec74aa&grant_type=authorization_code&js_code=0d1RnJkl2eGlnh46F3ml2JJFVe0RnJks&secret=3c1fb1f63e6e052222bbcead9d07fe0c request header: { Accept:*/*} "}
{"level":"debug","timestamp":"2026-03-18T15:43:09+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 82\r\nConnection: keep-alive\r\nContent-Type: text/plain\r\nDate: Wed, 18 Mar 2026 07:43:10 GMT\r\n\r\n{\"session_key\":\"mur32y6Vm+aQjbMsDdhZew==\",\"openid\":\"ogpTW5a9exdEmEwqZsYywvgSpSQg\"}"}
{"level":"debug","timestamp":"2026-03-18T21:07:15+08:00","caller":"kernel/baseClient.go:457","content":"POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi request header: { Content-Type:application/jsonAuthorization:WECHATPAY2-SHA256-RSA2048 mchid=\"1318592501\",nonce_str=\"90HyKiGGHV25V7Ijj6ul6JoRie0poe2L\",timestamp=\"1773839235\",serial_no=\"4A1DB62CD5C9BE0B6FC51C30621D6F99686E75C5\",signature=\"rdDfM6QSNyG9LxELDdSNUTTgtDWCzCIbW1VTG/5PMaDUfWjUwwbGLMPlb6M0iMjt08pXoJ7jj4fCE4J6qxd8EYP2HAKRQWOGuthIXHSEjuEUDve/ZIzdWtSmnTDxKG+2TvTsCprQDSZZCWvSTdpf3kEIQW/PbQCyXhzz8Vcq/I1t8U2k5s69S+Kwp6wt6bwmbT7NNVTMR1BQSfac2bFvWtFdavxBdBgESqf09ZzSZCqQZ2Cc5b6ksgovpeaiRotijSmjsjOetK8RVbqymg0tW/cq3S385J7dDvbXTUNmjYlEwDQx/elxaO6gSr9gNkfvwForJLpv8ngh/4cAWDisBw==\"Accept:*/*} request body:"}
{"level":"debug","timestamp":"2026-03-18T21:07:15+08:00","caller":"kernel/baseClient.go:459","content":"------------------response content:HTTP/1.1 200 OK\r\nContent-Length: 52\r\nCache-Control: no-cache, must-revalidate\r\nConnection: keep-alive\r\nContent-Language: zh-CN\r\nContent-Type: application/json; charset=utf-8\r\nDate: Wed, 18 Mar 2026 13:07:16 GMT\r\nKeep-Alive: timeout=8\r\nRequest-Id: 0884C7EACD0610AA031894CE8C5820BCA32F28E444-0\r\nServer: nginx\r\nWechatpay-Nonce: 6966f72530a549d7a3100836b57f72bc\r\nWechatpay-Serial: 5F2543BF58239A4EB68FA4433DF1438A88B34B16\r\nWechatpay-Signature: WQFiAIW3gFIPi+QA0vEYygWXWMe20N6SZtOjfoh3MzCeD/nu57TmMrUNEibuxgvGznowHIbSGQaLuY8YoMo+Q7fo+sh4yHJgkOF6ewQIERgfEK4fbm0BM82A99c6jkmbElcG5nwfRRX9AiHXhYK/G6h1iGuBcOWUwhuuO1qtNJC1w5tSvN6AnIoSDaMLcokdYay3f/1anB7cwPkQhC4ekou6J5K16WuEZy+PN+XBLTB4C4OiX3Dm5E1DvGpaHnsGXodFBO7odskA3vAGFRM72djzJRv8A/N9Ek7gmnq8W9UUlLzbVg7oeyZPbKP0PSLRtVDg0CcdRtyzFA+d1GPd5Q==\r\nWechatpay-Signature-Type: WECHATPAY2-SHA256-RSA2048\r\nWechatpay-Timestamp: 1773839236\r\nX-Content-Type-Options: nosniff\r\n\r\n{\"prepay_id\":\"wx182107166273785544637f1df033050001\"}"}