From 5af882cc973568aef08f588146a37f923f6216ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A1=E8=8B=A5?= Date: Sun, 8 Mar 2026 08:22:03 +0800 Subject: [PATCH] =?UTF-8?q?sync:=20soul-admin=20=E9=A1=B5=E9=9D=A2=20|=20?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0:=20=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- soul-admin/src/pages/users/UsersPage.tsx | 1076 +++++++++++++++------- 1 file changed, 737 insertions(+), 339 deletions(-) diff --git a/soul-admin/src/pages/users/UsersPage.tsx b/soul-admin/src/pages/users/UsersPage.tsx index c57f2e5a..11ace343 100644 --- a/soul-admin/src/pages/users/UsersPage.tsx +++ b/soul-admin/src/pages/users/UsersPage.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { Card, CardContent } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Table, TableBody, @@ -20,6 +21,7 @@ import { DialogFooter, } from '@/components/ui/dialog' import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' import { Search, UserPlus, @@ -32,6 +34,11 @@ import { Users, Eye, Crown, + Plus, + BookOpen, + TrendingUp, + Settings, + PenLine, } from 'lucide-react' import { UserDetailModal } from '@/components/modules/user/UserDetailModal' import { SetVipModal } from '@/components/modules/user/SetVipModal' @@ -57,7 +64,32 @@ interface User { updatedAt?: string | null } +interface UserRule { + id: number + title: string + description: string + trigger: string + sort: number + enabled: boolean + createdAt?: string + updatedAt?: string +} + +interface RFMUser { + id: string + nickname: string + phone?: string + avatar?: string + rfmScore: number + rfmLevel: string + recency: number + frequency: number + monetary: number + lastOrderAt?: string +} + export function UsersPage() { + // ===== 用户列表 state ===== const [users, setUsers] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(1) @@ -92,6 +124,20 @@ export function UsersPage() { hasFullBook: false, }) + // ===== 规则管理 state ===== + const [rules, setRules] = useState([]) + const [rulesLoading, setRulesLoading] = useState(false) + const [showRuleModal, setShowRuleModal] = useState(false) + const [editingRule, setEditingRule] = useState(null) + const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true }) + + // ===== RFM 排行榜 state ===== + const [rfmList, setRfmList] = useState([]) + const [rfmLoading, setRfmLoading] = useState(false) + const [rfmSearch, setRfmSearch] = useState('') + const debouncedRfmSearch = useDebounce(rfmSearch, 300) + + // ===== 用户列表 ===== async function loadUsers() { setIsLoading(true) setError(null) @@ -160,13 +206,7 @@ export function UsersPage() { const handleAddUser = () => { setEditingUser(null) - setFormData({ - phone: '', - nickname: '', - password: '', - isAdmin: false, - hasFullBook: false, - }) + setFormData({ phone: '', nickname: '', password: '', isAdmin: false, hasFullBook: false }) setShowUserModal(true) } @@ -185,10 +225,7 @@ export function UsersPage() { hasFullBook: formData.hasFullBook, ...(formData.password && { password: formData.password }), }) - if (!data?.success) { - alert('更新失败: ' + (data?.error || '未知错误')) - return - } + if (!data?.success) { alert('更新失败: ' + (data?.error || '未知错误')); return } } else { const data = await post<{ success?: boolean; error?: string }>('/api/db/users', { phone: formData.phone, @@ -196,10 +233,7 @@ export function UsersPage() { password: formData.password, isAdmin: formData.isAdmin, }) - if (!data?.success) { - alert('创建失败: ' + (data?.error || '未知错误')) - return - } + if (!data?.success) { alert('创建失败: ' + (data?.error || '未知错误')); return } } setShowUserModal(false) loadUsers() @@ -247,30 +281,17 @@ export function UsersPage() { } async function handleSavePassword() { - if (!newPassword) { - alert('请输入新密码') - return - } - if (newPassword !== confirmPassword) { - alert('两次输入的密码不一致') - return - } - if (newPassword.length < 6) { - alert('密码长度不能少于6位') - return - } + if (!newPassword) { alert('请输入新密码'); return } + if (newPassword !== confirmPassword) { alert('两次输入的密码不一致'); return } + if (newPassword.length < 6) { alert('密码长度不能少于6位'); return } setIsSaving(true) try { const data = await put<{ success?: boolean; error?: string }>('/api/db/users', { id: editingUser?.id, password: newPassword, }) - if (data?.success) { - alert('密码修改成功') - setShowPasswordModal(false) - } else { - alert('密码修改失败: ' + (data?.error || '未知错误')) - } + if (data?.success) { alert('密码修改成功'); setShowPasswordModal(false) } + else alert('密码修改失败: ' + (data?.error || '未知错误')) } catch (err) { console.error('Change password error:', err) alert('密码修改失败') @@ -279,71 +300,613 @@ export function UsersPage() { } } + // ===== 规则管理 ===== + const loadRules = useCallback(async () => { + setRulesLoading(true) + try { + const data = await get<{ success?: boolean; rules?: UserRule[] }>('/api/db/user-rules') + if (data?.success) setRules(data.rules || []) + } catch (err) { + console.error('Load rules error:', err) + } finally { + setRulesLoading(false) + } + }, []) + + const handleAddRule = () => { + setEditingRule(null) + setRuleForm({ title: '', description: '', trigger: '', sort: 0, enabled: true }) + setShowRuleModal(true) + } + + const handleEditRule = (rule: UserRule) => { + setEditingRule(rule) + setRuleForm({ + title: rule.title, + description: rule.description, + trigger: rule.trigger, + sort: rule.sort, + enabled: rule.enabled, + }) + setShowRuleModal(true) + } + + async function handleSaveRule() { + if (!ruleForm.title) { alert('请填写规则标题'); return } + setIsSaving(true) + try { + if (editingRule) { + const data = await put<{ success?: boolean; error?: string }>('/api/db/user-rules', { + id: editingRule.id, + ...ruleForm, + }) + if (!data?.success) { alert('更新失败: ' + (data?.error || '')); return } + } else { + const data = await post<{ success?: boolean; error?: string }>('/api/db/user-rules', ruleForm) + if (!data?.success) { alert('创建失败: ' + (data?.error || '')); return } + } + setShowRuleModal(false) + loadRules() + } catch (err) { + console.error('Save rule error:', err) + alert('保存失败') + } finally { + setIsSaving(false) + } + } + + async function handleDeleteRule(ruleId: number) { + if (!confirm('确定删除该规则?')) return + try { + const data = await del<{ success?: boolean; error?: string }>(`/api/db/user-rules?id=${ruleId}`) + if (data?.success) loadRules() + else alert('删除失败: ' + (data?.error || '')) + } catch (err) { + console.error('Delete rule error:', err) + } + } + + async function handleToggleRule(rule: UserRule) { + try { + await put('/api/db/user-rules', { id: rule.id, enabled: !rule.enabled }) + loadRules() + } catch (err) { + console.error('Toggle rule error:', err) + } + } + + // ===== RFM 排行榜 ===== + const loadRFM = useCallback(async () => { + setRfmLoading(true) + try { + const params = new URLSearchParams({ search: debouncedRfmSearch, limit: '50' }) + const data = await get<{ success?: boolean; users?: RFMUser[] }>(`/api/db/users/rfm?${params}`) + if (data?.success) setRfmList(data.users || []) + } catch (err) { + console.error('Load RFM error:', err) + } finally { + setRfmLoading(false) + } + }, [debouncedRfmSearch]) + + const getRFMLevelColor = (level: string) => { + const map: Record = { + S: 'bg-amber-500/20 text-amber-400', + A: 'bg-green-500/20 text-green-400', + B: 'bg-blue-500/20 text-blue-400', + C: 'bg-gray-500/20 text-gray-400', + D: 'bg-red-500/20 text-red-400', + } + return map[level] || 'bg-gray-500/20 text-gray-400' + } + return (
{error && (
{error} - +
)} -
+ +

