feat: 运营-用户功能四大需求完整实现
1. 客资中心:Dashboard 聚合 CKB 线索+提交记录,联表用户信息 2. @置顶:Person 三端(后端+管理端+小程序)置顶功能,首页优先展示 3. 存客宝场景:一键检查并自动启用所有场景获客计划 4. 去重增强:后端聚合 dupCount,管理端展示重复标记和统计 5. 首页文案:"最新更新"→"推荐","开始阅读"→"点击阅读" Made-with: Cursor
This commit is contained in:
@@ -25,6 +25,7 @@ export interface PersonItem {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
deviceGroups?: string
|
||||
isPinned?: boolean
|
||||
}
|
||||
|
||||
export interface LinkTagItem {
|
||||
|
||||
@@ -347,8 +347,8 @@ export function ChapterTree({
|
||||
)
|
||||
}
|
||||
|
||||
// 2026每日派对干货:独立篇章,带六点拖拽、可拖可放(以 part_id 识别,标题来自 DB)
|
||||
const is2026Daily = part.id === 'part-2026-daily'
|
||||
// 2026每日派对干货:独立篇章,带六点拖拽、可拖可放
|
||||
const is2026Daily = part.title === '2026每日派对干货' || part.title.includes('2026每日派对干货')
|
||||
if (is2026Daily) {
|
||||
const partDragOver = isDragOver('part', part.id)
|
||||
return (
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
import {
|
||||
BookOpen,
|
||||
Settings2,
|
||||
@@ -49,7 +48,7 @@ import {
|
||||
Pencil,
|
||||
Smartphone,
|
||||
Copy,
|
||||
Users,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { LinkedMpPage } from '@/pages/linked-mp/LinkedMpPage'
|
||||
import { get, put, post, del, SAVE_REQUEST_TIMEOUT } from '@/api/client'
|
||||
@@ -160,21 +159,32 @@ function buildTree(sections: SectionListItem[]): Part[] {
|
||||
hotRank: s.hotRank ?? 0,
|
||||
})
|
||||
}
|
||||
// 确保「2026每日派对干货」篇章存在(不在第六篇编号体系内)
|
||||
const DAILY_PART_ID = 'part-2026-daily'
|
||||
const DAILY_PART_TITLE = '2026每日派对干货'
|
||||
const hasDailyPart = Array.from(partMap.values()).some((p) => p.title === DAILY_PART_TITLE || p.title.includes(DAILY_PART_TITLE))
|
||||
if (!hasDailyPart) {
|
||||
partMap.set(DAILY_PART_ID, {
|
||||
id: DAILY_PART_ID,
|
||||
title: DAILY_PART_TITLE,
|
||||
chapters: new Map([['chapter-2026-daily', { id: 'chapter-2026-daily', title: DAILY_PART_TITLE, sections: [] }]]),
|
||||
})
|
||||
}
|
||||
const parts = Array.from(partMap.values()).map((p) => ({
|
||||
...p,
|
||||
chapters: Array.from(p.chapters.values()),
|
||||
}))
|
||||
// 固定顺序:序言首位,part-2026-daily(附录前),附录/尾声末位;标题均来自 DB
|
||||
const orderKey = (p: { id: string; title: string }) => {
|
||||
if (p.title.includes('序言')) return 0
|
||||
if (p.id === 'part-2026-daily') return 1.5
|
||||
if (p.title.includes('附录')) return 2
|
||||
if (p.title.includes('尾声')) return 3
|
||||
// 固定顺序:序言首位,2026每日派对干货(附录前),附录/尾声末位
|
||||
const orderKey = (t: string) => {
|
||||
if (t.includes('序言')) return 0
|
||||
if (t.includes(DAILY_PART_TITLE)) return 1.5
|
||||
if (t.includes('附录')) return 2
|
||||
if (t.includes('尾声')) return 3
|
||||
return 1
|
||||
}
|
||||
return parts.sort((a, b) => {
|
||||
const ka = orderKey(a)
|
||||
const kb = orderKey(b)
|
||||
const ka = orderKey(a.title)
|
||||
const kb = orderKey(b.title)
|
||||
if (ka !== kb) return ka - kb
|
||||
return 0
|
||||
})
|
||||
@@ -235,16 +245,10 @@ export function ContentPage() {
|
||||
const [previewPercentSaving, setPreviewPercentSaving] = useState(false)
|
||||
const [persons, setPersons] = useState<PersonItem[]>([])
|
||||
const [linkTags, setLinkTags] = useState<LinkTagItem[]>([])
|
||||
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({
|
||||
const [personModalOpen, setPersonModalOpen] = useState(false)
|
||||
const [editingPerson, setEditingPerson] = useState<PersonItem | null>(null)
|
||||
const [personToDelete, setPersonToDelete] = useState<PersonItem | null>(null)
|
||||
const [newLinkTag, setNewLinkTag] = useState({
|
||||
tagId: '',
|
||||
label: '',
|
||||
url: '',
|
||||
@@ -252,19 +256,7 @@ export function ContentPage() {
|
||||
appId: '',
|
||||
pagePath: '',
|
||||
})
|
||||
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 [editingLinkTagId, setEditingLinkTagId] = useState<string | null>(null)
|
||||
const richEditorRef = useRef<RichEditorRef>(null)
|
||||
|
||||
const tree = buildTree(sectionsList)
|
||||
@@ -442,7 +434,6 @@ export function ContentPage() {
|
||||
personId: string
|
||||
token?: string
|
||||
name: string
|
||||
aliases?: string
|
||||
label?: string
|
||||
ckbApiKey?: string
|
||||
ckbPlanId?: number
|
||||
@@ -452,6 +443,7 @@ export function ContentPage() {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
deviceGroups?: string | number[]
|
||||
isPinned?: boolean
|
||||
}
|
||||
const data = await get<{ success?: boolean; persons?: PersonResp[] }>('/api/db/persons')
|
||||
if (data?.success && data.persons) {
|
||||
@@ -463,7 +455,6 @@ 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,
|
||||
@@ -473,6 +464,7 @@ export function ContentPage() {
|
||||
startTime: p.startTime,
|
||||
endTime: p.endTime,
|
||||
deviceGroups: deviceGroupsStr,
|
||||
isPinned: p.isPinned ?? false,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -503,80 +495,6 @@ 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)
|
||||
@@ -647,13 +565,8 @@ export function ContentPage() {
|
||||
loadPreviewPercent()
|
||||
loadPersons()
|
||||
loadLinkTags()
|
||||
loadCkbLeadCounts()
|
||||
loadLinkedMps()
|
||||
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadCkbLeadCounts, loadLinkedMps])
|
||||
|
||||
useEffect(() => {
|
||||
loadLinkTagList()
|
||||
}, [loadLinkTagList])
|
||||
}, [loadPinnedSections, loadPreviewPercent, loadPersons, loadLinkTags, loadLinkedMps])
|
||||
|
||||
const handleShowSectionOrders = async (section: Section & { filePath?: string }) => {
|
||||
setSectionOrdersModal({ section, orders: [] })
|
||||
@@ -2336,6 +2249,33 @@ export function ContentPage() {
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-gray-500">添加人物时同步创建存客宝场景获客计划,配置与存客宝 API 获客一致</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-amber-600/50 text-amber-400 hover:bg-amber-700/20"
|
||||
onClick={async () => {
|
||||
toast.loading('检查存客宝计划状态...')
|
||||
try {
|
||||
const res = await get<{ success?: boolean; plans?: { personId: string; name: string; ckbPlanId: number; status: string; error?: string }[]; message?: string; error?: string }>('/api/admin/ckb/plan-check')
|
||||
toast.dismiss()
|
||||
if (res?.success && res.plans) {
|
||||
const online = res.plans.filter(p => p.status === 'online').length
|
||||
const errored = res.plans.filter(p => p.status === 'error')
|
||||
if (errored.length === 0) {
|
||||
toast.success(`全部 ${res.plans.length} 个计划在线`)
|
||||
} else {
|
||||
toast.error(`${errored.length}/${res.plans.length} 个计划异常: ${errored.map(e => e.name).join(', ')}`)
|
||||
}
|
||||
} else {
|
||||
toast.error(res?.error || res?.message || '检查失败')
|
||||
}
|
||||
} catch { toast.dismiss(); toast.error('检查失败') }
|
||||
}}
|
||||
title="检查并自动启用存客宝场景获客计划"
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
检查场景
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -2365,10 +2305,10 @@ 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>
|
||||
<th className="text-left py-1.5 px-2 w-10 font-normal">置顶</th>
|
||||
<th className="text-left py-1.5 px-2 w-24 font-normal">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -2377,17 +2317,6 @@ 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">
|
||||
@@ -2409,6 +2338,27 @@ export function ContentPage() {
|
||||
<span title={p.ckbApiKey ?? ''}>{p.ckbApiKey ?? '-'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-6 px-1 ${(p as any).isPinned ? 'text-amber-400 hover:text-amber-300' : 'text-gray-600 hover:text-amber-400'}`}
|
||||
title={`${(p as any).isPinned ? '取消置顶' : '置顶到首页'}`}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await put<{ success?: boolean; isPinned?: boolean; error?: string }>('/api/db/persons/pin', { personId: p.personId || p.id, isPinned: !(p as any).isPinned })
|
||||
if (res?.success) {
|
||||
toast.success(res.isPinned ? '已置顶' : '已取消置顶')
|
||||
loadPersons()
|
||||
} else {
|
||||
toast.error(res?.error || '操作失败')
|
||||
}
|
||||
} catch { toast.error('操作失败') }
|
||||
}}
|
||||
>
|
||||
<Pin className={`w-3.5 h-3.5 ${(p as any).isPinned ? 'fill-current' : ''}`} />
|
||||
</Button>
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<div className="flex items-center gap-0">
|
||||
<Button
|
||||
@@ -2448,15 +2398,6 @@ 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) {
|
||||
@@ -2539,375 +2480,209 @@ export function ContentPage() {
|
||||
<p className="text-xs text-gray-500 mt-1">小程序端点击 #标签 可直接跳转对应链接,进入流量池</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white h-8 w-48"
|
||||
placeholder="按标签ID/显示文字搜索"
|
||||
value={linkTagSearch}
|
||||
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}
|
||||
onChange={(e) => {
|
||||
setLinkTagSearch(e.target.value)
|
||||
setLinkTagPage(1)
|
||||
if (newLinkTag.type === 'url' || newLinkTag.type === 'ckb') setNewLinkTag({ ...newLinkTag, url: e.target.value })
|
||||
else setNewLinkTag({ ...newLinkTag, appId: 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>
|
||||
|
||||
{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
|
||||
size="sm"
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
|
||||
onClick={() => {
|
||||
setLinkTagEditing(null)
|
||||
setLinkTagForm({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||||
setMpSearchQuery('')
|
||||
setMpDropdownOpen(false)
|
||||
setLinkTagModalOpen(true)
|
||||
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()
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加标签
|
||||
<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" />
|
||||
</Button>
|
||||
</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 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>
|
||||
</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">
|
||||
@@ -2923,7 +2698,6 @@ 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,
|
||||
@@ -2973,7 +2747,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 wrap-break-word">
|
||||
<DialogDescription className="text-gray-400 text-sm leading-relaxed break-words">
|
||||
{personToDelete && (
|
||||
<>
|
||||
<p>确定删除「SOUL链接人与事-{personToDelete.name}」?将同时删除存客宝对应获客计划。</p>
|
||||
@@ -3003,78 +2777,6 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3 } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3, Phone, MessageSquare, UserCheck } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
|
||||
@@ -77,6 +78,12 @@ export function DashboardPage() {
|
||||
const [showDetailModal, setShowDetailModal] = useState(false)
|
||||
const [giftedTotal, setGiftedTotal] = useState(0)
|
||||
const [ordersExpanded, setOrdersExpanded] = useState(false)
|
||||
// 客资中心
|
||||
const [leadsLoading, setLeadsLoading] = useState(false)
|
||||
const [leads, setLeads] = useState<{ id: string; type: string; userId?: string; userNickname?: string; userAvatar?: string; phone?: string; wechatId?: string; name?: string; source?: string; sourceLabel?: string; matchType?: string; createdAt?: string; dupCount?: number }[]>([])
|
||||
const [leadStats, setLeadStats] = useState<{ totalLeads: number; totalSubmits: number; withPhone: number; total: number }>({ totalLeads: 0, totalSubmits: 0, withPhone: 0, total: 0 })
|
||||
const [leadsExpanded, setLeadsExpanded] = useState(false)
|
||||
|
||||
const [trackPeriod, setTrackPeriod] = useState<string>('week')
|
||||
const [trackStats, setTrackStats] = useState<{
|
||||
total: number
|
||||
@@ -184,6 +191,23 @@ export function DashboardPage() {
|
||||
await Promise.all([loadOrders(), loadUsers()])
|
||||
}
|
||||
|
||||
async function loadLeads() {
|
||||
setLeadsLoading(true)
|
||||
try {
|
||||
const res = await get<{ success?: boolean; leads?: typeof leads; totalLeads?: number; totalSubmits?: number; withPhone?: number; total?: number }>('/api/admin/dashboard/leads?limit=20')
|
||||
if (res?.success) {
|
||||
setLeads(res.leads || [])
|
||||
setLeadStats({
|
||||
totalLeads: res.totalLeads ?? 0,
|
||||
totalSubmits: res.totalSubmits ?? 0,
|
||||
withPhone: res.withPhone ?? 0,
|
||||
total: res.total ?? 0,
|
||||
})
|
||||
}
|
||||
} catch { }
|
||||
finally { setLeadsLoading(false) }
|
||||
}
|
||||
|
||||
async function loadTrackStats(period?: string) {
|
||||
const p = period || trackPeriod
|
||||
setTrackLoading(true)
|
||||
@@ -205,7 +229,8 @@ export function DashboardPage() {
|
||||
const ctrl = new AbortController()
|
||||
loadAll(ctrl.signal)
|
||||
loadTrackStats()
|
||||
const timer = setInterval(() => { loadAll(); loadTrackStats() }, 30000)
|
||||
loadLeads()
|
||||
const timer = setInterval(() => { loadAll(); loadTrackStats(); loadLeads() }, 30000)
|
||||
return () => {
|
||||
ctrl.abort()
|
||||
clearInterval(timer)
|
||||
@@ -303,6 +328,15 @@ export function DashboardPage() {
|
||||
bg: 'bg-orange-500/20',
|
||||
link: '/distribution',
|
||||
},
|
||||
{
|
||||
title: '总客资',
|
||||
value: leadStats.total > 0 ? leadStats.total : (leadsLoading ? null : 0),
|
||||
sub: leadStats.withPhone > 0 ? `有手机号 ${leadStats.withPhone}` : null,
|
||||
icon: UserCheck,
|
||||
color: 'text-cyan-400',
|
||||
bg: 'bg-cyan-500/20',
|
||||
link: '/find-partner',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -320,7 +354,7 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
@@ -545,6 +579,89 @@ export function DashboardPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-8 bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<UserCheck className="w-5 h-5 text-[#38bdac]" />
|
||||
客资中心
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-4 text-xs text-gray-400">
|
||||
<span>总客资 <span className="text-white font-bold">{leadStats.total}</span></span>
|
||||
<span>有手机号 <span className="text-green-400 font-bold">{leadStats.withPhone}</span></span>
|
||||
{leads.filter(l => (l.dupCount ?? 0) > 0).length > 0 && (
|
||||
<span>重复 <span className="text-amber-400 font-bold">{leads.filter(l => (l.dupCount ?? 0) > 0).length}</span></span>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={loadLeads} disabled={leadsLoading} className="text-xs text-gray-400 hover:text-[#38bdac] flex items-center gap-1 disabled:opacity-50">
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${leadsLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{leadsLoading && leads.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>
|
||||
) : leads.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<UserCheck className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无客资数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{leads.slice(0, leadsExpanded ? 20 : 6).map((lead) => (
|
||||
<div key={`${lead.type}-${lead.id}`} className="flex items-center justify-between p-3 bg-[#0a1628] rounded-lg border border-gray-700/30 hover:border-[#38bdac]/30 transition-colors">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{lead.userAvatar ? (
|
||||
<img src={lead.userAvatar} alt="" className="w-8 h-8 rounded-full object-cover flex-shrink-0" onError={(e) => { e.currentTarget.style.display = 'none' }} />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-xs font-medium text-[#38bdac] flex-shrink-0">
|
||||
{(lead.userNickname || lead.name || '?').charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => { if (lead.userId) { setDetailUserId(lead.userId); setShowDetailModal(true) } }} className="text-sm text-[#38bdac] hover:underline truncate">
|
||||
{lead.userNickname || lead.name || '匿名'}
|
||||
</button>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-gray-600 text-gray-400 shrink-0">
|
||||
{lead.sourceLabel || lead.source || lead.type}
|
||||
</Badge>
|
||||
{(lead.dupCount ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-amber-500/50 text-amber-400 shrink-0">
|
||||
重复{lead.dupCount}次
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 text-xs text-gray-500">
|
||||
{lead.phone && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Phone className="w-3 h-3" />{lead.phone}
|
||||
</span>
|
||||
)}
|
||||
{lead.wechatId && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="w-3 h-3" />{lead.wechatId}
|
||||
</span>
|
||||
)}
|
||||
<span>{lead.createdAt ? new Date(lead.createdAt).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{leads.length > 6 && !leadsExpanded && (
|
||||
<button type="button" onClick={() => setLeadsExpanded(true)} className="w-full py-2 text-sm text-[#38bdac] hover:text-[#2da396] border border-dashed border-gray-600 rounded-lg hover:border-[#38bdac]/50 transition-colors">
|
||||
展开更多 ({leads.length - 6} 条)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-8 bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user