go
This commit is contained in:
2
new-soul/soul-admin/.gitignore
vendored
Normal file
2
new-soul/soul-admin/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
318
new-soul/soul-admin/deploy_admin.py
Normal file
318
new-soul/soul-admin/deploy_admin.py
Normal 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 → dist1,dist2 → dist ====================
|
||||
|
||||
|
||||
def remote_swap_dist(cfg):
|
||||
"""服务器上:dist→dist1,dist2→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())
|
||||
12
new-soul/soul-admin/index.html
Normal file
12
new-soul/soul-admin/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
new-soul/soul-admin/package.json
Normal file
59
new-soul/soul-admin/package.json
Normal 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
4131
new-soul/soul-admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
new-soul/soul-admin/postcss.config.js
Normal file
5
new-soul/soul-admin/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
58
new-soul/soul-admin/src/App.tsx
Normal file
58
new-soul/soul-admin/src/App.tsx
Normal 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
|
||||
28
new-soul/soul-admin/src/api/auth.ts
Normal file
28
new-soul/soul-admin/src/api/auth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
87
new-soul/soul-admin/src/api/ckb.ts
Normal file
87
new-soul/soul-admin/src/api/ckb.ts
Normal 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')
|
||||
}
|
||||
92
new-soul/soul-admin/src/api/client.ts
Normal file
92
new-soul/soul-admin/src/api/client.ts
Normal 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
|
||||
}
|
||||
|
||||
/** 请求完整 URL:baseUrl + path,path 必须与现网一致(如 /api/orders) */
|
||||
export function apiUrl(path: string): string {
|
||||
const base = getBaseUrl()
|
||||
const p = path.startsWith('/') ? path : `/${path}`
|
||||
return base ? `${base}${p}` : p
|
||||
}
|
||||
|
||||
export type RequestInitWithBody = RequestInit & { data?: unknown }
|
||||
|
||||
/**
|
||||
* 发起请求。path 为与现网一致的 API 路径(如 /api/admin、/api/orders)。
|
||||
* 若有 admin_token(JWT)则自动带 Authorization: Bearer;credentials: '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' })
|
||||
}
|
||||
239
new-soul/soul-admin/src/components/RichEditor.css
Normal file
239
new-soul/soul-admin/src/components/RichEditor.css
Normal 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;
|
||||
}
|
||||
635
new-soul/soul-admin/src/components/RichEditor.tsx
Normal file
635
new-soul/soul-admin/src/components/RichEditor.tsx
Normal 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, '')
|
||||
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '')
|
||||
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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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
|
||||
281
new-soul/soul-admin/src/components/modules/user/SetVipModal.tsx
Normal file
281
new-soul/soul-admin/src/components/modules/user/SetVipModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1054
new-soul/soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
1054
new-soul/soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
81
new-soul/soul-admin/src/components/ui/Pagination.tsx
Normal file
81
new-soul/soul-admin/src/components/ui/Pagination.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
31
new-soul/soul-admin/src/components/ui/badge.tsx
Normal file
31
new-soul/soul-admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-white',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
return <Comp className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
52
new-soul/soul-admin/src/components/ui/button.tsx
Normal file
52
new-soul/soul-admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-white hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
42
new-soul/soul-admin/src/components/ui/card.tsx
Normal file
42
new-soul/soul-admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
85
new-soul/soul-admin/src/components/ui/dialog.tsx
Normal file
85
new-soul/soul-admin/src/components/ui/dialog.tsx
Normal 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
|
||||
18
new-soul/soul-admin/src/components/ui/input.tsx
Normal file
18
new-soul/soul-admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 md:text-sm focus-visible:ring-2 focus-visible:ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
new-soul/soul-admin/src/components/ui/label.tsx
Normal file
20
new-soul/soul-admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
79
new-soul/soul-admin/src/components/ui/select.tsx
Normal file
79
new-soul/soul-admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
|
||||
position === 'popper' && 'data-[side=bottom]:translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
|
||||
48
new-soul/soul-admin/src/components/ui/slider.tsx
Normal file
48
new-soul/soul-admin/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="bg-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 }
|
||||
26
new-soul/soul-admin/src/components/ui/switch.tsx
Normal file
26
new-soul/soul-admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#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 }
|
||||
63
new-soul/soul-admin/src/components/ui/table.tsx
Normal file
63
new-soul/soul-admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
49
new-soul/soul-admin/src/components/ui/tabs.tsx
Normal file
49
new-soul/soul-admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
19
new-soul/soul-admin/src/components/ui/textarea.tsx
Normal file
19
new-soul/soul-admin/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
17
new-soul/soul-admin/src/hooks/useDebounce.ts
Normal file
17
new-soul/soul-admin/src/hooks/useDebounce.ts
Normal 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
|
||||
}
|
||||
36
new-soul/soul-admin/src/index.css
Normal file
36
new-soul/soul-admin/src/index.css
Normal file
@@ -0,0 +1,36 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.2 0.02 240);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.2 0.02 240);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.65 0.15 180);
|
||||
--primary-foreground: oklch(0.2 0 0);
|
||||
--secondary: oklch(0.27 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.27 0 0);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.27 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.55 0.2 25);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.35 0 0);
|
||||
--input: oklch(0.35 0 0);
|
||||
--ring: oklch(0.65 0.15 180);
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: #0a1628;
|
||||
color: var(--foreground);
|
||||
}
|
||||
145
new-soul/soul-admin/src/layouts/AdminLayout.tsx
Normal file
145
new-soul/soul-admin/src/layouts/AdminLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
new-soul/soul-admin/src/lib/utils.ts
Normal file
19
new-soul/soul-admin/src/lib/utils.ts
Normal 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
|
||||
}
|
||||
13
new-soul/soul-admin/src/main.tsx
Normal file
13
new-soul/soul-admin/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
413
new-soul/soul-admin/src/pages/admin-users/AdminUsersPage.tsx
Normal file
413
new-soul/soul-admin/src/pages/admin-users/AdminUsersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
new-soul/soul-admin/src/pages/api-doc/ApiDocPage.tsx
Normal file
146
new-soul/soul-admin/src/pages/api-doc/ApiDocPage.tsx
Normal 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">表单:file(必填)、folder(可选,默认 images)、quality(可选 1-100,默认 85)</p>
|
||||
<p className="text-gray-500 text-xs">支持 jpeg/png/gif,单张最大 5MB。JPEG 按 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">表单:file(必填)、folder(可选,默认 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>
|
||||
)
|
||||
}
|
||||
442
new-soul/soul-admin/src/pages/api-docs/ApiDocsPage.tsx
Normal file
442
new-soul/soul-admin/src/pages/api-docs/ApiDocsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
338
new-soul/soul-admin/src/pages/chapters/ChaptersPage.tsx
Normal file
338
new-soul/soul-admin/src/pages/chapters/ChaptersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
924
new-soul/soul-admin/src/pages/content/ChapterTree.tsx
Normal file
924
new-soul/soul-admin/src/pages/content/ChapterTree.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2875
new-soul/soul-admin/src/pages/content/ContentPage.tsx
Normal file
2875
new-soul/soul-admin/src/pages/content/ContentPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
650
new-soul/soul-admin/src/pages/content/PersonAddEditModal.tsx
Normal file
650
new-soul/soul-admin/src/pages/content/PersonAddEditModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
626
new-soul/soul-admin/src/pages/dashboard/DashboardPage.tsx
Normal file
626
new-soul/soul-admin/src/pages/dashboard/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1301
new-soul/soul-admin/src/pages/distribution/DistributionPage.tsx
Normal file
1301
new-soul/soul-admin/src/pages/distribution/DistributionPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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=200,message=新增成功 或 已存在`
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
197
new-soul/soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal file
197
new-soul/soul-admin/src/pages/find-partner/tabs/CKBStatsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
359
new-soul/soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal file
359
new-soul/soul-admin/src/pages/find-partner/tabs/MatchPoolTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
282
new-soul/soul-admin/src/pages/linked-mp/LinkedMpPage.tsx
Normal file
282
new-soul/soul-admin/src/pages/linked-mp/LinkedMpPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
new-soul/soul-admin/src/pages/login/LoginPage.tsx
Normal file
123
new-soul/soul-admin/src/pages/login/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
235
new-soul/soul-admin/src/pages/match-records/MatchRecordsPage.tsx
Normal file
235
new-soul/soul-admin/src/pages/match-records/MatchRecordsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
576
new-soul/soul-admin/src/pages/match/MatchPage.tsx
Normal file
576
new-soul/soul-admin/src/pages/match/MatchPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
499
new-soul/soul-admin/src/pages/mentors/MentorsPage.tsx
Normal file
499
new-soul/soul-admin/src/pages/mentors/MentorsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
new-soul/soul-admin/src/pages/not-found/NotFoundPage.tsx
Normal file
29
new-soul/soul-admin/src/pages/not-found/NotFoundPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
459
new-soul/soul-admin/src/pages/orders/OrdersPage.tsx
Normal file
459
new-soul/soul-admin/src/pages/orders/OrdersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
422
new-soul/soul-admin/src/pages/payment/PaymentPage.tsx
Normal file
422
new-soul/soul-admin/src/pages/payment/PaymentPage.tsx
Normal 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">配置微信、支付宝、USDT、PayPal等支付参数</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadConfig}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
同步配置
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 bg-[#07C160]/10 border border-[#07C160]/30 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<HelpCircle className="w-5 h-5 text-[#07C160] flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium mb-2 text-[#07C160]">如何获取微信群跳转链接?</p>
|
||||
<ol className="text-[#07C160]/80 space-y-1 list-decimal list-inside">
|
||||
<li>打开微信,进入目标微信群</li>
|
||||
<li>点击右上角"..." → "群二维码"</li>
|
||||
<li>点击右上角"..." → "发送到电脑"</li>
|
||||
<li>在电脑上保存二维码图片,上传到图床获取URL</li>
|
||||
<li>或使用草料二维码等工具解析二维码获取链接</li>
|
||||
</ol>
|
||||
<p className="text-[#07C160]/60 mt-2">提示:微信群二维码7天后失效,建议使用活码工具</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="wechat" className="space-y-6">
|
||||
<TabsList className="bg-[#0f2137] border border-gray-700/50 p-1 grid grid-cols-4 w-full">
|
||||
<TabsTrigger
|
||||
value="wechat"
|
||||
className="data-[state=active]:bg-[#07C160]/20 data-[state=active]:text-[#07C160] text-gray-400"
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-2" />
|
||||
微信
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="alipay"
|
||||
className="data-[state=active]:bg-[#1677FF]/20 data-[state=active]:text-[#1677FF] text-gray-400"
|
||||
>
|
||||
<CreditCard className="w-4 h-4 mr-2" />
|
||||
支付宝
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="usdt"
|
||||
className="data-[state=active]:bg-[#26A17B]/20 data-[state=active]:text-[#26A17B] text-gray-400"
|
||||
>
|
||||
<Bitcoin className="w-4 h-4 mr-2" />
|
||||
USDT
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="paypal"
|
||||
className="data-[state=active]:bg-[#003087]/20 data-[state=active]:text-[#169BD7] text-gray-400"
|
||||
>
|
||||
<Globe className="w-4 h-4 mr-2" />
|
||||
PayPal
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="wechat" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#07C160] flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
微信支付配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置微信支付参数和跳转链接</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(w.enabled)}
|
||||
onCheckedChange={(c) => updateWechat('enabled', c)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">网站AppID</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
value={String(w.websiteAppId ?? '')}
|
||||
onChange={(e) => updateWechat('websiteAppId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">商户号</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
value={String(w.merchantId ?? '')}
|
||||
onChange={(e) => updateWechat('merchantId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
||||
<h4 className="text-white font-medium flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
||||
跳转链接配置(核心功能)
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">微信收款码/支付链接</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
||||
placeholder="https://收款码图片URL 或 weixin://支付链接"
|
||||
value={String(w.qrCode ?? '')}
|
||||
onChange={(e) => updateWechat('qrCode', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">用户点击微信支付后显示的二维码图片URL</p>
|
||||
</div>
|
||||
<div className="space-y-2 bg-[#07C160]/5 p-4 rounded-xl border border-[#07C160]/20">
|
||||
<Label className="text-[#07C160] font-medium">微信群跳转链接(支付成功后跳转)</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-[#07C160]/30 text-white placeholder:text-gray-500"
|
||||
placeholder="https://weixin.qq.com/g/... 或微信群二维码图片URL"
|
||||
value={String(w.groupQrCode ?? '')}
|
||||
onChange={(e) => updateWechat('groupQrCode', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-[#07C160]/70">用户支付成功后将自动跳转到此链接,进入指定微信群</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alipay" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#1677FF] flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
支付宝配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">已加载真实支付宝参数</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(a.enabled)}
|
||||
onCheckedChange={(c) => updateAlipay('enabled', c)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">合作者身份 (PID)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
value={String(a.partnerId ?? '')}
|
||||
onChange={(e) => updateAlipay('partnerId', e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="border-gray-700 bg-transparent"
|
||||
onClick={() => handleCopy(String(a.partnerId ?? ''), 'pid')}
|
||||
>
|
||||
{copied === 'pid' ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4 text-gray-400" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">安全校验码 (Key)</Label>
|
||||
<Input
|
||||
type="password"
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
value={String(a.securityKey ?? '')}
|
||||
onChange={(e) => updateAlipay('securityKey', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-700/50 pt-4 space-y-4">
|
||||
<h4 className="text-white font-medium flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4 text-[#38bdac]" />
|
||||
跳转链接配置
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">支付宝收款码/跳转链接</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500"
|
||||
placeholder="https://qr.alipay.com/... 或收款码图片URL"
|
||||
value={String(a.qrCode ?? '')}
|
||||
onChange={(e) => updateAlipay('qrCode', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">用户点击支付宝支付后显示的二维码</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usdt" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#26A17B] flex items-center gap-2">
|
||||
<Bitcoin className="w-5 h-5" />
|
||||
USDT配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置加密货币收款地址</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(u.enabled)}
|
||||
onCheckedChange={(c) => updateUsdt('enabled', c)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">网络类型</Label>
|
||||
<select
|
||||
className="w-full bg-[#0a1628] border border-gray-700 text-white rounded-md p-2"
|
||||
value={String(u.network ?? 'TRC20')}
|
||||
onChange={(e) => updateUsdt('network', e.target.value)}
|
||||
>
|
||||
<option value="TRC20">TRC20 (波场)</option>
|
||||
<option value="ERC20">ERC20 (以太坊)</option>
|
||||
<option value="BEP20">BEP20 (币安链)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">收款地址</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white font-mono text-sm"
|
||||
placeholder="T... (TRC20地址)"
|
||||
value={String(u.address ?? '')}
|
||||
onChange={(e) => updateUsdt('address', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={Number(u.exchangeRate) ?? 7.2}
|
||||
onChange={(e) => updateUsdt('exchangeRate', Number.parseFloat(e.target.value) || 7.2)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="paypal" className="space-y-4">
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-[#169BD7] flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
PayPal配置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-gray-400">配置PayPal收款账户</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(p.enabled)}
|
||||
onCheckedChange={(c) => updatePaypal('enabled', c)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">PayPal邮箱</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="your@email.com"
|
||||
value={String(p.email ?? '')}
|
||||
onChange={(e) => updatePaypal('email', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">汇率 (1 USD = ? CNY)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={Number(p.exchangeRate) ?? 7.2}
|
||||
onChange={(e) => updatePaypal('exchangeRate', Number(e.target.value) || 7.2)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
249
new-soul/soul-admin/src/pages/qrcodes/QRCodesPage.tsx
Normal file
249
new-soul/soul-admin/src/pages/qrcodes/QRCodesPage.tsx
Normal 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>微信打开目标群 → 右上角"..." → 群二维码</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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
788
new-soul/soul-admin/src/pages/settings/SettingsPage.tsx
Normal file
788
new-soul/soul-admin/src/pages/settings/SettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
406
new-soul/soul-admin/src/pages/site/SitePage.tsx
Normal file
406
new-soul/soul-admin/src/pages/site/SitePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1265
new-soul/soul-admin/src/pages/users/UsersPage.tsx
Normal file
1265
new-soul/soul-admin/src/pages/users/UsersPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
125
new-soul/soul-admin/src/pages/vip-roles/VipRolesPage.tsx
Normal file
125
new-soul/soul-admin/src/pages/vip-roles/VipRolesPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
556
new-soul/soul-admin/src/pages/withdrawals/WithdrawalsPage.tsx
Normal file
556
new-soul/soul-admin/src/pages/withdrawals/WithdrawalsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
new-soul/soul-admin/src/utils/toast.ts
Normal file
93
new-soul/soul-admin/src/utils/toast.ts
Normal 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
9
new-soul/soul-admin/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
24
new-soul/soul-admin/tsconfig.json
Normal file
24
new-soul/soul-admin/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
9
new-soul/soul-admin/tsconfig.node.json
Normal file
9
new-soul/soul-admin/tsconfig.node.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
new-soul/soul-admin/tsconfig.tsbuildinfo
Normal file
1
new-soul/soul-admin/tsconfig.tsbuildinfo
Normal 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"}
|
||||
18
new-soul/soul-admin/vite.config.ts
Normal file
18
new-soul/soul-admin/vite.config.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
@@ -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\"}"}
|
||||
|
||||
Reference in New Issue
Block a user