用户管理

-

- 共 {total} 位注册用户 - {vipFilter === 'vip' && `,当前筛选 VIP`} -

-
-
- - -
- - setSearchTerm(e.target.value)} - /> -
- +

共 {total} 位注册用户

+ + + + + 用户列表 + + + + RFM 估值排行 + + + + 规则配置 + + + + {/* ===== 用户列表 Tab ===== */} + +
+ + +
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ + + + {isLoading ? ( +
+ + 加载中... +
+ ) : ( +
+ + + + 用户信息 + 绑定信息 + 购买状态 + 分销收益 + 注册时间 + 操作 + + + + {users.map((user) => ( + + +
+
+ {user.avatar ? ( + + ) : ( + user.nickname?.charAt(0) || '?' + )} +
+
+
+

{user.nickname}

+ {user.isAdmin && ( + + 管理员 + + )} + {user.openId && !user.id?.startsWith('user_') && ( + + 微信 + + )} +
+

+ {user.openId ? user.openId.slice(0, 12) + '...' : user.id?.slice(0, 12)} +

+
+
+
+ +
+ {user.phone && ( +
+ 📱 + {user.phone} +
+ )} + {user.wechatId && ( +
+ 💬 + {user.wechatId} +
+ )} + {user.openId && ( +
+ 🔗 + + {user.openId.slice(0, 12)}... + +
+ )} + {!user.phone && !user.wechatId && !user.openId && ( + 未绑定 + )} +
+
+ + {user.hasFullBook ? ( + + VIP + + ) : ( + + 未购买 + + )} + + +
+
+ ¥{parseFloat(String(user.earnings || 0)).toFixed(2)} +
+ {parseFloat(String(user.pendingEarnings || 0)) > 0 && ( +
+ 待提现: ¥{parseFloat(String(user.pendingEarnings || 0)).toFixed(2)} +
+ )} +
handleViewReferrals(user)} + onKeyDown={(e) => e.key === 'Enter' && handleViewReferrals(user)} + role="button" + tabIndex={0} + > + + 绑定{user.referralCount || 0}人 +
+
+
+ + {user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'} + + +
+ + + + + +
+
+
+ ))} + {users.length === 0 && ( + + + 暂无用户数据 + + + )} +
+
+ { setPageSize(n); setPage(1) }} + /> +
+ )} +
+
+
+ + {/* ===== RFM 排行榜 Tab ===== */} + +
+
+ + setRfmSearch(e.target.value)} + /> +
+ +
+ S 超级VIP + A 高价值 + B 潜力用户 + C 普通 + D 低活跃 +
+
+ + {/* RFM 说明卡片 */} +
+
+
+
R
+ 最近购买(Recency) +
+

