feat: 运营-用户功能四大需求完整实现

1. 客资中心:Dashboard 聚合 CKB 线索+提交记录,联表用户信息
2. @置顶:Person 三端(后端+管理端+小程序)置顶功能,首页优先展示
3. 存客宝场景:一键检查并自动启用所有场景获客计划
4. 去重增强:后端聚合 dupCount,管理端展示重复标记和统计
5. 首页文案:"最新更新"→"推荐","开始阅读"→"点击阅读"

Made-with: Cursor
This commit is contained in:
卡若
2026-03-19 16:20:46 +08:00
parent 01d700aab2
commit 80e397f7ac
17 changed files with 1330 additions and 1130 deletions

View File

@@ -1 +1,8 @@
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted
/bin/bash: /Users/karuo/Documents/开发/3、自营项目/一场soul的创业实验-永平/.cursor/scripts/gitea-sync.sh: Operation not permitted

View File

@@ -4,3 +4,45 @@ From http://192.168.1.201:3000/fnvtk/soul-yongping
* [new branch] main -> gitea-local/main
error: cannot pull with rebase: You have unstaged changes.
error: Please commit or stash them.
[devlop 28a69cbc] sync: 2026-03-19 14:54
Committer: 卡若 <karuo@MacBook-Pro.local>
Your name and email address were configured automatically based
on your username and hostname. Please check that they are accurate.
You can suppress this message by setting them explicitly:
git config --global user.name "Your Name"
git config --global user.email you@example.com
After doing this, you may fix the identity used for this commit with:
git commit --amend --reset-author
26 files changed, 164 insertions(+), 2133 deletions(-)
create mode 100644 .cursor/scripts/README-gitea-sync.md
create mode 100644 .cursor/scripts/gitea-sync-launchd.err.log
create mode 100644 .cursor/scripts/gitea-sync-launchd.log
create mode 100644 .cursor/scripts/gitea-sync.log
create mode 100755 .cursor/scripts/gitea-sync.sh
create mode 100644 project.config.json
delete mode 100644 开发文档/1、需求/文章详情-阅读页线框图.md
delete mode 100644 开发文档/1、需求/链接人与事-所有同步需求.md
delete mode 100644 开发文档/代付功能-美团式方案与场景清单.md
delete mode 100644 开发文档/全站测试报告_20260315.md
delete mode 100644 开发文档/存客宝对接逻辑图.md
delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260315_2344.png
delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260316_0221.png
delete mode 100644 开发文档/小程序管理/scripts/reports/体验版二维码_soul-party_20260316_1804.png
delete mode 100644 开发文档/找朋友代付-流程与配置.md
delete mode 100644 开发文档/新版管理端迁移到稳定版-需求评估.md
delete mode 100644 开发文档/新版迁移-开发方案与清单.md
delete mode 100644 开发文档/稳定版-小程序与API对比.md
delete mode 100644 开发文档/稳定版-源码质量分析报告.md
delete mode 100644 开发文档/稳定版-管理端与小程序对接分析.md
delete mode 100644 开发文档/稳定版适配新界面-调整清单.md
delete mode 100644 开发文档/管理端两版界面差异-新需求参考.md
delete mode 100644 开发文档/管理端迁移分析-基于小程序功能.md
delete mode 100644 开发文档/规则引擎迁移-影响分析.md
delete mode 100644 开发文档/迁移完成度与待办清单.md
remote: Failed to authenticate user
fatal: Authentication failed for 'http://192.168.1.201:3000/fnvtk/soul-yongping.git/'
[2026-03-19 14:54:02] --- sync end ---

View File

@@ -14,13 +14,16 @@ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
log "--- sync start (branch=$BRANCH, remote=$REMOTE) ---"
# 1. 拉取远程更新(若远程无此分支则仅 fetch
# 1. 拉取远程更新(若有未提交变更则先 stashpull 后再 pop
STASHED=""
if [ -n "$(git status -s)" ]; then
git stash push -u -m "gitea-sync $(date +%s)" 2>/dev/null && STASHED=1 || true
fi
git fetch "$REMOTE" 2>&1 | tee -a "$LOG_FILE" || true
if git ls-remote --exit-code --heads "$REMOTE" "$BRANCH" &>/dev/null; then
git pull "$REMOTE" "$BRANCH" --no-edit 2>&1 | tee -a "$LOG_FILE" || log "pull 失败或冲突,继续尝试推送本地变更"
else
log "远程无 $REMOTE/$BRANCH,仅 fetch"
fi
[ -n "$STASHED" ] && git stash pop 2>/dev/null || true
# 2. 若有本地未提交变更,则提交并推送
STATUS=$(git status -s)

