Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	soul-admin/src/api/ckb.ts
#	soul-admin/src/pages/content/PersonAddEditModal.tsx
#	soul-api/internal/model/person.go
#	开发文档/1、需求/以界面定需求.md
#	开发文档/1、需求/需求汇总.md
This commit is contained in:
Alex-larget
2026-03-18 21:10:02 +08:00
460 changed files with 92262 additions and 3962 deletions

914
soul-admin/dist/assets/index-34teBEu9.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-DyqIjjBz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-o3d5k2lQ.css">
<script type="module" crossorigin src="/assets/index-34teBEu9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B7tt33mg.css">
</head>
<body>
<div id="root"></div>

View File

@@ -77,6 +77,7 @@ export interface CkbPlansResponse {
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))
@@ -85,3 +86,5 @@ export function getCkbPlans(params?: { page?: number; limit?: number; keyword?:
const qs = search.toString()
return get<CkbPlansResponse>(qs ? `/api/admin/ckb/plans?${qs}` : '/api/admin/ckb/plans')
}

View File

@@ -28,6 +28,7 @@ import {
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Pagination } from '@/components/ui/Pagination'
import {
BookOpen,
Settings2,
@@ -48,6 +49,7 @@ import {
Pencil,
Smartphone,
Copy,
Users,
} from 'lucide-react'
import { LinkedMpPage } from '@/pages/linked-mp/LinkedMpPage'
import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client'
@@ -233,10 +235,16 @@ export function ContentPage() {
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
const [persons, setPersons] = useState<PersonItem[]>([])
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
const [personModalOpen, setPersonModalOpen] = useState(false)
const [editingPerson, setEditingPerson] = useState<PersonItem | null>(null)
const [personToDelete, setPersonToDelete] = useState<PersonItem | null>(null)
const [newLinkTag, setNewLinkTag] = useState({
const [linkTagList, setLinkTagList] = useState<LinkTagItem[]>([])
const [linkTagListLoading, setLinkTagListLoading] = useState(false)
const [linkTagPage, setLinkTagPage] = useState(1)
const [linkTagPageSize, setLinkTagPageSize] = useState(20)
const [linkTagTotal, setLinkTagTotal] = useState(0)
const [linkTagTotalPages, setLinkTagTotalPages] = useState(1)
const [linkTagSearch, setLinkTagSearch] = useState('')
const [linkTagModalOpen, setLinkTagModalOpen] = useState(false)
const [linkTagEditing, setLinkTagEditing] = useState<LinkTagItem | null>(null)
const [linkTagForm, setLinkTagForm] = useState({
tagId: '',
label: '',
url: '',
@@ -244,7 +252,19 @@ export function ContentPage() {
appId: '',
pagePath: '',
})
const [editingLinkTagId, setEditingLinkTagId] = useState<string | null>(null)
const [linkTagSaving, setLinkTagSaving] = useState(false)
const [personModalOpen, setPersonModalOpen] = useState(false)
const [editingPerson, setEditingPerson] = useState<PersonItem | null>(null)
const [personToDelete, setPersonToDelete] = useState<PersonItem | null>(null)
// CKB 获客统计(按人物 token 聚合)
const [ckbLeadCounts, setCkbLeadCounts] = useState<Record<string, number>>({})
const [ckbLeadDetailOpen, setCkbLeadDetailOpen] = useState(false)
const [ckbLeadDetailToken, setCkbLeadDetailToken] = useState('')
const [ckbLeadDetailName, setCkbLeadDetailName] = useState('')
const [ckbLeadRecords, setCkbLeadRecords] = useState<{ id: number; userId: string; nickname: string; phone: string; wechatId: string; name: string; source: string; createdAt: string }[]>([])
const [ckbLeadTotal, setCkbLeadTotal] = useState(0)
const [ckbLeadPage, setCkbLeadPage] = useState(1)
const [ckbLeadLoading, setCkbLeadLoading] = useState(false)
const richEditorRef = useRef<RichEditorRef>(null)
const tree = buildTree(sectionsList)
@@ -422,6 +442,7 @@ export function ContentPage() {
personId: string
token?: string
name: string
aliases?: string
label?: string
ckbApiKey?: string
ckbPlanId?: number
@@ -442,6 +463,7 @@ export function ContentPage() {
id: p.token ?? p.personId ?? '',
personId: p.personId,
name: p.name,
aliases: p.aliases ?? '',
label: p.label ?? '',
ckbApiKey: p.ckbApiKey ?? '',
ckbPlanId: p.ckbPlanId,
@@ -481,6 +503,80 @@ export function ContentPage() {
}
}, [])
const loadCkbLeadCounts = useCallback(async () => {
try {
const data = await get<{ success?: boolean; byPerson?: { token: string; total: number }[] }>('/api/db/ckb-person-leads')
if (data?.success && data.byPerson) {
const m: Record<string, number> = {}
for (const item of data.byPerson) m[item.token] = item.total
setCkbLeadCounts(m)
}
} catch { /* ignore */ }
}, [])
const openCkbLeadDetail = useCallback(async (token: string, name: string, page = 1) => {
setCkbLeadDetailToken(token)
setCkbLeadDetailName(name)
setCkbLeadDetailOpen(true)
setCkbLeadPage(page)
setCkbLeadLoading(true)
try {
const data = await get<{ success?: boolean; records?: typeof ckbLeadRecords; total?: number; personName?: string; error?: string }>(
`/api/db/ckb-person-leads?token=${encodeURIComponent(token)}&page=${page}&pageSize=20`,
)
if (data?.success) {
setCkbLeadRecords(data.records || [])
setCkbLeadTotal(data.total || 0)
} else {
toast.error(data?.error || '加载获客详情失败')
}
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载获客详情失败')
} finally {
setCkbLeadLoading(false)
}
}, [])
const loadLinkTagList = useCallback(async () => {
setLinkTagListLoading(true)
try {
const qs = new URLSearchParams({
page: String(linkTagPage),
pageSize: String(linkTagPageSize),
})
const s = linkTagSearch.trim()
if (s) qs.set('search', s)
const data = await get<{
success?: boolean
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
total?: number
page?: number
pageSize?: number
totalPages?: number
}>(`/api/db/link-tags?${qs.toString()}`)
if (data?.success) {
const items = Array.isArray(data.linkTags) ? data.linkTags : []
setLinkTagList(
items.map((t) => ({
id: t.tagId,
label: t.label,
url: t.url,
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
appId: t.appId || '',
pagePath: t.pagePath || '',
})),
)
setLinkTagTotal(typeof data.total === 'number' ? data.total : 0)
setLinkTagTotalPages(typeof data.totalPages === 'number' && data.totalPages > 0 ? data.totalPages : 1)
}
} catch (e) {
console.error(e)
toast.error('加载链接标签失败')
} finally {
setLinkTagListLoading(false)
}
}, [linkTagPage, linkTagPageSize, linkTagSearch])
const [linkedMps, setLinkedMps] = useState<{ key: string; name: string; appId: string; path?: string }[]>([])
const [mpSearchQuery, setMpSearchQuery] = useState('')
const [mpDropdownOpen, setMpDropdownOpen] = useState(false)
@@ -551,8 +647,13 @@ export function ContentPage() {
loadPreviewPercent()
loadPersons()
loadLinkTags()
loadCkbLeadCounts()
loadLinkedMps()
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadLinkedMps])
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps])
useEffect(() => {
loadLinkTagList()
}, [loadLinkTagList])
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
setSectionOrdersModal({ section, orders: [] })
@@ -2264,6 +2365,7 @@ export function ContentPage() {
<tr className="text-xs text-gray-500 border-b border-gray-700/50">
<th className="text-left py-1.5 px-3 w-[280px] font-normal">token</th>
<th className="text-left py-1.5 px-3 w-24 font-normal">@的人</th>
<th className="py-1.5 px-3 w-16 font-normal text-center"></th>
<th className="text-left py-1.5 px-3 font-normal"></th>
<th className="text-left py-1.5 px-3 w-20 font-normal">planId</th>
<th className="text-left py-1.5 px-3 font-normal">apiKey</th>
@@ -2275,6 +2377,17 @@ export function ContentPage() {
<tr key={p.id} className="border-b border-gray-700/30 hover:bg-[#0a1628]/80">
<td className="py-2 px-3 text-gray-400 text-xs font-mono" title="32位token">{p.id}</td>
<td className="py-2 px-3 text-amber-400 truncate max-w-[96px]" title="@的人">{p.name}</td>
{(() => {
const leadCount = ckbLeadCounts[p.id] || 0
return (
<td
className={`py-2 px-3 shrink-0 w-16 text-center text-xs font-bold ${leadCount > 0 ? 'text-green-400' : 'text-gray-600'}`}
title="获客数"
>
{leadCount}
</td>
)
})()}
<td className="py-2 px-3 text-white truncate max-w-[200px]" title="获客计划活动名">SOUL链接人与事-{p.name}</td>
<td className="py-2 px-3 text-gray-400 text-xs font-mono" title="存客宝计划ID">{p.ckbPlanId ?? '-'}</td>
<td className="py-2 px-3 text-gray-400 text-xs font-mono whitespace-nowrap">
@@ -2335,6 +2448,15 @@ export function ContentPage() {
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-gray-400 hover:text-green-400 h-6 px-2"
title="查看新客户"
onClick={() => openCkbLeadDetail(p.id, p.name)}
>
<Users className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" className="text-gray-400 hover:text-amber-400 h-6 px-2" title="编辑计划(跳转存客宝)" onClick={() => {
const planId = (p as { ckbPlanId?: number }).ckbPlanId
if (planId) {
@@ -2417,209 +2539,375 @@ export function ContentPage() {
<p className="text-xs text-gray-500 mt-1"> # </p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2 items-end flex-wrap justify-between">
<div className="flex gap-2 items-end flex-wrap">
<div className="space-y-2">
<Label className="text-gray-400 text-xs">ID</Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-24" placeholder="如 team01" value={newLinkTag.tagId} onChange={e => setNewLinkTag({ ...newLinkTag, tagId: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-28" placeholder="如 神仙团队" value={newLinkTag.label} onChange={e => setNewLinkTag({ ...newLinkTag, label: e.target.value })} />
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Select value={newLinkTag.type} onValueChange={v => setNewLinkTag({ ...newLinkTag, type: v as 'url' | 'miniprogram' | 'ckb' })}>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url"></SelectItem>
<SelectItem value="miniprogram"></SelectItem>
<SelectItem value="ckb"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-gray-400 text-xs">
{newLinkTag.type === 'url' ? 'URL地址' : newLinkTag.type === 'ckb' ? '存客宝计划URL' : '小程序(选密钥)'}
</Label>
{newLinkTag.type === 'miniprogram' && linkedMps.length > 0 ? (
<div ref={mpDropdownRef} className="relative w-44">
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 w-44"
placeholder="搜索名称或密钥"
value={mpDropdownOpen ? mpSearchQuery : newLinkTag.appId}
onChange={(e) => {
const v = e.target.value
setMpSearchQuery(v)
setMpDropdownOpen(true)
if (!linkedMps.some((m) => m.key === v)) setNewLinkTag({ ...newLinkTag, appId: v })
}}
onFocus={() => {
setMpSearchQuery(newLinkTag.appId)
setMpDropdownOpen(true)
}}
onBlur={() => setTimeout(() => setMpDropdownOpen(false), 150)}
/>
{mpDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 max-h-48 overflow-y-auto rounded-md border border-gray-700 bg-[#0a1628] shadow-lg z-50">
{filteredLinkedMps.length === 0 ? (
<div className="px-3 py-2 text-gray-500 text-xs"></div>
) : (
filteredLinkedMps.map((m) => (
<button
key={m.key}
type="button"
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#38bdac]/20 flex flex-col gap-0.5"
onMouseDown={(e) => {
e.preventDefault()
setNewLinkTag({ ...newLinkTag, appId: m.key, pagePath: m.path || '' })
setMpSearchQuery('')
setMpDropdownOpen(false)
}}
>
<span>{m.name}</span>
<span className="text-xs text-gray-400 font-mono">{m.key}</span>
</button>
))
)}
</div>
)}
</div>
) : (
<div className="flex items-end justify-between gap-3 flex-wrap">
<div className="flex items-end gap-2 flex-wrap">
<div className="space-y-1">
<Label className="text-gray-400 text-xs"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 w-44"
placeholder={newLinkTag.type === 'url' ? 'https://...' : newLinkTag.type === 'ckb' ? 'https://ckbapi.quwanzhi.com/...' : '关联小程序的32位密钥'}
value={newLinkTag.type === 'url' || newLinkTag.type === 'ckb' ? newLinkTag.url : newLinkTag.appId}
className="bg-[#0a1628] border-gray-700 text-white h-8 w-48"
placeholder="按标签ID/显示文字搜索"
value={linkTagSearch}
onChange={(e) => {
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
else setNewLinkTag({ ...newLinkTag, appId: e.target.value })
setLinkTagSearch(e.target.value)
setLinkTagPage(1)
}}
/>
)}
</div>
{newLinkTag.type === 'miniprogram' && (
<div className="space-y-2">
<Label className="text-gray-400 text-xs"></Label>
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="pages/index/index" value={newLinkTag.pagePath} onChange={e => setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
</div>
)}
<Button
variant="outline"
size="sm"
className="border-gray-600 text-gray-400 hover:bg-gray-700/50 h-8"
onClick={() => {
loadLinkTags()
loadLinkTagList()
}}
title="刷新"
>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
<Button
size="sm"
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
onClick={async () => {
if (!newLinkTag.tagId || !newLinkTag.label) {
toast.error('标签ID和显示文字必填')
return
}
const payload = { ...newLinkTag }
if (payload.type === 'miniprogram') payload.url = ''
await post('/api/db/link-tags', payload)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
setEditingLinkTagId(null)
loadLinkTags()
onClick={() => {
setLinkTagEditing(null)
setLinkTagForm({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
setMpSearchQuery('')
setMpDropdownOpen(false)
setLinkTagModalOpen(true)
}}
>
<Plus className="w-3 h-3 mr-1" />
{editingLinkTagId ? '保存' : '添加'}
</Button>
</div>
<Button
variant="outline"
size="sm"
className="border-gray-600 text-gray-400 hover:bg-gray-700/50 h-8"
onClick={() => loadLinkTags()}
title="刷新"
>
<RefreshCw className="w-4 h-4" />
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{linkTags.map((t) => (
<div key={t.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
<div className="flex items-center gap-3 text-sm">
<button
type="button"
className="text-amber-400 font-bold text-base hover:underline"
onClick={() => {
setNewLinkTag({
tagId: t.id,
label: t.label,
url: t.url,
type: t.type,
appId: t.appId ?? '',
pagePath: t.pagePath ?? '',
})
setEditingLinkTagId(t.id)
}}
>
#{t.label}
</button>
<Badge
variant="secondary"
className={`text-[10px] ${
t.type === 'ckb'
? 'bg-green-500/20 text-green-300 border-green-500/30'
: 'bg-gray-700 text-gray-300'
}`}
>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
</Badge>
{t.type === 'miniprogram' ? (
<span className="text-gray-400 text-xs font-mono">{t.appId} {t.pagePath ? `· ${t.pagePath}` : ''}</span>
) : t.url ? (
<a
href={t.url}
target="_blank"
rel="noreferrer"
className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1"
>
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
) : null}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-gray-300 hover:text-white h-6 px-2"
onClick={() => {
setNewLinkTag({
tagId: t.id,
label: t.label,
url: t.url,
type: t.type,
appId: t.appId ?? '',
pagePath: t.pagePath ?? '',
})
setEditingLinkTagId(t.id)
}}
>
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300 h-6 px-2"
onClick={async () => {
await del(`/api/db/link-tags?tagId=${t.id}`)
if (editingLinkTagId === t.id) {
setEditingLinkTagId(null)
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
}
loadLinkTags()
}}
>
<X className="w-3 h-3" />
</Button>
</div>
</div>
))}
{linkTags.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">使 # </div>}
<div className="rounded-md border border-gray-700/50 overflow-hidden">
<div className="max-h-[420px] overflow-y-auto">
<table className="w-full text-sm">
<thead className="bg-[#0a1628] border-b border-gray-700/50">
<tr>
<th className="text-left px-3 py-2 text-gray-400 w-40"></th>
<th className="text-left px-3 py-2 text-gray-400 w-20"></th>
<th className="text-left px-3 py-2 text-gray-400"></th>
<th className="text-right px-3 py-2 text-gray-400 w-28"></th>
</tr>
</thead>
<tbody>
{linkTagListLoading ? (
<tr>
<td colSpan={4} className="text-center py-10 text-gray-500">
...
</td>
</tr>
) : linkTagList.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-10 text-gray-500">
使 #
</td>
</tr>
) : (
linkTagList.map((t) => (
<tr key={t.id} className="border-b border-gray-700/30 hover:bg-white/5">
<td className="px-3 py-2">
<div className="text-amber-400 font-semibold">#{t.label}</div>
<div className="text-xs text-gray-500 font-mono">tagId: {t.id}</div>
</td>
<td className="px-3 py-2">
<Badge
variant="secondary"
className={`text-[10px] ${
t.type === 'ckb'
? 'bg-green-500/20 text-green-300 border-green-500/30'
: t.type === 'miniprogram'
? 'bg-[#38bdac]/20 text-[#38bdac] border-[#38bdac]/30'
: 'bg-gray-700 text-gray-300'
}`}
>
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
</Badge>
</td>
<td className="px-3 py-2 text-gray-300">
{t.type === 'miniprogram' ? (
<span className="text-xs font-mono">
{t.appId || '—'} {t.pagePath ? `· ${t.pagePath}` : ''}
</span>
) : t.url ? (
<a
href={t.url}
target="_blank"
rel="noreferrer"
className="text-blue-400 text-xs truncate max-w-[420px] hover:underline inline-flex items-center gap-1"
>
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
</a>
) : (
<span className="text-gray-500 text-xs"></span>
)}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="text-gray-300 hover:text-white h-7 px-2"
onClick={() => {
setLinkTagEditing(t)
setLinkTagForm({
tagId: t.id,
label: t.label,
url: t.url,
type: t.type,
appId: t.appId ?? '',
pagePath: t.pagePath ?? '',
})
setMpSearchQuery(t.appId ?? '')
setMpDropdownOpen(false)
setLinkTagModalOpen(true)
}}
title="编辑"
>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300 h-7 px-2"
onClick={async () => {
if (!confirm(`确定要删除「#${t.label}」吗?`)) return
try {
const res = await del<{ success?: boolean; error?: string }>(
`/api/db/link-tags?tagId=${encodeURIComponent(t.id)}`,
)
if (res?.success) {
toast.success('已删除')
loadLinkTags()
loadLinkTagList()
} else {
toast.error(res?.error ?? '删除失败')
}
} catch (e) {
console.error(e)
toast.error('删除失败')
}
}}
title="删除"
>
<X className="w-3 h-3" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
page={linkTagPage}
pageSize={linkTagPageSize}
total={linkTagTotal}
totalPages={linkTagTotalPages}
onPageChange={(p) => setLinkTagPage(p)}
onPageSizeChange={(s) => {
setLinkTagPageSize(s)
setLinkTagPage(1)
}}
/>
</div>
</CardContent>
</Card>
<Dialog open={linkTagModalOpen} onOpenChange={setLinkTagModalOpen}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg p-4 gap-3">
<DialogHeader className="gap-1">
<DialogTitle className="text-base">{linkTagEditing ? '编辑链接标签' : '添加链接标签'}</DialogTitle>
<DialogDescription className="text-gray-400 text-xs">
#
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-gray-300 text-sm">ID</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm font-mono"
placeholder="留空自动生成;或填 12位数字 / z开头12位"
value={linkTagForm.tagId}
disabled={!!linkTagEditing}
onChange={(e) => setLinkTagForm((p) => ({ ...p, tagId: 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="如 神仙团队"
value={linkTagForm.label}
onChange={(e) => setLinkTagForm((p) => ({ ...p, label: e.target.value }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3 items-end">
<div className="space-y-1">
<Label className="text-gray-300 text-sm"></Label>
<Select
value={linkTagForm.type}
onValueChange={(v) =>
setLinkTagForm((p) => ({ ...p, type: v as 'url' | 'miniprogram' | 'ckb' }))
}
>
<SelectTrigger className="bg-[#0a1628] border-gray-700 text-white h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="url"></SelectItem>
<SelectItem value="miniprogram"></SelectItem>
<SelectItem value="ckb"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-gray-300 text-sm">
{linkTagForm.type === 'url'
? 'URL地址'
: linkTagForm.type === 'ckb'
? '存客宝计划URL'
: '小程序(选密钥)'}
</Label>
{linkTagForm.type === 'miniprogram' && linkedMps.length > 0 ? (
<div ref={mpDropdownRef} className="relative">
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
placeholder="搜索名称或密钥"
value={mpDropdownOpen ? mpSearchQuery : linkTagForm.appId}
onChange={(e) => {
const v = e.target.value
setMpSearchQuery(v)
setMpDropdownOpen(true)
if (!linkedMps.some((m) => m.key === v)) setLinkTagForm((p) => ({ ...p, appId: v }))
}}
onFocus={() => {
setMpSearchQuery(linkTagForm.appId)
setMpDropdownOpen(true)
}}
onBlur={() => setTimeout(() => setMpDropdownOpen(false), 150)}
/>
{mpDropdownOpen && (
<div className="absolute top-full left-0 right-0 mt-1 max-h-48 overflow-y-auto rounded-md border border-gray-700 bg-[#0a1628] shadow-lg z-50">
{filteredLinkedMps.length === 0 ? (
<div className="px-3 py-2 text-gray-500 text-xs"></div>
) : (
filteredLinkedMps.map((m) => (
<button
key={m.key}
type="button"
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-[#38bdac]/20 flex flex-col gap-0.5"
onMouseDown={(e) => {
e.preventDefault()
setLinkTagForm((p) => ({ ...p, appId: m.key, pagePath: m.path || '' }))
setMpSearchQuery('')
setMpDropdownOpen(false)
}}
>
<span>{m.name}</span>
<span className="text-xs text-gray-400 font-mono">{m.key}</span>
</button>
))
)}
</div>
)}
</div>
) : (
<Input
className="bg-[#0a1628] border-gray-700 text-white h-8 text-sm"
placeholder={
linkTagForm.type === 'url'
? 'https://...'
: linkTagForm.type === 'ckb'
? 'https://ckbapi.quwanzhi.com/...'
: '关联小程序的32位密钥'
}
value={linkTagForm.type === 'url' || linkTagForm.type === 'ckb' ? linkTagForm.url : linkTagForm.appId}
onChange={(e) => {
if (linkTagForm.type === 'url' || linkTagForm.type === 'ckb')
setLinkTagForm((p) => ({ ...p, url: e.target.value }))
else setLinkTagForm((p) => ({ ...p, appId: e.target.value }))
}}
/>
)}
</div>
</div>
{linkTagForm.type === 'miniprogram' && (
<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 font-mono"
placeholder="pages/index/index"
value={linkTagForm.pagePath}
onChange={(e) => setLinkTagForm((p) => ({ ...p, pagePath: e.target.value }))}
/>
</div>
)}
</div>
<DialogFooter className="gap-2 pt-1">
<Button variant="outline" onClick={() => setLinkTagModalOpen(false)} className="border-gray-600">
</Button>
<Button
onClick={async () => {
const payload = {
tagId: linkTagForm.tagId.trim(),
label: linkTagForm.label.trim(),
url: linkTagForm.url.trim(),
type: linkTagForm.type,
appId: linkTagForm.appId.trim(),
pagePath: linkTagForm.pagePath.trim(),
}
// 新增允许留空后端自动生成编辑tagId 已锁定
if (payload.tagId) {
const ok = /^\d{12}$/.test(payload.tagId) || /^z[a-z0-9]{11}$/.test(payload.tagId)
if (!ok) {
toast.error('标签ID需为12位数字或 z 开头的12位z+11位小写字母数字')
return
}
}
if (!payload.label) {
toast.error('显示文字必填')
return
}
if (payload.type === 'miniprogram') payload.url = ''
setLinkTagSaving(true)
try {
const res = await post<{ success?: boolean; error?: string }>('/api/db/link-tags', payload)
if (res?.success) {
toast.success(linkTagEditing ? '已更新' : '已添加')
setLinkTagModalOpen(false)
loadLinkTags()
loadLinkTagList()
} else {
toast.error(res?.error ?? '保存失败')
}
} catch (e) {
console.error(e)
toast.error('保存失败')
} finally {
setLinkTagSaving(false)
}
}}
disabled={linkTagSaving}
className="bg-amber-500 hover:bg-amber-600 text-white"
>
{linkTagSaving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
<TabsContent value="linkedmp" className="space-y-4">
@@ -2635,6 +2923,7 @@ export function ContentPage() {
const payload = {
personId: data.personId || (data.name.toLowerCase().replace(/\s+/g, '_') + '_' + Date.now().toString(36)),
name: data.name,
aliases: data.aliases || undefined,
label: data.label,
ckbApiKey: data.ckbApiKey || undefined,
greeting: data.greeting || undefined,
@@ -2684,7 +2973,7 @@ export function ContentPage() {
<DialogContent showCloseButton={true} className="bg-[#0f2137] border-gray-700 text-white max-w-md p-4 gap-3">
<DialogHeader className="gap-1">
<DialogTitle className="text-white text-base"></DialogTitle>
<DialogDescription className="text-gray-400 text-sm leading-relaxed break-words">
<DialogDescription className="text-gray-400 text-sm leading-relaxed wrap-break-word">
{personToDelete && (
<>
<p>SOUL链接人与事-{personToDelete.name}</p>
@@ -2714,6 +3003,78 @@ export function ContentPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* CKB 获客详情弹窗 */}
<Dialog open={ckbLeadDetailOpen} onOpenChange={setCkbLeadDetailOpen}>
<DialogContent className="max-w-2xl bg-[#0f2137] border-gray-700">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-green-400" />
{ckbLeadDetailName} {ckbLeadTotal}
</DialogTitle>
</DialogHeader>
<div className="max-h-[450px] overflow-y-auto space-y-2">
{ckbLeadLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : ckbLeadRecords.length === 0 ? (
<div className="text-gray-500 text-sm py-8 text-center"></div>
) : (
<>
<div className="grid grid-cols-[60px_1fr_100px_100px_80px_120px] gap-2 px-3 py-1.5 text-xs text-gray-500 border-b border-gray-700/50">
<span>#</span>
<span>/</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{ckbLeadRecords.map((r, i) => (
<div key={r.id} className="grid grid-cols-[60px_1fr_100px_100px_80px_120px] gap-2 px-3 py-2 bg-[#0a1628] rounded text-sm">
<span className="text-gray-500 text-xs">{(ckbLeadPage - 1) * 20 + i + 1}</span>
<span className="text-white truncate">{r.nickname || r.name || r.userId || '-'}</span>
<span className="text-gray-300 text-xs">{r.phone || '-'}</span>
<span className="text-gray-300 text-xs truncate">{r.wechatId || '-'}</span>
<span className="text-gray-500 text-xs">{r.source === 'article_mention' ? '文章@' : r.source === 'index_lead' ? '首页' : r.source || '-'}</span>
<span className="text-gray-500 text-xs">
{r.createdAt
? new Date(r.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
: '-'}
</span>
</div>
))}
</>
)}
</div>
{ckbLeadTotal > 20 && (
<div className="flex items-center justify-center gap-2 pt-2">
<Button
size="sm"
variant="outline"
disabled={ckbLeadPage <= 1}
onClick={() => openCkbLeadDetail(ckbLeadDetailToken, ckbLeadDetailName, ckbLeadPage - 1)}
className="border-gray-600 text-gray-300 bg-transparent h-7 px-3"
>
</Button>
<span className="text-gray-400 text-xs">
{ckbLeadPage} / {Math.ceil(ckbLeadTotal / 20)}
</span>
<Button
size="sm"
variant="outline"
disabled={ckbLeadPage >= Math.ceil(ckbLeadTotal / 20)}
onClick={() => openCkbLeadDetail(ckbLeadDetailToken, ckbLeadDetailName, ckbLeadPage + 1)}
className="border-gray-600 text-gray-300 bg-transparent h-7 px-3"
>
</Button>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -130,10 +130,11 @@ export function PersonAddEditModal({
}
setErrors({})
// 懒加载设备列表和计划列表
// 懒加载设备列表:仅在第一次需要时加载
if (deviceOptions.length === 0) {
void loadDevices('')
}
// 懒加载计划列表:用于一键覆盖参数
if (planOptions.length === 0) {
void loadPlans('')
}
@@ -174,7 +175,7 @@ export function PersonAddEditModal({
const selectPlan = (plan: CkbPlan) => {
const dg = Array.isArray(plan.deviceGroups) ? plan.deviceGroups.map(String).join(',') : ''
setForm(f => ({
setForm((f) => ({
...f,
ckbApiKey: plan.apiKey || '',
greeting: plan.greeting || f.greeting,
@@ -191,7 +192,9 @@ export function PersonAddEditModal({
}
const filteredPlans = planKeyword.trim()
? planOptions.filter(p => (p.name || '').includes(planKeyword.trim()) || String(p.id).includes(planKeyword.trim()))
? planOptions.filter(
(p) => (p.name || '').includes(planKeyword.trim()) || String(p.id).includes(planKeyword.trim()),
)
: planOptions
const handleSubmit = async () => {
@@ -310,7 +313,8 @@ export function PersonAddEditModal({
>
{form.ckbApiKey ? (
<span className="text-white truncate">
{planOptions.find(p => p.apiKey === form.ckbApiKey)?.name || `密钥: ${form.ckbApiKey.slice(0, 20)}...`}
{planOptions.find((p) => p.apiKey === form.ckbApiKey)?.name ||
`密钥: ${form.ckbApiKey.slice(0, 20)}...`}
</span>
) : (
<span className="text-gray-500"> / </span>
@@ -321,7 +325,10 @@ export function PersonAddEditModal({
variant="outline"
size="sm"
className="border-gray-600 text-gray-200 shrink-0"
onClick={() => { void loadPlans(planKeyword); setPlanDropdownOpen(true) }}
onClick={() => {
void loadPlans(planKeyword)
setPlanDropdownOpen(true)
}}
disabled={planLoading}
>
{planLoading ? '加载...' : '刷新'}
@@ -334,8 +341,10 @@ export function PersonAddEditModal({
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) }}
onChange={(e) => setPlanKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void loadPlans(planKeyword)
}}
autoFocus
/>
</div>
@@ -344,26 +353,44 @@ export function PersonAddEditModal({
<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>
) : (
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>
{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>
<Button
type="button"
size="sm"
variant="ghost"
className="text-gray-400 h-7 text-xs"
onClick={() => setPlanDropdownOpen(false)}
>
</Button>
</div>
</div>
)}

View File

@@ -1138,6 +1138,7 @@ export function DistributionPage() {
<option value="pending"></option>
<option value="pending_pay"></option>
<option value="paid"></option>
<option value="refunded">退</option>
<option value="cancelled"></option>
<option value="expired"></option>
</select>
@@ -1184,10 +1185,20 @@ export function DistributionPage() {
? 'bg-green-500/20 text-green-400 border-0'
: r.status === 'pending' || r.status === 'pending_pay'
? 'bg-amber-500/20 text-amber-400 border-0'
: r.status === 'refunded'
? 'bg-red-500/20 text-red-400 border-0'
: 'bg-gray-500/20 text-gray-400 border-0'
}
>
{r.status === 'paid' ? '已支付' : r.status === 'pending' || r.status === 'pending_pay' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
{r.status === 'paid'
? '已支付'
: r.status === 'pending' || r.status === 'pending_pay'
? '待支付'
: r.status === 'refunded'
? '已退款'
: r.status === 'cancelled'
? '已取消'
: '已过期'}
</Badge>
</td>
<td className="p-4 text-gray-400 text-sm">