sync: soul-admin 页面、开发文档 需求 | 原因: 前端页面修改、需求文档更新

This commit is contained in:
卡若
2026-03-08 17:15:35 +08:00
parent ce72b67335
commit 8443289738
2 changed files with 302 additions and 125 deletions

View File

@@ -1,29 +1,71 @@
import { useEffect, useState } from 'react'
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, FileText, ExternalLink, Save,
} from 'lucide-react'
import { RefreshCw, Zap, CheckCircle2, XCircle, Smartphone, FileText, 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
endpoint: string
label: string
method: 'GET' | 'POST'
status: 'idle' | 'testing' | 'success' | 'error'
message?: string
responseTime?: number
}
interface LeadRow {
id: string
userId: string
userNickname?: string
matchType: string
phone?: string
wechatId?: string
createdAt: string
}
interface RouteConfig {
apiUrl: string
apiKey: string
source: string
tags: string
siteTags: string
notes: string
}
const typeMap = ['partner', 'investor', 'mentor', 'team']
const routeDefs = [
{ key: 'join_partner', label: '找伙伴场景' },
{ key: 'join_investor', label: '资源对接场景' },
{ key: 'join_mentor', label: '导师顾问场景' },
{ key: 'join_team', label: '团队招募场景' },
{ key: 'match', label: '匹配上报' },
{ key: 'lead', label: '链接卡若' },
] as const
const defaultDoc = `# 场景获客接口摘要
- 地址POST /v1/api/scenarios
- 必填apiKey、sign、timestamp
- 主标识phone 或 wechatId 至少一项
- 可选name、source、remark、tags、siteTags、portrait
- 签名:排除 sign/apiKey/portrait键名升序拼接值后双重 MD5
- 成功code=200message=新增成功 或 已存在`
export function CKBConfigPanel() {
const [activeTab, setActiveTab] = useState<WorkspaceTab>('overview')
const [testPhone, setTestPhone] = useState('13800000000')
const [testWechat, setTestWechat] = useState('')
const [apiUrl, setApiUrl] = useState('https://ckbapi.quwanzhi.com/v1/api/scenarios')
const [apiKey, setApiKey] = useState('fyngh-ecy9h-qkdae-epwd5-rz6kd')
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' },
@@ -34,56 +76,57 @@ export function CKBConfigPanel() {
{ 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()
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 {}
}
const testOne = async (idx: number) => {
const t = tests[idx]
if (t.method === 'POST' && !testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
const next = [...tests]; next[idx] = { ...t, status: 'testing', message: undefined, responseTime: undefined }; setTests(next)
const start = performance.now()
async function loadWorkspace() {
setLoading(true)
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 n = [...tests]; n[idx] = { ...t, status: ok ? 'success' : 'error', message: msg || (ok ? '正常' : '异常'), responseTime: elapsed }; setTests(n)
} catch (e: unknown) {
const elapsed = Math.round(performance.now() - start)
const n = [...tests]; n[idx] = { ...t, status: 'error', message: e instanceof Error ? e.message : '失败', responseTime: elapsed }; setTests(n)
}
}
const testAll = async () => {
if (!testPhone.trim() && !testWechat.trim()) { alert('请填写测试手机号'); return }
for (let i = 0; i < tests.length; i++) await testOne(i)
}
async function loadConfig() {
try {
const data = await get<{ data?: { apiUrl?: string; apiKey?: string; docNotes?: string } }>('/api/db/config/full?key=ckb_config')
const c = data?.data
if (c?.apiUrl) setApiUrl(c.apiUrl)
if (c?.apiKey) setApiKey(c.apiKey)
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)
} catch {
// ignore
if (c?.docContent) setDocContent(c.docContent)
if (submittedRes?.success) setSubmittedLeads(submittedRes.records || [])
if (contactRes?.success) setContactLeads(contactRes.records || [])
} finally {
setLoading(false)
}
}
useEffect(() => { void loadWorkspace() }, [])
async function saveConfig() {
setIsSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'ckb_config',
value: { apiUrl, apiKey, docNotes },
value: { routes: routeMap, docNotes, docContent },
description: '存客宝接口配置',
})
alert(res?.success !== false ? '存客宝配置已保存' : `保存失败: ${res?.error || '未知错误'}`)
@@ -94,108 +137,242 @@ export function CKBConfigPanel() {
}
}
useEffect(() => { void loadConfig() }, [])
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()) { alert('请填写测试手机号'); 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()) { alert('请填写测试手机号'); 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>
<h3 className="text-white font-semibold"></h3>
<Badge className="bg-orange-500/20 text-orange-400 border-0 text-xs">CKB</Badge>
<a href="https://ckbapi.quwanzhi.com" target="_blank" rel="noreferrer"
className="text-orange-400/60 text-xs hover:text-orange-400 flex items-center gap-1">
<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
</a>
</button>
</div>
<Button onClick={testAll} size="sm" className="bg-orange-500 hover:bg-orange-600 text-white">
<Zap className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-5">
<div className="space-y-1">
<Label className="text-gray-500 text-xs">API </Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-9 text-sm" value={apiUrl} onChange={e => setApiUrl(e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-gray-500 text-xs">API Key</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-9 text-sm" value={apiKey} onChange={e => setApiKey(e.target.value)} />
</div>
<div className="flex items-end">
<Button onClick={saveConfig} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white w-full">
<Save className="w-4 h-4 mr-2" /> {isSaving ? '保存中...' : '保存配置'}
<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 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>
<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 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>
<div className="flex gap-3 mt-3 text-xs">
<a href="https://ckbapi.quwanzhi.com/v1/api/scenarios" target="_blank" rel="noreferrer"
className="text-gray-500 hover:text-orange-400 flex items-center gap-1">
<FileText className="w-3 h-3" /> API
</a>
<span className="text-gray-700">|</span>
<span className="text-gray-600">Key: {apiKey ? `${apiKey.slice(0, 8)}...` : '未配置'}</span>
</div>
<div className="mt-5 grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="bg-[#0a1628] rounded-lg border border-gray-700/30 p-4">
<h4 className="text-white text-sm font-medium mb-3"></h4>
<div className="space-y-2 text-xs text-gray-400">
<p><span className="text-gray-300"></span><code className="text-orange-300">POST /v1/api/scenarios</code></p>
<p><span className="text-gray-300"></span><code>apiKey</code><code>sign</code><code>timestamp</code></p>
<p><span className="text-gray-300"></span><code>phone</code> <code>wechatId</code></p>
<p><span className="text-gray-300"></span><code>name</code><code>source</code><code>remark</code><code>tags</code><code>siteTags</code><code>portrait</code></p>
<p><span className="text-gray-300"></span> <code>sign/apiKey/portrait</code> MD5</p>
<p><span className="text-gray-300"></span><code>{"{ code: 200, message: '新增成功|已存在' }"}</code></p>
{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>
<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-[140px] 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、计划说明、接口对接约定、回复率统计规则等。"
/>
)}
{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>
</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>
)