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:
914
soul-admin/dist/assets/index-34teBEu9.js
vendored
914
soul-admin/dist/assets/index-34teBEu9.js
vendored
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-B7tt33mg.css
vendored
1
soul-admin/dist/assets/index-B7tt33mg.css
vendored
File diff suppressed because one or more lines are too long
914
soul-admin/dist/assets/index-DCoaVA6V.js
vendored
Normal file
914
soul-admin/dist/assets/index-DCoaVA6V.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-DGXqHqcA.css
vendored
Normal file
1
soul-admin/dist/assets/index-DGXqHqcA.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
自动提现开关
|
||||
|
||||
@@ -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">R(Recency)</span>:距最近购买天数,越近分越高,权重 40%</p>
|
||||
<p><span className="text-[#38bdac] font-medium">F(Frequency)</span>:购买频次,越多分越高,权重 30%</p>
|
||||
<p><span className="text-[#38bdac] font-medium">M(Monetary)</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>(<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>
|
||||
)
|
||||
|
||||
@@ -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() : '-'}
|
||||
|
||||
Reference in New Issue
Block a user