View File

@@ -33,7 +33,7 @@ Page({
// 最新章节(动态计算)
latestSection: null,
latestLabel: '最新更新',
latestLabel: '推荐',
// 内容概览
partsList: [
@@ -135,29 +135,63 @@ Page({
async loadSuperMembers() {
this.setData({ superMembersLoading: true })
try {
// 并行请求 VIP 会员和普通用户,合并后取前 4 个VIP 优先)
const [vipRes, usersRes] = await Promise.all([
const [pinnedRes, vipRes, usersRes] = await Promise.all([
app.request({ url: '/api/miniprogram/persons/pinned', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/vip/members', silent: true }).catch(() => null),
app.request({ url: '/api/miniprogram/users?limit=20', silent: true }).catch(() => null)
])
let members = []
if (vipRes && vipRes.success && Array.isArray(vipRes.data) && vipRes.data.length > 0) {
members = vipRes.data.slice(0, 4).map(u => ({
id: u.id,
name: u.nickname || u.vipName || u.vip_name || '会员',
avatar: u.avatar || '',
isVip: true
}))
if (members.length > 0) console.log('[Index] 超级个体加载成功:', members.length, '人')
const usedIds = new Set()
// 1. 后台置顶人物优先(最多 4 个)
if (pinnedRes && pinnedRes.success && Array.isArray(pinnedRes.persons)) {
pinnedRes.persons.slice(0, 4).forEach(p => {
const id = p.userId || p.personId
members.push({
id,
personId: p.personId,
name: p.nickname || p.name || '置顶',
avatar: p.avatar || '',
isVip: true,
isPinned: true
})
usedIds.add(id)
})
}
// 2. VIP 会员补位
if (members.length < 4 && vipRes && vipRes.success && Array.isArray(vipRes.data)) {
vipRes.data.forEach(u => {
if (members.length >= 4) return
if (usedIds.has(u.id)) return
members.push({
id: u.id,
name: u.nickname || u.vipName || u.vip_name || '会员',
avatar: u.avatar || '',
isVip: true,
isPinned: false
})
usedIds.add(u.id)
})
}
// 3. 普通用户兜底
if (members.length < 4 && usersRes && usersRes.success && Array.isArray(usersRes.data)) {
const existIds = new Set(members.map(m => m.id))
const extra = usersRes.data
.filter(u => u.avatar && u.nickname && !existIds.has(u.id))
.slice(0, 4 - members.length)
.map(u => ({ id: u.id, name: u.nickname, avatar: u.avatar, isVip: u.is_vip === 1 }))
members = members.concat(extra)
usersRes.data
.filter(u => u.avatar && u.nickname && !usedIds.has(u.id))
.forEach(u => {
if (members.length >= 4) return
members.push({
id: u.id,
name: u.nickname,
avatar: u.avatar,
isVip: u.is_vip === 1,
isPinned: false
})
})
}
if (members.length > 0) console.log('[Index] 超级个体加载成功:', members.length, '人 (置顶', members.filter(m => m.isPinned).length, '人)')
this.setData({ superMembers: members, superMembersLoading: false })
} catch (e) {
console.log('[Index] 加载超级个体失败:', e)

View File

@@ -38,18 +38,18 @@
<!-- Banner卡片 - 最新章节(异步加载) -->
<view class="banner-card" wx:if="{{latestSection}}" bindtap="goToRead" data-id="{{latestSection.id}}" data-mid="{{latestSection.mid}}">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">推荐</view>
<view class="banner-title">{{latestSection.title}}</view>
<view class="banner-action">
<text class="banner-action-text">开始阅读</text>
<text class="banner-action-text">点击阅读</text>
<icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon>
</view>
</view>
<view class="banner-card banner-skeleton" wx:else bindtap="goToChapters">
<view class="banner-glow"></view>
<view class="banner-tag">最新更新</view>
<view class="banner-tag">推荐</view>
<view class="banner-title">加载中...</view>
<view class="banner-action"><text class="banner-action-text">开始阅读</text><icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon></view>
<view class="banner-action"><text class="banner-action-text">点击阅读</text><icon name="chevron-right" size="32" color="#fff" customClass="banner-arrow"></icon></view>
</view>
<!-- 超级个体(横向滚动,已去掉「查看全部」;审核模式隐藏) -->
@@ -70,15 +70,16 @@
<scroll-view wx:elif="{{superMembers.length > 0}}" class="super-scroll" scroll-x>
<view class="super-scroll-inner">
<view
class="super-item-h"
class="super-item-h {{item.isPinned ? 'super-item-pinned' : ''}}"
wx:for="{{superMembers}}"
wx:key="id"
bindtap="goToMemberDetail"
data-id="{{item.id}}"
>
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}}">
<view class="super-avatar {{item.isVip ? 'super-avatar-vip' : ''}} {{item.isPinned ? 'super-avatar-pinned' : ''}}">
<image class="super-avatar-img" wx:if="{{item.avatar}}" src="{{item.avatar}}" mode="aspectFill"/>
<text class="super-avatar-text" wx:else>{{item.name[0] || '会'}}</text>
<view class="pinned-badge" wx:if="{{item.isPinned}}">★</view>
</view>
<text class="super-name">{{item.name}}</text>
</view>

View File

@@ -634,10 +634,11 @@
gap: 10rpx;
}
.super-avatar {
position: relative;
width: 108rpx;
height: 108rpx;
border-radius: 50%;
overflow: hidden;
overflow: visible;
background: rgba(0,206,209,0.1);
display: flex;
align-items: center;
@@ -648,10 +649,33 @@
border: 3rpx solid #FFD700;
box-shadow: 0 0 12rpx rgba(255,215,0,0.3);
}
.super-avatar-pinned {
border: 3rpx solid #38bdac;
box-shadow: 0 0 16rpx rgba(56, 189, 172, 0.4);
}
.super-item-pinned .super-name {
color: #38bdac;
}
.pinned-badge {
position: absolute;
bottom: -4rpx;
right: -4rpx;
width: 28rpx;
height: 28rpx;
background: #38bdac;
border-radius: 50%;
font-size: 18rpx;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.super-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.super-avatar-text {
font-size: 40rpx;

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ export interface PersonItem {
startTime?: string
endTime?: string
deviceGroups?: string
isPinned?: boolean
}
export interface LinkTagItem {

View File

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

View File

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

View File

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

View File

@@ -188,91 +188,6 @@ func buildRecentOrdersOut(db *gorm.DB, recentOrders []model.Order) []gin.H {
return out
}
// AdminTrackStats GET /api/admin/track/stats?period=today|week|month|all
// 埋点统计:按 extra_data->module 分组,按 action+target 聚合 count
func AdminTrackStats(c *gin.Context) {
period := c.DefaultQuery("period", "week")
if period != "today" && period != "week" && period != "month" && period != "all" {
period = "week"
}
now := time.Now()
var start time.Time
switch period {
case "today":
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
case "week":
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
start = time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location())
case "month":
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
case "all":
start = time.Time{}
}
db := database.DB()
var tracks []model.UserTrack
q := db.Model(&model.UserTrack{})
if !start.IsZero() {
q = q.Where("created_at >= ?", start)
}
if err := q.Find(&tracks).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// byModule: module -> map[key] -> count, key = action + "|" + target
type item struct {
Action string `json:"action"`
Target string `json:"target"`
Module string `json:"module"`
Page string `json:"page"`
Count int `json:"count"`
}
byModule := make(map[string]map[string]*item)
total := 0
for _, t := range tracks {
total++
module := "other"
page := ""
if len(t.ExtraData) > 0 {
var extra map[string]interface{}
if err := json.Unmarshal(t.ExtraData, &extra); err == nil {
if m, ok := extra["module"].(string); ok && m != "" {
module = m
}
if p, ok := extra["page"].(string); ok {
page = p
}
}
}
target := ""
if t.Target != nil {
target = *t.Target
}
key := t.Action + "|" + target
if byModule[module] == nil {
byModule[module] = make(map[string]*item)
}
if byModule[module][key] == nil {
byModule[module][key] = &item{Action: t.Action, Target: target, Module: module, Page: page, Count: 0}
}
byModule[module][key].Count++
}
// 转为前端期望格式byModule[module] = [{action,target,module,page,count},...]
out := make(map[string][]gin.H)
for mod, m := range byModule {
list := make([]gin.H, 0, len(m))
for _, v := range m {
list = append(list, gin.H{
"action": v.Action, "target": v.Target, "module": v.Module, "page": v.Page, "count": v.Count,
})
}
out[mod] = list
}
c.JSON(http.StatusOK, gin.H{"success": true, "total": total, "byModule": out})
}
// AdminBalanceSummary GET /api/admin/balance/summary
// 汇总代付金额product_type 为 gift_pay 或 gift_pay_batch 的已支付订单),用于 Dashboard 显示「含代付 ¥xx」
func AdminBalanceSummary(c *gin.Context) {
@@ -303,6 +218,196 @@ func AdminDashboardMerchantBalance(c *gin.Context) {
})
}
// AdminDashboardLeads GET /api/admin/dashboard/leads?limit=20
// 管理端-首页客资中心:聚合 ckb_lead_records链接卡若留资+ ckb_submit_recordsjoin/match
// 联表 users 补齐头像/昵称按时间倒序每条包含联系方式phone/wechatId与来源。
func AdminDashboardLeads(c *gin.Context) {
db := database.DB()
limit := 20
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n >= 1 && n <= 100 {
limit = n
}
}
search := c.Query("search")
// 1. ckb_lead_records链接卡若 / 文章@
var leads []model.CkbLeadRecord
qLead := db.Model(&model.CkbLeadRecord{}).Order("created_at DESC")
if search != "" {
qLead = qLead.Where("nickname LIKE ? OR phone LIKE ? OR name LIKE ? OR wechat_id LIKE ?",
"%"+search+"%", "%"+search+"%", "%"+search+"%", "%"+search+"%")
}
qLead.Limit(limit).Find(&leads)
// 2. ckb_submit_recordsjoin/match
var submits []model.CkbSubmitRecord
qSub := db.Model(&model.CkbSubmitRecord{}).Order("created_at DESC")
if search != "" {
qSub = qSub.Where("nickname LIKE ? OR params LIKE ?", "%"+search+"%", "%"+search+"%")
}
qSub.Limit(limit).Find(&submits)
// 收集所有 userID 关联用户信息
userIDs := make(map[string]bool)
for _, l := range leads {
if l.UserID != "" {
userIDs[l.UserID] = true
}
}
for _, s := range submits {
if s.UserID != "" {
userIDs[s.UserID] = true
}
}
ids := make([]string, 0, len(userIDs))
for id := range userIDs {
ids = append(ids, id)
}
var users []model.User
if len(ids) > 0 {
db.Select("id", "nickname", "avatar", "phone", "wechat_id", "is_vip", "tags", "ckb_tags").Where("id IN ?", ids).Find(&users)
}
userMap := make(map[string]*model.User)
for i := range users {
userMap[users[i].ID] = &users[i]
}
// 统计
var totalLeads, totalSubmits int64
db.Model(&model.CkbLeadRecord{}).Count(&totalLeads)
db.Model(&model.CkbSubmitRecord{}).Count(&totalSubmits)
var withPhone int64
db.Model(&model.CkbLeadRecord{}).Where("phone != '' AND phone IS NOT NULL").Count(&withPhone)
// 去重统计:按 userId/phone/wechatId 聚合重复次数
dupCounts := make(map[string]int64)
for _, l := range leads {
key := l.UserID
if key == "" {
key = l.Phone
}
if key == "" {
key = l.WechatID
}
if key != "" {
if _, ok := dupCounts[key]; !ok {
var cnt int64
q := db.Model(&model.CkbLeadRecord{})
if l.UserID != "" {
q = q.Where("user_id = ?", l.UserID)
} else if l.Phone != "" {
q = q.Where("phone = ?", l.Phone)
} else {
q = q.Where("wechat_id = ?", l.WechatID)
}
q.Count(&cnt)
dupCounts[key] = cnt
}
}
}
// 构造输出
type leadOut struct {
SortTime time.Time
Data gin.H
}
all := make([]leadOut, 0, len(leads)+len(submits))
for _, l := range leads {
u := userMap[l.UserID]
avatar := ""
userNickname := l.Nickname
if u != nil {
avatar = dashStr(u.Avatar)
if dashStr(u.Nickname) != "" {
userNickname = dashStr(u.Nickname)
}
}
sourceLabel := "链接卡若"
if l.Source == "article_mention" {
sourceLabel = "文章@"
} else if l.Source == "index_link_button" {
sourceLabel = "首页链接"
}
key := l.UserID
if key == "" {
key = l.Phone
}
if key == "" {
key = l.WechatID
}
dupCount := dupCounts[key]
if dupCount <= 1 {
dupCount = 0
}
all = append(all, leadOut{
SortTime: l.CreatedAt,
Data: gin.H{
"id": l.ID,
"type": "lead",
"userId": l.UserID,
"userNickname": userNickname,
"userAvatar": avatar,
"phone": l.Phone,
"wechatId": l.WechatID,
"name": l.Name,
"source": l.Source,
"sourceLabel": sourceLabel,
"createdAt": l.CreatedAt,
"dupCount": dupCount,
},
})
}
for _, s := range submits {
u := userMap[s.UserID]
avatar := ""
userNickname := s.Nickname
if u != nil {
avatar = dashStr(u.Avatar)
if dashStr(u.Nickname) != "" {
userNickname = dashStr(u.Nickname)
}
}
all = append(all, leadOut{
SortTime: s.CreatedAt,
Data: gin.H{
"id": s.ID,
"type": "submit",
"userId": s.UserID,
"userNickname": userNickname,
"userAvatar": avatar,
"matchType": s.Action,
"source": s.Action,
"sourceLabel": ckbSourceMap[s.Action],
"createdAt": s.CreatedAt,
},
})
}
// 按时间倒序合并
for i := 0; i < len(all); i++ {
for j := i + 1; j < len(all); j++ {
if all[j].SortTime.After(all[i].SortTime) {
all[i], all[j] = all[j], all[i]
}
}
}
if len(all) > limit {
all = all[:limit]
}
out := make([]gin.H, 0, len(all))
for _, a := range all {
out = append(out, a.Data)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"leads": out,
"totalLeads": totalLeads,
"totalSubmits": totalSubmits,
"withPhone": withPhone,
"total": totalLeads + totalSubmits,
})
}
func buildNewUsersOut(newUsers []model.User) []gin.H {
out := make([]gin.H, 0, len(newUsers))
for _, u := range newUsers {

View File

@@ -43,8 +43,46 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
// 查询每条记录的重复次数
dupCounts := make(map[string]int64)
if dedup == "true" && len(records) > 0 {
for _, r := range records {
key := r.UserID
if key == "" {
key = r.Phone
}
if key == "" {
key = r.WechatID
}
if key != "" {
var cnt int64
cntQ := db.Model(&model.CkbLeadRecord{})
if r.UserID != "" {
cntQ = cntQ.Where("user_id = ?", r.UserID)
} else if r.Phone != "" {
cntQ = cntQ.Where("phone = ?", r.Phone)
} else if r.WechatID != "" {
cntQ = cntQ.Where("wechat_id = ?", r.WechatID)
}
cntQ.Count(&cnt)
dupCounts[key] = cnt
}
}
}
out := make([]gin.H, 0, len(records))
for _, r := range records {
key := r.UserID
if key == "" {
key = r.Phone
}
if key == "" {
key = r.WechatID
}
dupCount := dupCounts[key]
if dupCount <= 1 {
dupCount = 0
}
out = append(out, gin.H{
"id": r.ID,
"userId": r.UserID,
@@ -54,6 +92,7 @@ func DBCKBLeadList(c *gin.Context) {
"wechatId": r.WechatID,
"name": r.Name,
"createdAt": r.CreatedAt,
"dupCount": dupCount,
})
}
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})

View File

@@ -404,6 +404,100 @@ func genPersonToken() (string, error) {
return s + "0123456789abcdefghijklmnopqrstuv"[:(32-len(s))], nil
}
// DBPersonPin PUT /api/db/persons/pin 管理端-置顶/取消置顶人物到小程序首页
func DBPersonPin(c *gin.Context) {
var body struct {
PersonID string `json:"personId" binding:"required"`
IsPinned *bool `json:"isPinned"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"})
return
}
db := database.DB()
var row model.Person
if err := db.Where("person_id = ? OR token = ?", body.PersonID, body.PersonID).First(&row).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "人物不存在"})
return
}
pinned := true
if body.IsPinned != nil {
pinned = *body.IsPinned
} else {
pinned = !row.IsPinned
}
if err := db.Model(&row).Update("is_pinned", pinned).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "isPinned": pinned})
}
// DBPersonPinnedList GET /api/db/persons/pinned 管理端/小程序-获取置顶人物列表
func DBPersonPinnedList(c *gin.Context) {
var rows []model.Person
if err := database.DB().Where("is_pinned = ?", true).Order("updated_at DESC").Find(&rows).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
out := make([]gin.H, 0, len(rows))
db := database.DB()
for _, p := range rows {
item := gin.H{
"personId": p.PersonID,
"token": p.Token,
"name": p.Name,
"label": p.Label,
"isPinned": p.IsPinned,
}
if p.UserID != nil && *p.UserID != "" {
var u model.User
if db.Select("id", "nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {
item["userId"] = u.ID
item["avatar"] = getUrlValue(u.Avatar)
item["nickname"] = getStringValue(u.Nickname)
}
}
out = append(out, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "persons": out})
}
// AdminCKBPlanCheck GET /api/admin/ckb/plan-check 管理端-检查存客宝计划在线状态
// 查询所有有 ckb_plan_id 的 Person对每个计划调用存客宝获取状态
func AdminCKBPlanCheck(c *gin.Context) {
db := database.DB()
var persons []model.Person
db.Where("ckb_plan_id > 0").Find(&persons)
if len(persons) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "plans": []interface{}{}, "message": "暂无配置了存客宝计划的人物"})
return
}
token, err := ckbOpenGetToken()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
out := make([]gin.H, 0, len(persons))
for _, p := range persons {
item := gin.H{
"personId": p.PersonID,
"name": p.Name,
"ckbPlanId": p.CkbPlanID,
"status": "unknown",
}
// 尝试启用计划
if enableErr := setCkbPlanEnabled(token, p.CkbPlanID, true); enableErr != nil {
item["status"] = "error"
item["error"] = enableErr.Error()
} else {
item["status"] = "online"
}
out = append(out, item)
}
c.JSON(http.StatusOK, gin.H{"success": true, "plans": out})
}
// DBPersonDelete DELETE /api/db/persons?personId=xxx 管理端-删除人物
// 若有 ckb_plan_id先调存客宝删除计划再删本地
func DBPersonDelete(c *gin.Context) {

View File

@@ -32,6 +32,8 @@ type Person struct {
EndTime string `gorm:"column:end_time;size:10;default:'18:00'" json:"endTime"`
DeviceGroups string `gorm:"column:device_groups;size:255;default:''" json:"deviceGroups"` // 逗号分隔的设备ID列表
IsPinned bool `gorm:"column:is_pinned;default:false" json:"isPinned"` // 置顶到小程序首页
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
}

View File

@@ -106,6 +106,8 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
admin.GET("/user/track", handler.UserTrackGet)
admin.GET("/track/stats", handler.AdminTrackStats)
admin.GET("/dashboard/leads", handler.AdminDashboardLeads)
admin.GET("/ckb/plan-check", handler.AdminCKBPlanCheck)
}
// ----- 鉴权 -----
@@ -199,6 +201,8 @@ func Setup(cfg *config.Config) *gin.Engine {
db.GET("/link-tags", handler.DBLinkTagList)
db.POST("/link-tags", handler.DBLinkTagSave)
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.PUT("/persons/pin", handler.DBPersonPin)
db.GET("/persons/pinned", handler.DBPersonPinnedList)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
@@ -352,6 +356,7 @@ func Setup(cfg *config.Config) *gin.Engine {
miniprogram.GET("/mentors/:id", handler.MiniprogramMentorsDetail)
miniprogram.POST("/mentors/:id/book", handler.MiniprogramMentorsBook)
miniprogram.GET("/about/author", handler.MiniprogramAboutAuthor)
miniprogram.GET("/persons/pinned", handler.DBPersonPinnedList)
// 埋点
miniprogram.POST("/track", handler.MiniprogramTrackPost)
// 规则引擎(用户旅程引导)

Binary file not shown.