稳定版本构建完成

This commit is contained in:
Alex-larget
2026-03-18 21:06:16 +08:00
parent d6cdd6fdba
commit c4f737c829
411 changed files with 90567 additions and 1216 deletions

View File

@@ -53,4 +53,38 @@ export function getPersonDetail(personId: string) {
return get<PersonDetailResponse>(`/api/db/person?personId=${encodeURIComponent(personId)}`)
}
export interface CkbPlan {
id: number | string
name: string
apiKey?: string
sceneId?: number
scenario?: number
enabled?: boolean
greeting?: string
tips?: string
remarkType?: string
remarkFormat?: string
addInterval?: number
startTime?: string
endTime?: string
deviceGroups?: (number | string)[]
}
export interface CkbPlansResponse {
success?: boolean
error?: string
plans?: CkbPlan[]
total?: number
}
// 管理端 - 存客宝获客计划列表(供链接人与事选择计划一键覆盖参数)
export function getCkbPlans(params?: { page?: number; limit?: number; keyword?: string }) {
const search = new URLSearchParams()
if (params?.page) search.set('page', String(params.page))
if (params?.limit) search.set('limit', String(params.limit))
if (params?.keyword?.trim()) search.set('keyword', params.keyword.trim())
const qs = search.toString()
return get<CkbPlansResponse>(qs ? `/api/admin/ckb/plans?${qs}` : '/api/admin/ckb/plans')
}

View File

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

@@ -23,11 +23,12 @@ import {
SelectValue,
} from '@/components/ui/select'
import toast from '@/utils/toast'
import { getCkbDevices, type CkbDevice } from '@/api/ckb'
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
@@ -46,6 +47,7 @@ const SCENE_ID_API = 11 // API获客固定
const defaultForm: PersonFormData = {
personId: '',
name: '',
aliases: '',
label: '',
sceneId: SCENE_ID_API,
ckbApiKey: '',
@@ -65,6 +67,7 @@ interface PersonAddEditModalProps {
editingPerson?: {
personId?: string
name: string
aliases?: string
label?: string
ckbApiKey?: string
remarkType?: string
@@ -90,6 +93,10 @@ export function PersonAddEditModal({
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
@@ -105,6 +112,7 @@ export function PersonAddEditModal({
setForm({
personId: editingPerson.personId ?? editingPerson.name ?? '',
name: editingPerson.name ?? '',
aliases: editingPerson.aliases ?? '',
label: editingPerson.label ?? '',
sceneId: SCENE_ID_API,
ckbApiKey: editingPerson.ckbApiKey ?? '',
@@ -126,6 +134,10 @@ export function PersonAddEditModal({
if (deviceOptions.length === 0) {
void loadDevices('')
}
// 懒加载计划列表:用于一键覆盖参数
if (planOptions.length === 0) {
void loadPlans('')
}
}
}, [open, editingPerson])
@@ -145,6 +157,46 @@ export function PersonAddEditModal({
}
}
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
@@ -234,6 +286,15 @@ export function PersonAddEditModal({
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>
@@ -243,16 +304,98 @@ export function PersonAddEditModal({
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
{/* 左列:计划密钥与设备 */}
<div className="space-y-4">
<div className="space-y-1.5">
<Label className="text-gray-400 text-xs"> apiKey</Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="创建计划成功后自动回填,不可手动修改"
value={form.ckbApiKey}
readOnly
/>
<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">
apiKey @人物
</p>
</div>
<div className="space-y-1.5">