距离最近一次购买的天数,越近分值越高

+
+
+
+
F
+ 购买频率(Frequency) +
+

累计购买次数,频次越高分值越高

+
+
+
+
M
+ 消费金额(Monetary) +
+

累计消费总额,金额越高分值越高

+
+
+ + + + {rfmLoading ? ( +
+ + 计算 RFM 中... +
+ ) : rfmList.length === 0 ? ( +
+ +

点击「刷新」加载 RFM 排行榜

+
+ ) : ( + + + + 排名 + 用户 + 等级 + RFM总分 + R 最近购买 + F 购买频次 + M 消费金额 + 最近订单 + + + + {rfmList.map((u, i) => ( + + + + #{i + 1} + + + +
+
+ {u.avatar ? ( + + ) : ( + u.nickname?.charAt(0) || '?' + )} +
+
+

{u.nickname}

+ {u.phone &&

{u.phone}

} +
+
+
+ + + {u.rfmLevel} 级 + + + + {u.rfmScore} + + + {u.recency} + + + {u.frequency} + + + ¥{u.monetary.toFixed(0)} + + + {u.lastOrderAt ? new Date(u.lastOrderAt).toLocaleDateString() : '-'} + +
+ ))} +
+
+ )} +
+
+
+ + {/* ===== 规则配置 Tab ===== */} + +
+
+

+ 配置用户旅程引导规则,包括填写头像、完善信息、付款节点等步骤触发规则,帮助用户了解完整路径。 +

+
+
+ + +
+
+ + {rulesLoading ? ( +
+ + 加载中... +
+ ) : rules.length === 0 ? ( +
+ +

暂无规则配置

+

添加用户旅程规则,帮助引导用户完善个人信息

+ +
+ ) : ( +
+ {rules.map((rule) => ( +
+
+
+
+ + {rule.title} + {rule.trigger && ( + + 触发:{rule.trigger} + + )} + + {rule.enabled ? '启用中' : '已禁用'} + +
+ {rule.description && ( +

{rule.description}

+ )} +
+
+ handleToggleRule(rule)} + /> + + +
+
+
+ ))} +
+ )} +
+
+ + {/* ===== 添加/编辑用户弹框 ===== */} - {editingUser ? ( - - ) : ( - - )} + {editingUser ? : } {editingUser ? '编辑用户' : '添加用户'} @@ -368,9 +931,7 @@ export function UsersPage() { />
- + 取消 - @@ -415,6 +972,7 @@ export function UsersPage() { + {/* ===== 修改密码弹框 ===== */} @@ -457,32 +1015,79 @@ export function UsersPage() { > 取消 - - setShowDetailModal(false)} - userId={selectedUserIdForDetail} - onUserUpdated={loadUsers} - /> - - { setShowSetVipModal(false); setSelectedUserForVip(null) }} - userId={selectedUserForVip?.id ?? null} - userNickname={selectedUserForVip?.nickname} - onSaved={loadUsers} - /> + {/* ===== 添加/编辑规则弹框 ===== */} + + + + + + {editingRule ? '编辑规则' : '添加规则'} + + +
+
+ + setRuleForm({ ...ruleForm, title: e.target.value })} + /> +
+
+ +