Merge branch 'yongxu-dev' into devlop

# Conflicts:
#	miniprogram/pages/profile-edit/profile-edit.js
#	miniprogram/pages/profile-edit/profile-edit.wxml
#	miniprogram/pages/settings/settings.js
#	miniprogram/utils/ruleEngine.js
#	soul-admin/src/pages/distribution/DistributionPage.tsx
#	soul-admin/src/pages/users/UsersPage.tsx
#	soul-api/.env.production
#	soul-api/.gitignore
#	soul-api/internal/handler/db_ckb_leads.go
#	soul-api/internal/handler/miniprogram.go
#	soul-api/internal/handler/referral.go
#	开发文档/1、需求/archive/链接人与事-存客宝同步-需求规划.md
#	开发文档/1、需求/archive/链接人与事-实现方案.md
This commit is contained in:
Alex-larget
2026-03-20 14:48:02 +08:00
247 changed files with 8990 additions and 6983 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -1,4 +1,4 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { get, post } from '@/api/client'
@@ -33,6 +33,13 @@ interface Stats {
totalParts: number
}
/** 篇内章区间(按章条数,非节数) */
function chapterRangeLabel(chapterCount: number): string {
if (chapterCount <= 0) return '暂无章节'
if (chapterCount === 1) return '第1章'
return `第1章 ~ 第${chapterCount}`
}
export function ChaptersPage() {
const [structure, setStructure] = useState<Part[]>([])
const [stats, setStats] = useState<Stats | null>(null)
@@ -204,7 +211,8 @@ export function ChaptersPage() {
</span>
<span className="font-semibold text-white">{part.title}</span>
<span className="text-white/40 text-sm">
({part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
({chapterRangeLabel(part.chapters.length)} ·{' '}
{part.chapters.reduce((acc, ch) => acc + (ch.sections?.length || 1), 0)} )
</span>
</div>
<span className="text-white/40">{expandedParts.includes(part.id) ? '▲' : '▼'}</span>

View File

@@ -12,6 +12,8 @@ export interface SectionItem {
id: string
title: string
price: number
/** 数据库自增序号,仅作兜底展示 */
mid?: number
isFree?: boolean
isNew?: boolean
clickCount?: number
@@ -34,6 +36,23 @@ export interface PartItem {
type DragType = 'part' | 'chapter' | 'section'
function isPrefacePart(p: PartItem) {
return p.title === '序言' || p.title.includes('序言')
}
/** 篇内按章数量展示第1章 ~ 第n章仅章数与节数无关 */
function sectionIdRangeSubtitle(part: PartItem): string {
const ids: string[] = []
for (const ch of part.chapters) {
for (const s of ch.sections) {
ids.push(s.id)
}
}
if (ids.length === 0) return '暂无章节'
if (ids.length === 1) return ids[0]
return `${ids[0]}~${ids[ids.length - 1]}`
}
function parseDragData(data: string): { type: DragType; id: string } | null {
if (data.startsWith('part:')) return { type: 'part', id: data.slice(5) }
if (data.startsWith('chapter:')) return { type: 'chapter', id: data.slice(8) }
@@ -84,6 +103,8 @@ export function ChapterTree({
const [draggingItem, setDraggingItem] = useState<{ type: DragType; id: string } | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{ type: DragType; id: string } | null>(null)
// 章节ID/范围展示由数据本身决定,不再依赖“节序号”重新编号
const isDragging = (type: DragType, id: string) => draggingItem?.type === type && draggingItem?.id === id
const isDragOver = (type: DragType, id: string) => dragOverTarget?.type === type && dragOverTarget?.id === id
@@ -267,6 +288,24 @@ export function ChapterTree({
const partLabel = (i: number) => PART_LABELS[i] ?? String(i + 1)
/** 篇角标:去掉序言后第一篇为「一」 */
const bodyPartOrdinal = (partIndex: number) =>
parts.slice(0, partIndex).filter((p) => !isPrefacePart(p)).length
const sectionTitleLine = (section: SectionItem) => {
return (
<>
<span
className="text-gray-500 font-mono text-xs tabular-nums shrink-0 mr-1.5 max-w-[72px] truncate"
title={`章节ID: ${section.id}`}
>
{section.id}
</span>
<span className="truncate">{section.title}</span>
</>
)
}
return (
<div className="space-y-3">
{parts.map((part, partIndex) => {
@@ -381,7 +420,7 @@ export function ChapterTree({
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
<p className="text-xs text-gray-500 mt-0.5">{sectionIdRangeSubtitle(part)}</p>
</div>
</div>
<div
@@ -404,7 +443,9 @@ export function ChapterTree({
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
<span className="text-xs text-gray-500" title="本篇章数与节数">
{chapterCount} · {sectionCount}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
@@ -471,7 +512,9 @@ export function ChapterTree({
/>
</label>
)}
<span className="text-sm text-gray-200 truncate">{section.id} {section.title}</span>
<span className="text-sm text-gray-200 truncate flex items-center min-w-0">
{sectionTitleLine(section)}
</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>
<div className="flex items-center gap-2 shrink-0" onClick={(e) => e.stopPropagation()}>
@@ -740,11 +783,11 @@ export function ChapterTree({
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac] flex items-center justify-center text-white font-bold shadow-lg shadow-[#38bdac]/30 shrink-0">
{partLabel(partIndex)}
{partLabel(bodyPartOrdinal(partIndex))}
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
<p className="text-xs text-gray-500 mt-0.5"> {sectionCount} </p>
<p className="text-xs text-gray-500 mt-0.5">{sectionIdRangeSubtitle(part)}</p>
</div>
</div>
<div
@@ -767,7 +810,9 @@ export function ChapterTree({
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
<span className="text-xs text-gray-500">{chapterCount}</span>
<span className="text-xs text-gray-500" title="本篇章数与节数">
{chapterCount} · {sectionCount}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
@@ -873,8 +918,8 @@ export function ChapterTree({
<div
className={`w-2 h-2 rounded-full shrink-0 ${section.price === 0 || section.isFree ? 'border-2 border-[#38bdac] bg-transparent' : 'bg-gray-500'}`}
/>
<span className="text-sm text-gray-200 truncate">
{section.id} {section.title}
<span className="text-sm text-gray-200 truncate flex items-center min-w-0">
{sectionTitleLine(section)}
</span>
{pinnedSectionIds.includes(section.id) && <span title="已置顶"><Star className="w-3 h-3 text-amber-400 fill-amber-400 shrink-0" /></span>}
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import toast from '@/utils/toast'
import {
Card,
@@ -129,7 +129,7 @@ interface EditingSection {
editionPremium?: boolean
}
function buildTree(sections: SectionListItem[]): Part[] {
function buildTree(sections: SectionListItem[], hotRankMap: Map<string, number>): Part[] {
const partMap = new Map<
string,
{ id: string; title: string; chapters: Map<string, { id: string; title: string; sections: Section[] }> }
@@ -157,7 +157,7 @@ function buildTree(sections: SectionListItem[]): Part[] {
clickCount: s.clickCount ?? 0,
payCount: s.payCount ?? 0,
hotScore: s.hotScore ?? 0,
hotRank: s.hotRank ?? 0,
hotRank: hotRankMap.get(s.id) ?? 0,
})
}
const parts = Array.from(partMap.values()).map((p) => ({
@@ -210,7 +210,17 @@ export function ContentPage() {
const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null)
const [isSavingPartTitle, setIsSavingPartTitle] = useState(false)
const [showNewPartModal, setShowNewPartModal] = useState(false)
const [editingChapter, setEditingChapter] = useState<{ part: Part; chapter: Chapter; title: string } | null>(null)
const [editingChapter, setEditingChapter] = useState<{
part: Part
chapter: Chapter
title: string
price: number
isFree: boolean
priceMixed: boolean
initialTitle: string
initialPrice: number
initialIsFree: boolean
} | null>(null)
const [isSavingChapterTitle, setIsSavingChapterTitle] = useState(false)
const [selectedSectionIds, setSelectedSectionIds] = useState<string[]>([])
const [showBatchMoveModal, setShowBatchMoveModal] = useState(false)
@@ -267,7 +277,16 @@ export function ContentPage() {
const [ckbLeadLoading, setCkbLeadLoading] = useState(false)
const richEditorRef = useRef<RichEditorRef>(null)
const tree = buildTree(sectionsList)
const hotRankMap = useMemo(() => {
const m = new Map<string, number>()
rankedSectionsList.forEach((s, idx) => {
// 排名展示从 1 开始避免出现“第0名”
m.set(s.id, idx + 1)
})
return m
}, [rankedSectionsList])
const tree = buildTree(sectionsList, hotRankMap)
const totalSections = sectionsList.length
// 内容排行榜:排序与置顶由后端 API 统一计算,前端只展示
@@ -914,40 +933,98 @@ export function ContentPage() {
}
const handleEditChapter = (part: Part, chapter: Chapter) => {
setEditingChapter({ part, chapter, title: chapter.title })
const secs = chapter.sections
let price = 1
let isFree = false
let priceMixed = false
if (secs.length > 0) {
const p0 = typeof secs[0].price === 'number' ? secs[0].price : Number(secs[0].price) || 1
const f0 = !!(secs[0].isFree || p0 === 0)
priceMixed = secs.some((s) => {
const p = typeof s.price === 'number' ? s.price : Number(s.price) || 1
const f = !!(s.isFree || p === 0)
return p !== p0 || f !== f0
})
price = f0 ? 0 : p0
isFree = f0
}
setEditingChapter({
part,
chapter,
title: chapter.title,
price,
isFree,
priceMixed,
initialTitle: chapter.title,
initialPrice: price,
initialIsFree: isFree,
})
}
const handleSaveChapterTitle = async () => {
if (!editingChapter?.title?.trim()) return
const ec = editingChapter
const newTitle = ec.title.trim()
const titleChanged = newTitle !== ec.initialTitle
const priceChanged =
ec.isFree !== ec.initialIsFree ||
(!ec.isFree && Number(ec.price) !== Number(ec.initialPrice))
if (!titleChanged && !priceChanged) {
toast.info('未修改任何内容')
setEditingChapter(null)
return
}
if (ec.priceMixed && priceChanged) {
const n = ec.chapter.sections.length
const tip = ec.isFree ? '全部设为免费' : `全部设为 ¥${ec.price}`
if (!confirm(`本章 ${n} 节当前定价不一致,保存后将${tip},确定?`)) return
}
setIsSavingChapterTitle(true)
try {
const items = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || editingChapter.part.id,
partTitle: s.partId === editingChapter.part.id ? editingChapter.part.title : (s.partTitle || ''),
chapterId: s.chapterId || editingChapter.chapter.id,
chapterTitle:
s.partId === editingChapter.part.id && s.chapterId === editingChapter.chapter.id
? editingChapter.title.trim()
: (s.chapterTitle || ''),
}))
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
if (res && (res as { success?: boolean }).success !== false) {
const newTitle = editingChapter.title.trim()
const partId = editingChapter.part.id
const chapterId = editingChapter.chapter.id
if (titleChanged) {
const items = sectionsList.map((s) => ({
id: s.id,
partId: s.partId || ec.part.id,
partTitle: s.partId === ec.part.id ? ec.part.title : (s.partTitle || ''),
chapterId: s.chapterId || ec.chapter.id,
chapterTitle:
s.partId === ec.part.id && s.chapterId === ec.chapter.id ? newTitle : (s.chapterTitle || ''),
}))
const res = await put<{ success?: boolean; error?: string }>('/api/db/book', { action: 'reorder', items })
if (res && (res as { success?: boolean }).success === false) {
toast.error('保存章节名失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
return
}
const partId = ec.part.id
const chapterId = ec.chapter.id
setSectionsList((prev) =>
prev.map((s) =>
s.partId === partId && s.chapterId === chapterId
? { ...s, chapterTitle: newTitle }
: s
s.partId === partId && s.chapterId === chapterId ? { ...s, chapterTitle: newTitle } : s
)
)
setEditingChapter(null)
loadList()
} else {
toast.error('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
}
if (priceChanged) {
const res2 = await put<{ success?: boolean; error?: string; affected?: number }>('/api/db/book', {
action: 'update-chapter-pricing',
partId: ec.part.id,
chapterId: ec.chapter.id,
price: ec.isFree ? 0 : Number(ec.price) || 0,
isFree: ec.isFree,
})
if (res2 && (res2 as { success?: boolean }).success === false) {
toast.error('保存定价失败: ' + (res2 && typeof res2 === 'object' && 'error' in res2 ? (res2 as { error?: string }).error : '未知错误'))
if (titleChanged) loadList()
return
}
}
setEditingChapter(null)
loadList()
toast.success('已保存')
} catch (e) {
console.error(e)
toast.error('保存失败')
@@ -1471,14 +1548,17 @@ export function ContentPage() {
</DialogContent>
</Dialog>
{/* 编辑章节名称弹窗 */}
{/* 编辑章节名称 + 本章统一定价(对齐新版管理端能力) */}
<Dialog open={!!editingChapter} onOpenChange={(open) => !open && setEditingChapter(null)}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Edit3 className="w-5 h-5 text-[#38bdac]" />
</DialogTitle>
<p className="text-gray-400 text-sm font-normal pt-1">
</p>
</DialogHeader>
{editingChapter && (
<div className="space-y-4 py-4">
@@ -1491,6 +1571,47 @@ export function ContentPage() {
placeholder="输入章节名称"
/>
</div>
<div className="space-y-2 border-t border-gray-700/60 pt-4">
<Label className="text-gray-300"> {editingChapter.chapter.sections.length} </Label>
{editingChapter.priceMixed && (
<p className="text-amber-400/90 text-xs"></p>
)}
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1 flex-1 min-w-[120px]">
<span className="text-gray-500 text-xs"> ()</span>
<Input
type="number"
className="bg-[#0a1628] border-gray-700 text-white"
value={editingChapter.isFree ? 0 : editingChapter.price}
onChange={(e) =>
setEditingChapter({
...editingChapter,
price: Number(e.target.value),
isFree: Number(e.target.value) === 0,
})
}
disabled={editingChapter.isFree}
min={0}
step={0.01}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer pb-2">
<input
type="checkbox"
checked={editingChapter.isFree || editingChapter.price === 0}
onChange={(e) =>
setEditingChapter({
...editingChapter,
isFree: e.target.checked,
price: e.target.checked ? 0 : editingChapter.initialPrice > 0 ? editingChapter.initialPrice : 1,
})
}
className="w-4 h-4 rounded border-gray-600 bg-[#0a1628] text-[#38bdac]"
/>
<span className="text-gray-400 text-sm"></span>
</label>
</div>
</div>
</div>
)}
<DialogFooter>

View File

@@ -1,7 +1,7 @@
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 { Users, BookOpen, ShoppingBag, TrendingUp, RefreshCw, ChevronRight, BarChart3, UserPlus } from 'lucide-react'
import { get } from '@/api/client'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
@@ -77,6 +77,7 @@ export function DashboardPage() {
const [showDetailModal, setShowDetailModal] = useState(false)
const [giftedTotal, setGiftedTotal] = useState(0)
const [ordersExpanded, setOrdersExpanded] = useState(false)
const [ckbStats, setCkbStats] = useState<{ ckbTotal?: number; withContact?: number } | null>(null)
const [trackPeriod, setTrackPeriod] = useState<string>('week')
const [trackStats, setTrackStats] = useState<{
total: number
@@ -134,6 +135,18 @@ export function DashboardPage() {
// 不影响主面板
}
// 加载获客信息(存客宝计划统计)
try {
const ckbRes = await get<{ success?: boolean; data?: { ckbTotal?: number; withContact?: number } }>('/api/db/ckb-plan-stats', init)
if (ckbRes?.success && ckbRes.data) {
setCkbStats({ ckbTotal: ckbRes.data.ckbTotal ?? 0, withContact: ckbRes.data.withContact ?? 0 })
} else {
setCkbStats(null)
}
} catch {
setCkbStats(null)
}
// 2. 并行加载订单和用户
setOrdersLoading(true)
setUsersLoading(true)
@@ -303,6 +316,15 @@ export function DashboardPage() {
bg: 'bg-orange-500/20',
link: '/distribution',
},
{
title: '存客宝获客',
value: ckbStats ? ckbStats.ckbTotal ?? 0 : null,
sub: ckbStats?.withContact != null ? `含联系方式 ${ckbStats.withContact}` : null,
icon: UserPlus,
color: 'text-cyan-400',
bg: 'bg-cyan-500/20',
link: '/users?tab=leads',
},
]
return (

View File

@@ -1,5 +1,4 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect } from 'react'
import {
Users,
@@ -11,11 +10,13 @@ import {
RefreshCw,
CheckCircle,
XCircle,
Calendar,
DollarSign,
Link2,
Eye,
Undo2,
Settings,
Zap,
} from 'lucide-react'
import { ReferralSettingsPage } from '@/pages/referral-settings/ReferralSettingsPage'
import { Pagination } from '@/components/ui/Pagination'
@@ -23,6 +24,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
@@ -86,6 +88,7 @@ interface Withdrawal {
account?: string
name?: string
status: string
remark?: string
createdAt?: string
processedAt?: string
}
@@ -146,6 +149,8 @@ export function DistributionPage() {
const [rejectWithdrawalId, setRejectWithdrawalId] = useState<string | null>(null)
const [rejectReason, setRejectReason] = useState('')
const [rejectLoading, setRejectLoading] = useState(false)
const [enableAutoApprove, setEnableAutoApprove] = useState(false)
const [autoApproveLoading, setAutoApproveLoading] = useState(false)
const [giftPayRequests, setGiftPayRequests] = useState<Array<{
id: string
requestSn: string
@@ -189,6 +194,10 @@ export function DistributionPage() {
}
}, [page, pageSize, statusFilter, searchTerm, giftPayPage, giftPayStatusFilter])
useEffect(() => {
if (activeTab === 'withdrawals') loadAutoApprove()
}, [activeTab])
async function loadInitialData() {
setError(null)
try {
@@ -408,6 +417,39 @@ export function DistributionPage() {
}
}
async function loadAutoApprove() {
try {
const data = await get<{ success?: boolean; enableAutoApprove?: boolean }>(
'/api/admin/withdrawals/auto-approve'
)
if (data?.success && typeof data.enableAutoApprove === 'boolean') {
setEnableAutoApprove(data.enableAutoApprove)
}
} catch {
// ignore
}
}
async function toggleAutoApprove(checked: boolean) {
setAutoApproveLoading(true)
try {
const data = await put<{ success?: boolean; error?: string }>(
'/api/admin/withdrawals/auto-approve',
{ enableAutoApprove: checked }
)
if (data?.success) {
setEnableAutoApprove(checked)
toast.success(checked ? '已开启自动审批,新提现将自动打款' : '已关闭自动审批')
} else {
toast.error('更新失败: ' + (data?.error ?? ''))
}
} catch {
toast.error('更新失败')
} finally {
setAutoApproveLoading(false)
}
}
function closeRejectDialog() {
if (rejectWithdrawalId) toast.info('已取消操作')
setRejectWithdrawalId(null)
@@ -555,128 +597,290 @@ export function DistributionPage() {
) : (
<>
{activeTab === 'overview' && overview && (
<div className="space-y-5">
{/* 今日核心指标 - 一行紧凑 */}
<div className="grid grid-cols-6 gap-3">
{[
{ label: '今日点击', value: overview.todayClicks, icon: Eye, color: 'blue' },
{ label: '独立用户', value: overview.todayUniqueVisitors ?? 0, icon: Users, color: 'cyan' },
{ label: '人均点击', value: (overview.todayClickRate ?? 0).toFixed(1), icon: TrendingUp, color: 'amber' },
{ label: '今日绑定', value: overview.todayBindings, icon: Link2, color: 'green' },
{ label: '今日转化', value: overview.todayConversions, icon: CheckCircle, color: 'purple' },
{ label: '今日佣金', value: `¥${overview.todayEarnings.toFixed(2)}`, icon: DollarSign, color: 'teal', isMoney: true },
].map((item) => (
<Card key={item.label} className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="text-gray-400 text-xs">{item.label}</p>
<item.icon className={`w-4 h-4 text-${item.color}-400 opacity-60`} />
<div className="space-y-6">
<div className="grid grid-cols-4 gap-4">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayClicks}</p>
<p className="text-xs text-gray-500 mt-0.5"></p>
</div>
<p className={`text-xl font-bold ${item.isMoney ? 'text-[#38bdac]' : 'text-white'}`}>{item.value}</p>
</CardContent>
</Card>
))}
</div>
{/* 文章点击 + 提醒 并排 */}
<div className="grid grid-cols-3 gap-4">
{/* 文章点击表 */}
<Card className="bg-[#0f2137] border-gray-700/50 col-span-2">
<CardContent className="p-4">
<h4 className="text-sm font-medium text-white mb-3 flex items-center gap-1.5">
<Eye className="w-4 h-4 text-[#38bdac]" />
</h4>
{(overview.todayClicksByPage?.length ?? 0) > 0 ? (
<div className="space-y-1.5 max-h-[200px] overflow-y-auto">
{[...(overview.todayClicksByPage ?? [])].sort((a, b) => b.clicks - a.clicks).map((row, i) => (
<div key={i} className="flex items-center justify-between text-xs py-1 border-b border-gray-700/30 last:border-0">
<span className="text-gray-300 truncate mr-2">{row.page || '(未区分)'}</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-white font-medium">{row.clicks}</span>
<span className="text-gray-500 w-12 text-right">{overview.todayClicks > 0 ? ((row.clicks / overview.todayClicks) * 100).toFixed(1) : 0}%</span>
</div>
</div>
))}
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<Eye className="w-6 h-6 text-blue-400" />
</div>
) : (
<p className="text-gray-500 text-xs"></p>
)}
</div>
</CardContent>
</Card>
{/* 提醒卡片 */}
<div className="space-y-3">
<Card className="bg-orange-500/10 border-orange-500/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-orange-400 shrink-0" />
<div>
<p className="text-orange-300 text-xs font-medium"></p>
<p className="text-xl font-bold text-white">{overview.expiringBindings} </p>
<p className="text-orange-300/50 text-[10px]">7</p>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayUniqueVisitors ?? 0}</p>
<p className="text-xs text-gray-500 mt-0.5">访</p>
</div>
</CardContent>
</Card>
<Card className="bg-blue-500/10 border-blue-500/30 cursor-pointer" onClick={() => setActiveTab('withdrawals')}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<Wallet className="w-5 h-5 text-blue-400 shrink-0" />
<div>
<p className="text-blue-300 text-xs font-medium"></p>
<p className="text-xl font-bold text-white">{overview.pendingWithdrawals} </p>
<p className="text-blue-300/50 text-[10px]"> ¥{overview.pendingWithdrawAmount.toFixed(2)} </p>
</div>
<div className="w-12 h-12 rounded-xl bg-cyan-500/20 flex items-center justify-center">
<Users className="w-6 h-6 text-cyan-400" />
</div>
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">
{(overview.todayClickRate ?? 0).toFixed(2)}
</p>
<p className="text-xs text-gray-500 mt-0.5">/</p>
</div>
<div className="w-12 h-12 rounded-xl bg-amber-500/20 flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-amber-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayBindings}</p>
</div>
<div className="w-12 h-12 rounded-xl bg-green-500/20 flex items-center justify-center">
<Link2 className="w-6 h-6 text-green-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{overview.todayConversions}</p>
</div>
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-purple-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-[#38bdac] mt-1">
¥{overview.todayEarnings.toFixed(2)}
</p>
</div>
<div className="w-12 h-12 rounded-xl bg-[#38bdac]/20 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-[#38bdac]" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 每篇文章今日点击 */}
{(overview.todayClicksByPage?.length ?? 0) > 0 && (
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<CardTitle className="text-white flex items-center gap-2">
<Eye className="w-5 h-5 text-[#38bdac]" />
/
</CardTitle>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700 text-left text-gray-400">
<th className="pb-3 pr-4">/</th>
<th className="pb-3 pr-4 text-right"></th>
<th className="pb-3 text-right"></th>
</tr>
</thead>
<tbody>
{[...(overview.todayClicksByPage ?? [])]
.sort((a, b) => b.clicks - a.clicks)
.map((row, i) => (
<tr key={i} className="border-b border-gray-700/50">
<td className="py-2 pr-4 text-white font-mono">{row.page || '(未区分)'}</td>
<td className="py-2 pr-4 text-right text-white">{row.clicks}</td>
<td className="py-2 text-right text-gray-400">
{overview.todayClicks > 0
? ((row.clicks / overview.todayClicks) * 100).toFixed(1)
: 0}
%
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-2 gap-4">
<Card className="bg-orange-500/10 border-orange-500/30">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center">
<Clock className="w-6 h-6 text-orange-400" />
</div>
<div className="flex-1">
<p className="text-orange-300 font-medium"></p>
<p className="text-2xl font-bold text-white">{overview.expiringBindings} </p>
<p className="text-orange-300/60 text-sm">7</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-blue-500/10 border-blue-500/30">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-blue-500/20 flex items-center justify-center">
<Wallet className="w-6 h-6 text-blue-400" />
</div>
<div className="flex-1">
<p className="text-blue-300 font-medium"></p>
<p className="text-2xl font-bold text-white">{overview.pendingWithdrawals} </p>
<p className="text-blue-300/60 text-sm">
¥{overview.pendingWithdrawAmount.toFixed(2)}
</p>
</div>
<Button
onClick={() => setActiveTab('withdrawals')}
variant="outline"
className="border-blue-500/50 text-blue-400 hover:bg-blue-500/20"
>
</Button>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-2 gap-6">
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.monthClicks}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.monthBindings}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.monthConversions}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-[#38bdac]">
¥{overview.monthEarnings.toFixed(2)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">
{overview.totalClicks.toLocaleString()}
</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">
{overview.totalBindings.toLocaleString()}
</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-white">{overview.totalConversions}</p>
</div>
<div className="p-4 bg-white/5 rounded-lg">
<p className="text-gray-400 text-sm"></p>
<p className="text-xl font-bold text-[#38bdac]">
¥{overview.totalEarnings.toFixed(2)}
</p>
</div>
</div>
<div className="mt-4 p-4 bg-[#38bdac]/10 rounded-lg flex items-center justify-between">
<span className="text-gray-300"></span>
<span className="text-[#38bdac] font-bold text-xl">
{overview.conversionRate}%
</span>
</div>
</CardContent>
</Card>
</div>
{/* 本月/累计/推广 合并成一张表 */}
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-4">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700 text-gray-400 text-xs">
<th className="pb-2 text-left font-normal"></th>
<th className="pb-2 text-right font-normal"></th>
<th className="pb-2 text-right font-normal"></th>
</tr>
</thead>
<tbody className="text-white">
<tr className="border-b border-gray-700/30">
<td className="py-2.5 text-gray-300"></td>
<td className="py-2.5 text-right font-medium">{overview.monthClicks}</td>
<td className="py-2.5 text-right font-medium">{overview.totalClicks.toLocaleString()}</td>
</tr>
<tr className="border-b border-gray-700/30">
<td className="py-2.5 text-gray-300"></td>
<td className="py-2.5 text-right font-medium">{overview.monthBindings}</td>
<td className="py-2.5 text-right font-medium">{overview.totalBindings.toLocaleString()}</td>
</tr>
<tr className="border-b border-gray-700/30">
<td className="py-2.5 text-gray-300"></td>
<td className="py-2.5 text-right font-medium">{overview.monthConversions}</td>
<td className="py-2.5 text-right font-medium">{overview.totalConversions}</td>
</tr>
<tr className="border-b border-gray-700/30">
<td className="py-2.5 text-gray-300"></td>
<td className="py-2.5 text-right font-medium text-[#38bdac]">¥{overview.monthEarnings.toFixed(2)}</td>
<td className="py-2.5 text-right font-medium text-[#38bdac]">¥{overview.totalEarnings.toFixed(2)}</td>
</tr>
<tr>
<td className="py-2.5 text-gray-300"></td>
<td className="py-2.5 text-right"></td>
<td className="py-2.5 text-right font-medium text-[#38bdac]">{overview.conversionRate}%</td>
</tr>
</tbody>
</table>
<div className="flex items-center gap-6 mt-4 pt-3 border-t border-gray-700/30 text-xs">
<span className="text-gray-400">广 <span className="text-white font-medium ml-1">{overview.totalDistributors}</span></span>
<span className="text-gray-400"> <span className="text-green-400 font-medium ml-1">{overview.activeDistributors}</span></span>
<span className="text-gray-400"> <span className="text-[#38bdac] font-medium ml-1">90%</span></span>
<span className="text-gray-400"> <span className="text-orange-400 font-medium ml-1">30</span></span>
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Users className="w-5 h-5 text-[#38bdac]" />
广
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-4">
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-white">{overview.totalDistributors}</p>
<p className="text-gray-400 text-sm mt-1">广</p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-green-400">{overview.activeDistributors}</p>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-[#38bdac]">90%</p>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
<div className="p-4 bg-white/5 rounded-lg text-center">
<p className="text-3xl font-bold text-orange-400">30</p>
<p className="text-gray-400 text-sm mt-1"></p>
</div>
</div>
</CardContent>
</Card>
@@ -685,8 +889,8 @@ export function DistributionPage() {
{activeTab === 'orders' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
@@ -698,7 +902,7 @@ export function DistributionPage() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0"
>
<option value="all"></option>
<option value="completed"></option>
@@ -706,6 +910,16 @@ export function DistributionPage() {
<option value="failed"></option>
<option value="refunded">退</option>
</select>
<Button
type="button"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
@@ -882,8 +1096,8 @@ export function DistributionPage() {
{activeTab === 'bindings' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
@@ -895,13 +1109,23 @@ export function DistributionPage() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0"
>
<option value="all"></option>
<option value="active"></option>
<option value="converted"></option>
<option value="expired"></option>
</select>
<Button
type="button"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
@@ -985,8 +1209,8 @@ export function DistributionPage() {
{activeTab === 'withdrawals' && (
<div className="space-y-4">
<div className="flex gap-4">
<div className="relative flex-1">
<div className="flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={searchTerm}
@@ -998,13 +1222,33 @@ export function DistributionPage() {
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white"
className="px-4 py-2 bg-[#0f2137] border border-gray-700 rounded-lg text-white shrink-0"
>
<option value="all"></option>
<option value="pending"></option>
<option value="completed"></option>
<option value="rejected"></option>
</select>
<div className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#0f2137] border border-gray-700/50 shrink-0">
<Zap className="w-4 h-4 text-[#38bdac]" />
<span className="text-sm text-gray-300"></span>
<Switch
checked={enableAutoApprove}
onCheckedChange={toggleAutoApprove}
disabled={autoApproveLoading}
className="data-[state=checked]:bg-[#38bdac]"
/>
</div>
<Button
type="button"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50">
<CardContent className="p-0">
@@ -1021,6 +1265,7 @@ export function DistributionPage() {
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-right font-medium"></th>
</tr>
</thead>
@@ -1031,7 +1276,7 @@ export function DistributionPage() {
<div className="flex items-center gap-2">
{withdrawal.userAvatar ? (
<img
src={normalizeImageUrl(withdrawal.userAvatar)}
src={withdrawal.userAvatar}
alt=""
className="w-8 h-8 rounded-full object-cover"
/>
@@ -1075,6 +1320,18 @@ export function DistributionPage() {
: '-'}
</td>
<td className="p-4">{getStatusBadge(withdrawal.status)}</td>
<td className="p-4 max-w-[160px]">
<span
className={`text-xs ${
withdrawal.status === 'rejected' || withdrawal.status === 'failed'
? 'text-red-400'
: 'text-gray-400'
}`}
title={withdrawal.remark}
>
{withdrawal.remark || '-'}
</span>
</td>
<td className="p-4 text-right">
{withdrawal.status === 'pending' && (
<div className="flex gap-2 justify-end">
@@ -1142,8 +1399,14 @@ export function DistributionPage() {
<option value="cancelled"></option>
<option value="expired"></option>
</select>
<Button size="sm" variant="outline" onClick={() => loadTabData('giftPay', true)} className="border-gray-600 text-gray-300">
<RefreshCw className="w-4 h-4 mr-1" />
<Button
size="sm"
variant="outline"
onClick={() => void refreshCurrentTab()}
disabled={loading}
className="border-gray-600 text-gray-300"
>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import toast from '@/utils/toast'
import toast from '@/utils/toast'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
@@ -15,6 +15,7 @@ interface ReferralConfig {
minWithdrawAmount: number
bindingDays: number
userDiscount: number
withdrawFee: number
enableAutoWithdraw: boolean
vipOrderShareVip: number
vipOrderShareNonVip: number
@@ -25,6 +26,7 @@ const DEFAULT: ReferralConfig = {
minWithdrawAmount: 10,
bindingDays: 30,
userDiscount: 5,
withdrawFee: 5,
enableAutoWithdraw: false,
vipOrderShareVip: 20,
vipOrderShareNonVip: 10,
@@ -49,6 +51,7 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
minWithdrawAmount: c.minWithdrawAmount ?? 10,
bindingDays: c.bindingDays ?? 30,
userDiscount: c.userDiscount ?? 5,
withdrawFee: c.withdrawFee ?? 5,
enableAutoWithdraw: c.enableAutoWithdraw ?? false,
vipOrderShareVip: c.vipOrderShareVip ?? 20,
vipOrderShareNonVip: c.vipOrderShareNonVip ?? 10,
@@ -67,6 +70,7 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
minWithdrawAmount: Number(config.minWithdrawAmount) || 0,
bindingDays: Number(config.bindingDays) || 0,
userDiscount: Number(config.userDiscount) || 0,
withdrawFee: Number(config.withdrawFee) ?? 5,
enableAutoWithdraw: Boolean(config.enableAutoWithdraw),
vipOrderShareVip: Number(config.vipOrderShareVip) || 20,
vipOrderShareNonVip: Number(config.vipOrderShareNonVip) || 10,
@@ -265,6 +269,22 @@ export function ReferralSettingsPage(_props?: ReferralSettingsPageProps & { embe
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300">%</Label>
<Input
type="number"
min={0}
max={100}
step={0.5}
className="bg-[#0a1628] border-gray-700 text-white"
value={config.withdrawFee}
onChange={handleNumberChange('withdrawFee')}
/>
<p className="text-xs text-gray-500">
5 100 95
</p>
</div>
<div className="space-y-2">
<Label className="text-gray-300 flex items-center gap-2">

View File

@@ -1,5 +1,4 @@
import toast from '@/utils/toast'
import { normalizeImageUrl } from '@/lib/utils'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -45,8 +44,7 @@ import {
ChevronUp,
Crown,
Tag,
Star,
Info,
UserPlus as LeadIcon,
} from 'lucide-react'
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
import { Pagination } from '@/components/ui/Pagination'
@@ -67,10 +65,10 @@ interface User {
earnings: number | string
pendingEarnings?: number | string
withdrawnEarnings?: number | string
walletBalance?: number | string
referralCount?: number
createdAt: string
updatedAt?: string | null
// RFM排序模式时有值
rfmScore?: number
rfmLevel?: string
}
@@ -108,6 +106,7 @@ const JOURNEY_STAGES = [
export function UsersPage() {
const [searchParams, setSearchParams] = useSearchParams()
const poolParam = searchParams.get('pool') // 'vip' | 'complete' | 'all' | null
const tabParam = searchParams.get('tab') || 'users' // users | journey | rules | vip-roles | leads
// ===== 用户列表 state =====
const [users, setUsers] = useState<User[]>([])
@@ -158,12 +157,61 @@ export function UsersPage() {
// ===== 用户旅程总览 =====
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
const [journeyLoading, setJourneyLoading] = useState(false)
const [journeySelectedStage, setJourneySelectedStage] = useState<string | null>(null)
const [journeyUsers, setJourneyUsers] = useState<{ id: string; nickname: string; phone?: string; createdAt?: string }[]>([])
const [journeyUsersLoading, setJourneyUsersLoading] = useState(false)
// RFM 算法说明
const [showRfmInfo, setShowRfmInfo] = useState(false)
// ===== 获客列表(存客宝) =====
const [leadsRecords, setLeadsRecords] = useState<{
id: number
userId?: string
userNickname?: string
phone?: string
wechatId?: string
name?: string
source?: string
personName?: string
ckbPlanId?: number
createdAt?: string
}[]>([])
const [leadsTotal, setLeadsTotal] = useState(0)
const [leadsPage, setLeadsPage] = useState(1)
const [leadsPageSize] = useState(20)
const [leadsLoading, setLeadsLoading] = useState(false)
const loadLeads = useCallback(async () => {
setLeadsLoading(true)
try {
const data = await get<{ success?: boolean; records?: unknown[]; total?: number }>(
`/api/db/ckb-leads?mode=contact&page=${leadsPage}&pageSize=${leadsPageSize}`
)
if (data?.success) {
setLeadsRecords((data.records || []) as typeof leadsRecords)
setLeadsTotal(data.total ?? 0)
}
} catch {
setLeadsRecords([])
setLeadsTotal(0)
} finally {
setLeadsLoading(false)
}
}, [leadsPage, leadsPageSize])
useEffect(() => {
if (searchParams.get('tab') === 'leads') loadLeads()
}, [searchParams.get('tab'), leadsPage, loadLeads])
// ===== 在线人数WSS 占位) =====
const [onlineCount, setOnlineCount] = useState<number | null>(null)
const loadOnlineStats = useCallback(async () => {
try {
const data = await get<{ success?: boolean; onlineCount?: number }>('/api/admin/users/online-stats')
if (data?.success && typeof data.onlineCount === 'number') setOnlineCount(data.onlineCount)
else setOnlineCount(0)
} catch {
setOnlineCount(null)
}
}, [])
useEffect(() => {
loadOnlineStats()
const t = setInterval(loadOnlineStats, 10000)
return () => clearInterval(t)
}, [loadOnlineStats])
// ===== 用户列表 =====
async function loadUsers(fromRefresh = false) {
@@ -310,20 +358,13 @@ export function UsersPage() {
try {
if (editingRule) {
const data = await put<{ success?: boolean; error?: string }>('/api/db/user-rules', { id: editingRule.id, ...ruleForm })
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '未知错误')); return }
toast.success('规则已更新')
if (!data?.success) { toast.error('更新失败: ' + (data?.error || '')); return }
} else {
const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', ruleForm)
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '未知错误')); return }
toast.success('规则已创建')
if (!data?.success) { toast.error('创建失败: ' + (data?.error || '')); return }
}
setShowRuleModal(false); loadRules()
} catch (err) {
const e = err as Error & { status?: number }
if (e?.status === 401) toast.error('登录已过期,请重新登录')
else if (e?.status === 404) toast.error('接口不存在,请确认后端已部署最新版本')
else toast.error('保存失败: ' + (e?.message || '网络错误'))
} finally { setIsSaving(false) }
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
}
async function handleDeleteRule(id: number) {
@@ -512,41 +553,6 @@ export function UsersPage() {
} catch { } finally { setJourneyLoading(false) }
}, [])
async function loadJourneyUsers(stageId: string) {
setJourneySelectedStage(stageId)
setJourneyUsersLoading(true)
try {
const data = await get<{ success?: boolean; users?: { id: string; nickname: string; phone?: string; createdAt?: string }[] }>(
`/api/db/users/journey-users?stage=${encodeURIComponent(stageId)}&limit=20`
)
if (data?.success && data.users) setJourneyUsers(data.users)
else setJourneyUsers([])
} catch {
setJourneyUsers([])
} finally {
setJourneyUsersLoading(false)
}
}
async function handleSetSuperMember(user: User) {
if (!user.hasFullBook) {
toast.error('仅 VIP 用户可置顶到超级个体')
return
}
if (!confirm('确定将该用户置顶到首页超级个体位最多4位')) return
try {
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: user.id, vipSort: 1 })
if (!res?.success) {
toast.error(res?.error || '置顶失败')
return
}
toast.success('已置顶到超级个体')
loadUsers()
} catch {
toast.error('置顶失败')
}
}
return (
<div className="p-8 w-full">
{error && (
@@ -558,21 +564,23 @@ export function UsersPage() {
<div className="flex justify-between items-center mb-6">
<div>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-white"></h2>
<Button variant="ghost" size="sm" onClick={() => setShowRfmInfo(true)} className="text-gray-500 hover:text-[#38bdac] h-8 w-8 p-0" title="RFM 算法说明">
<Info className="w-4 h-4" />
</Button>
</div>
<p className="text-gray-400 mt-1 text-sm"> {total} {rfmSortMode && ' · RFM 排序中'}</p>
<h2 className="text-2xl font-bold text-white"></h2>
<p className="text-gray-400 mt-1 text-sm">
{total}
{onlineCount !== null && <span className="text-[#38bdac] ml-1">· 线 {onlineCount} </span>}
{rfmSortMode && ' · RFM 排序中'}
</p>
</div>
</div>
<Tabs defaultValue="users" className="w-full">
<Tabs value={tabParam} onValueChange={(v) => { const sp = new URLSearchParams(searchParams); if (v === 'users') sp.delete('tab'); else sp.set('tab', v); setSearchParams(sp) }} className="w-full">
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-6 flex-wrap h-auto gap-1">
<TabsTrigger value="users" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5">
<Users className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="leads" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={() => loadLeads()}>
<LeadIcon className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="journey" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadJourneyStats}>
<Navigation className="w-4 h-4" />
</TabsTrigger>
@@ -640,7 +648,6 @@ export function UsersPage() {
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400">/</TableHead>
<TableHead className="text-gray-400 cursor-pointer select-none" onClick={toggleRfmSort}>
<div className="flex items-center gap-1 group">
<TrendingUp className="w-3.5 h-3.5" />
@@ -668,7 +675,7 @@ export function UsersPage() {
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
{user.avatar ? (
<img src={normalizeImageUrl(user.avatar)} className="w-full h-full rounded-full object-cover" alt="" />
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
) : user.nickname?.charAt(0) || '?'}
</div>
<div>
@@ -715,14 +722,6 @@ export function UsersPage() {
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="text-white font-medium">¥{parseFloat(String(user.walletBalance || 0)).toFixed(2)}</div>
{parseFloat(String(user.withdrawnEarnings || 0)) > 0 && (
<div className="text-xs text-gray-400">: ¥{parseFloat(String(user.withdrawnEarnings || 0)).toFixed(2)}</div>
)}
</div>
</TableCell>
{/* RFM 分值列 */}
<TableCell>
{user.rfmScore !== undefined ? (
@@ -742,9 +741,6 @@ export function UsersPage() {
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" onClick={() => { setSelectedUserIdForDetail(user.id); setShowDetailModal(true) }} className="text-gray-400 hover:text-blue-400 hover:bg-blue-400/10" title="用户详情"><Eye className="w-4 h-4" /></Button>
{user.hasFullBook && (
<Button variant="ghost" size="sm" onClick={() => handleSetSuperMember(user)} className="text-gray-400 hover:text-orange-400 hover:bg-orange-400/10" title="置顶超级个体"><Star className="w-4 h-4" /></Button>
)}
<Button variant="ghost" size="sm" onClick={() => handleEditUser(user)} className="text-gray-400 hover:text-[#38bdac] hover:bg-[#38bdac]/10" title="编辑用户"><Edit3 className="w-4 h-4" /></Button>
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 hover:bg-red-500/10" onClick={() => handleDelete(user.id)} title="删除"><Trash2 className="w-4 h-4" /></Button>
</div>
@@ -765,6 +761,67 @@ export function UsersPage() {
</Card>
</TabsContent>
{/* ===== 获客列表(存客宝) ===== */}
<TabsContent value="leads">
<div className="flex items-center justify-end mb-4">
<Button variant="outline" onClick={loadLeads} disabled={leadsLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${leadsLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
{leadsLoading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
<span className="ml-2 text-gray-400">...</span>
</div>
) : (
<div>
<Table>
<TableHeader>
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"> @人</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{leadsRecords.map((r) => (
<TableRow key={r.id} className="hover:bg-[#0a1628] border-gray-700/50">
<TableCell className="text-gray-300">{r.userNickname || r.name || '-'}</TableCell>
<TableCell className="text-gray-300">{r.phone || '-'}</TableCell>
<TableCell className="text-gray-300">{r.wechatId || '-'}</TableCell>
<TableCell className="text-[#38bdac]">{r.personName || '-'}</TableCell>
<TableCell className="text-gray-400">{r.ckbPlanId ? `#${r.ckbPlanId}` : '-'}</TableCell>
<TableCell className="text-gray-400">{r.source || '-'}</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{leadsRecords.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-12 text-gray-500"></TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Pagination
page={leadsPage}
totalPages={Math.ceil(leadsTotal / leadsPageSize) || 1}
total={leadsTotal}
pageSize={leadsPageSize}
onPageChange={setLeadsPage}
onPageSizeChange={() => {}}
/>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* ===== 用户旅程总览 ===== */}
<TabsContent value="journey">
<div className="flex items-center justify-between mb-5">
@@ -782,13 +839,7 @@ export function UsersPage() {
{JOURNEY_STAGES.map((stage, idx) => (
<div key={stage.id} className="relative flex flex-col items-center">
{/* 阶段卡片 */}
<div
role="button"
tabIndex={0}
className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-pointer`}
onClick={() => loadJourneyUsers(stage.id)}
onKeyDown={(e) => e.key === 'Enter' && loadJourneyUsers(stage.id)}
>
<div className={`relative w-full p-3 rounded-xl border ${stage.color} text-center cursor-default`}>
<div className="text-2xl mb-1">{stage.icon}</div>
<div className={`text-xs font-medium ${stage.color.split(' ').find(c => c.startsWith('text-'))}`}>{stage.label}</div>
{journeyStats[stage.id] !== undefined && (
@@ -814,44 +865,6 @@ export function UsersPage() {
</div>
</div>
{/* 选中阶段的用户列表 */}
{journeySelectedStage && (
<div className="mb-6 bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-white font-medium">
{JOURNEY_STAGES.find((s) => s.id === journeySelectedStage)?.label}
</span>
<Button variant="ghost" size="sm" onClick={() => setJourneySelectedStage(null)} className="text-gray-500 hover:text-white">
<X className="w-4 h-4" />
</Button>
</div>
{journeyUsersLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 text-[#38bdac] animate-spin" />
</div>
) : journeyUsers.length > 0 ? (
<div className="space-y-2 max-h-60 overflow-y-auto">
{journeyUsers.map((u) => (
<div
key={u.id}
className="flex items-center justify-between py-2 px-3 bg-[#0a1628] rounded-lg hover:bg-[#0a1628]/80 cursor-pointer"
onClick={() => { setSelectedUserIdForDetail(u.id); setShowDetailModal(true) }}
onKeyDown={(e) => e.key === 'Enter' && (setSelectedUserIdForDetail(u.id), setShowDetailModal(true))}
role="button"
tabIndex={0}
>
<span className="text-white font-medium">{u.nickname}</span>
<span className="text-gray-400 text-sm">{u.phone || '—'}</span>
<span className="text-gray-500 text-xs">{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'}</span>
</div>
))}
</div>
) : (
<p className="text-gray-500 text-sm py-4"></p>
)}
</div>
)}
{/* 旅程说明 */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-[#0f2137] border border-gray-700/50 rounded-lg p-4">
@@ -1032,7 +1045,7 @@ export function UsersPage() {
{m.avatar ? (
// eslint-disable-next-line jsx-a11y/alt-text
<img
src={normalizeImageUrl(m.avatar)}
src={m.avatar}
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
/>
) : (
@@ -1245,28 +1258,6 @@ export function UsersPage() {
</DialogContent>
</Dialog>
{/* RFM 算法说明 */}
<Dialog open={showRfmInfo} onOpenChange={setShowRfmInfo}>
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-[#38bdac]" />
RFM
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-4 text-sm text-gray-300">
<p><span className="text-[#38bdac] font-medium">RRecency</span> 40%</p>
<p><span className="text-[#38bdac] font-medium">FFrequency</span> 30%</p>
<p><span className="text-[#38bdac] font-medium">MMonetary</span> 30%</p>
<p className="text-gray-400"> = R×40% + F×30% + M×30% 0-100</p>
<p className="text-gray-400"><span className="text-amber-400">S</span>85<span className="text-green-400">A</span>70<span className="text-blue-400">B</span>50<span className="text-gray-400">C</span>30<span className="text-red-400">D</span>&lt;30</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRfmInfo(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<UserDetailModal open={showDetailModal} onClose={() => setShowDetailModal(false)} userId={selectedUserIdForDetail} onUserUpdated={loadUsers} />
</div>
)

View File

@@ -34,6 +34,7 @@ interface Withdrawal {
account?: string
name?: string
userConfirmedAt?: string | null
remark?: string
userCommissionInfo?: {
totalCommission: number
withdrawnEarnings: number
@@ -248,14 +249,14 @@ export function WithdrawalsPage() {
<p className="text-gray-400 mt-1"></p>
</div>
<Button
variant="outline"
onClick={loadWithdrawals}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
variant="outline"
onClick={loadWithdrawals}
disabled={loading}
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{/* 分账规则说明 */}
@@ -278,7 +279,7 @@ export function WithdrawalsPage() {
</p>
<p>
<span className="text-[#38bdac]"></span>
广-
</p>
</div>
</div>
@@ -366,6 +367,7 @@ export function WithdrawalsPage() {
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-left font-medium"></th>
<th className="p-4 text-right font-medium"></th>
@@ -445,9 +447,18 @@ export function WithdrawalsPage() {
</td>
<td className="p-4">
{getStatusBadge(w.status)}
{w.errorMessage && (
<p className="text-xs text-red-400 mt-1">{w.errorMessage}</p>
)}
</td>
<td className="p-4 max-w-[180px]">
<span
className={`text-xs ${
w.status === 'rejected' || w.status === 'failed'
? 'text-red-400'
: 'text-gray-400'
}`}
title={w.remark}
>
{w.remark || '-'}
</span>
</td>
<td className="p-4 text-gray-400">
{w.processedAt ? new Date(w.processedAt).toLocaleString() : '-'}