Refactor user profile handling and navigation logic in the mini program. Introduce functions to ensure user profile completeness after login, update avatar selection process, and enhance navigation between chapters based on backend data. Update API endpoints for user data synchronization and improve user experience with new UI elements for profile editing.
This commit is contained in:
@@ -226,6 +226,8 @@ export function ContentPage() {
|
||||
const [rankingWeightsLoading, setRankingWeightsLoading] = useState(false)
|
||||
const [rankingWeightsSaving, setRankingWeightsSaving] = useState(false)
|
||||
const [rankingPage, setRankingPage] = useState(1)
|
||||
const [rankedSectionsList, setRankedSectionsList] = useState<SectionListItem[]>([])
|
||||
const [rankingLoading, setRankingLoading] = useState(false)
|
||||
const [pinnedSectionIds, setPinnedSectionIds] = useState<string[]>([])
|
||||
const [pinnedLoading, setPinnedLoading] = useState(false)
|
||||
const [previewPercent, setPreviewPercent] = useState(20)
|
||||
@@ -236,21 +238,28 @@ export function ContentPage() {
|
||||
const [newPerson, setNewPerson] = useState({ personId: '', name: '', label: '', ckbApiKey: '' })
|
||||
const [editingPersonKey, setEditingPersonKey] = useState<string | null>(null) // 正在编辑密钥的 personId
|
||||
const [editingPersonKeyValue, setEditingPersonKeyValue] = useState('')
|
||||
const [newLinkTag, setNewLinkTag] = useState({ tagId: '', label: '', url: '', type: 'url' as 'url' | 'miniprogram' | 'ckb', appId: '', pagePath: '' })
|
||||
const [newLinkTag, setNewLinkTag] = useState({
|
||||
tagId: '',
|
||||
label: '',
|
||||
url: '',
|
||||
type: 'url' as 'url' | 'miniprogram' | 'ckb',
|
||||
appId: '',
|
||||
pagePath: '',
|
||||
})
|
||||
const [editingLinkTagId, setEditingLinkTagId] = useState<string | null>(null)
|
||||
const richEditorRef = useRef<RichEditorRef>(null)
|
||||
|
||||
const tree = buildTree(sectionsList)
|
||||
const totalSections = sectionsList.length
|
||||
|
||||
const rankedSections = [...sectionsList].sort((a, b) => (b.hotScore ?? 0) - (a.hotScore ?? 0))
|
||||
// 内容排行榜:排序与置顶由后端 API 统一计算,前端只展示
|
||||
const RANKING_PAGE_SIZE = 10
|
||||
const rankingTotalPages = Math.max(1, Math.ceil(rankedSections.length / RANKING_PAGE_SIZE))
|
||||
const rankingPageSections = rankedSections.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
|
||||
const rankingTotalPages = Math.max(1, Math.ceil(rankedSectionsList.length / RANKING_PAGE_SIZE))
|
||||
const rankingPageSections = rankedSectionsList.slice((rankingPage - 1) * RANKING_PAGE_SIZE, rankingPage * RANKING_PAGE_SIZE)
|
||||
|
||||
const loadList = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 每次请求均从服务端拉取最新数据,确保点击量/付款数与 reading_progress、orders 表直接捆绑
|
||||
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
|
||||
'/api/db/book?action=list',
|
||||
{ cache: 'no-store' as RequestCache },
|
||||
@@ -264,8 +273,29 @@ export function ContentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRanking = async () => {
|
||||
setRankingLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; sections?: SectionListItem[] }>(
|
||||
'/api/db/book?action=ranking',
|
||||
{ cache: 'no-store' as RequestCache },
|
||||
)
|
||||
const sections = Array.isArray(data?.sections) ? data.sections : []
|
||||
setRankedSectionsList(sections)
|
||||
// 同步置顶配置(后端为唯一数据源)
|
||||
const pinned = sections.filter((s) => s.isPinned).map((s) => s.id)
|
||||
setPinnedSectionIds(pinned)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
setRankedSectionsList([])
|
||||
} finally {
|
||||
setRankingLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadList()
|
||||
loadRanking()
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -310,6 +340,7 @@ export function ContentPage() {
|
||||
if (res && (res as { success?: boolean }).success !== false) {
|
||||
toast.success('已删除')
|
||||
loadList()
|
||||
loadRanking()
|
||||
} else {
|
||||
toast.error('删除失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '未知错误'))
|
||||
}
|
||||
@@ -394,9 +425,25 @@ export function ContentPage() {
|
||||
|
||||
const loadLinkTags = useCallback(async () => {
|
||||
try {
|
||||
const data = await get<{ success?: boolean; linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[] }>('/api/db/link-tags')
|
||||
if (data?.success && data.linkTags) setLinkTags(data.linkTags.map(t => ({ id: t.tagId, label: t.label, url: t.url, type: t.type as 'url' | 'miniprogram', appId: t.appId, pagePath: t.pagePath })))
|
||||
} catch { /* ignore */ }
|
||||
const data = await get<{
|
||||
success?: boolean
|
||||
linkTags?: { tagId: string; label: string; url: string; type: string; appId?: string; pagePath?: string }[]
|
||||
}>('/api/db/link-tags')
|
||||
if (data?.success && data.linkTags) {
|
||||
setLinkTags(
|
||||
data.linkTags.map((t) => ({
|
||||
id: t.tagId,
|
||||
label: t.label,
|
||||
url: t.url,
|
||||
type: (t.type || 'url') as 'url' | 'miniprogram' | 'ckb',
|
||||
appId: t.appId || '',
|
||||
pagePath: t.pagePath || '',
|
||||
})),
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTogglePin = async (sectionId: string) => {
|
||||
@@ -410,6 +457,7 @@ export function ContentPage() {
|
||||
value: next,
|
||||
description: '强制置顶章节ID列表(精选推荐/首页最新更新)',
|
||||
})
|
||||
loadRanking() // 置顶配置变更后重新拉取排行榜(后端统一计算排序)
|
||||
} catch { setPinnedSectionIds(pinnedSectionIds) }
|
||||
}
|
||||
|
||||
@@ -1984,13 +2032,23 @@ export function ContentPage() {
|
||||
<CardTitle className="text-white text-base flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-amber-400" />
|
||||
内容排行榜
|
||||
<span className="text-xs text-gray-500 font-normal ml-2">按热度排行 · 共 {rankedSections.length} 节</span>
|
||||
<span className="text-xs text-gray-500 font-normal ml-2">按热度排行 · 共 {rankedSectionsList.length} 节</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={rankingPage <= 1}
|
||||
onClick={() => loadRanking()}
|
||||
disabled={rankingLoading}
|
||||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||||
title="刷新排行榜"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${rankingLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={rankingPage <= 1 || rankingLoading}
|
||||
onClick={() => setRankingPage((p) => Math.max(1, p - 1))}
|
||||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||||
>
|
||||
@@ -2000,7 +2058,7 @@ export function ContentPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={rankingPage >= rankingTotalPages}
|
||||
disabled={rankingPage >= rankingTotalPages || rankingLoading}
|
||||
onClick={() => setRankingPage((p) => Math.min(rankingTotalPages, p + 1))}
|
||||
className="text-gray-400 hover:text-white h-7 w-7 p-0"
|
||||
>
|
||||
@@ -2023,7 +2081,7 @@ export function ContentPage() {
|
||||
</div>
|
||||
{rankingPageSections.map((s, idx) => {
|
||||
const globalRank = (rankingPage - 1) * RANKING_PAGE_SIZE + idx + 1
|
||||
const isPinned = pinnedSectionIds.includes(s.id)
|
||||
const isPinned = s.isPinned ?? pinnedSectionIds.includes(s.id)
|
||||
return (
|
||||
<div
|
||||
key={s.id}
|
||||
@@ -2217,35 +2275,102 @@ export function ContentPage() {
|
||||
<Input className="bg-[#0a1628] border-gray-700 text-white h-8 w-36" placeholder="pages/index/index" value={newLinkTag.pagePath} onChange={e => setNewLinkTag({ ...newLinkTag, pagePath: e.target.value })} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="bg-amber-500 hover:bg-amber-600 text-white h-8" onClick={async () => {
|
||||
if (!newLinkTag.tagId || !newLinkTag.label) { toast.error('标签ID和显示文字必填'); return }
|
||||
const payload = { ...newLinkTag }
|
||||
if (payload.type === 'miniprogram' && payload.appId) payload.url = `weixin://dl/business/?appid=${payload.appId}&path=${payload.pagePath}`
|
||||
await post('/api/db/link-tags', payload)
|
||||
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||||
loadLinkTags()
|
||||
}}>
|
||||
<Plus className="w-3 h-3 mr-1" />添加
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white h-8"
|
||||
onClick={async () => {
|
||||
if (!newLinkTag.tagId || !newLinkTag.label) {
|
||||
toast.error('标签ID和显示文字必填')
|
||||
return
|
||||
}
|
||||
const payload = { ...newLinkTag }
|
||||
await post('/api/db/link-tags', payload)
|
||||
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||||
setEditingLinkTagId(null)
|
||||
loadLinkTags()
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
{editingLinkTagId ? '保存' : '添加'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-[400px] overflow-y-auto">
|
||||
{linkTags.map(t => (
|
||||
{linkTags.map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between bg-[#0a1628] rounded px-3 py-2">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-amber-400 font-bold text-base">#{t.label}</span>
|
||||
<Badge variant="secondary" className={`text-[10px] ${t.type === 'ckb' ? 'bg-green-500/20 text-green-300 border-green-500/30' : 'bg-gray-700 text-gray-300'}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-amber-400 font-bold text-base hover:underline"
|
||||
onClick={() => {
|
||||
setNewLinkTag({
|
||||
tagId: t.id,
|
||||
label: t.label,
|
||||
url: t.url,
|
||||
type: t.type,
|
||||
appId: t.appId,
|
||||
pagePath: t.pagePath,
|
||||
})
|
||||
setEditingLinkTagId(t.id)
|
||||
}}
|
||||
>
|
||||
#{t.label}
|
||||
</button>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`text-[10px] ${
|
||||
t.type === 'ckb'
|
||||
? 'bg-green-500/20 text-green-300 border-green-500/30'
|
||||
: 'bg-gray-700 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t.type === 'url' ? '网页' : t.type === 'ckb' ? '存客宝' : '小程序'}
|
||||
</Badge>
|
||||
<a href={t.url} target="_blank" rel="noreferrer" className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1">
|
||||
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
{t.url && (
|
||||
<a
|
||||
href={t.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-400 text-xs truncate max-w-[250px] hover:underline flex items-center gap-1"
|
||||
>
|
||||
{t.url} <ExternalLink className="w-3 h-3 shrink-0" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-300 hover:text-white h-6 px-2"
|
||||
onClick={() => {
|
||||
setNewLinkTag({
|
||||
tagId: t.id,
|
||||
label: t.label,
|
||||
url: t.url,
|
||||
type: t.type,
|
||||
appId: t.appId,
|
||||
pagePath: t.pagePath,
|
||||
})
|
||||
setEditingLinkTagId(t.id)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300 h-6 px-2"
|
||||
onClick={async () => {
|
||||
await del(`/api/db/link-tags?tagId=${t.id}`)
|
||||
if (editingLinkTagId === t.id) {
|
||||
setEditingLinkTagId(null)
|
||||
setNewLinkTag({ tagId: '', label: '', url: '', type: 'url', appId: '', pagePath: '' })
|
||||
}
|
||||
loadLinkTags()
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="text-red-400 hover:text-red-300 h-6 px-2" onClick={async () => {
|
||||
await del(`/api/db/link-tags?tagId=${t.id}`)
|
||||
loadLinkTags()
|
||||
}}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{linkTags.length === 0 && <div className="text-gray-500 text-sm py-4 text-center">暂无链接标签,添加后可在编辑器中使用 #标签 跳转</div>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import toast from '@/utils/toast'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Crown,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { UserDetailModal } from '@/components/modules/user/UserDetailModal'
|
||||
import { Pagination } from '@/components/ui/Pagination'
|
||||
@@ -81,11 +82,12 @@ interface UserRule {
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface VipRole {
|
||||
id: number
|
||||
interface VipMember {
|
||||
id: string
|
||||
name: string
|
||||
sort: number
|
||||
createdAt?: string
|
||||
avatar?: string | null
|
||||
vipRole?: string | null
|
||||
vipSort?: number | null
|
||||
}
|
||||
|
||||
// 用户旅程阶段定义
|
||||
@@ -144,12 +146,11 @@ export function UsersPage() {
|
||||
const [editingRule, setEditingRule] = useState<UserRule | null>(null)
|
||||
const [ruleForm, setRuleForm] = useState({ title: '', description: '', trigger: '', sort: 0, enabled: true })
|
||||
|
||||
// ===== VIP 角色 =====
|
||||
const [vipRoles, setVipRoles] = useState<VipRole[]>([])
|
||||
const [vipRolesLoading, setVipRolesLoading] = useState(false)
|
||||
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
|
||||
const [editingVipRole, setEditingVipRole] = useState<VipRole | null>(null)
|
||||
const [vipRoleForm, setVipRoleForm] = useState({ name: '', sort: 0 })
|
||||
// ===== 超级个体(VIP 用户列表) =====
|
||||
const [vipMembers, setVipMembers] = useState<VipMember[]>([])
|
||||
const [vipMembersLoading, setVipMembersLoading] = useState(false)
|
||||
const [draggingVipId, setDraggingVipId] = useState<string | null>(null)
|
||||
const [dragOverVipId, setDragOverVipId] = useState<string | null>(null)
|
||||
|
||||
// ===== 用户旅程总览 =====
|
||||
const [journeyStats, setJourneyStats] = useState<Record<string, number>>({})
|
||||
@@ -313,36 +314,169 @@ export function UsersPage() {
|
||||
try { await put('/api/db/user-rules', { id: rule.id, enabled: !rule.enabled }); loadRules() } catch { }
|
||||
}
|
||||
|
||||
// ===== VIP 角色 =====
|
||||
const loadVipRoles = useCallback(async () => {
|
||||
setVipRolesLoading(true)
|
||||
// ===== 超级个体(VIP 用户列表) =====
|
||||
const loadVipMembers = useCallback(async () => {
|
||||
setVipMembersLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; roles?: VipRole[] }>('/api/db/vip-roles')
|
||||
if (data?.success) setVipRoles(data.roles || [])
|
||||
} catch { } finally { setVipRolesLoading(false) }
|
||||
const data = await get<{ success?: boolean; data?: VipMember[]; error?: string }>(
|
||||
'/api/db/vip-members?limit=500',
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
const list = [...data.data].map((m, idx) => ({
|
||||
...m,
|
||||
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
|
||||
}))
|
||||
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
|
||||
setVipMembers(list)
|
||||
} else if (data && data.error) {
|
||||
toast.error(data.error)
|
||||
}
|
||||
} catch {
|
||||
toast.error('加载超级个体列表失败')
|
||||
} finally {
|
||||
setVipMembersLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSaveVipRole() {
|
||||
if (!vipRoleForm.name) { toast.error('请填写角色名称'); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingVipRole) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', { id: editingVipRole.id, ...vipRoleForm })
|
||||
if (!data?.success) { toast.error('更新失败'); return }
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', vipRoleForm)
|
||||
if (!data?.success) { toast.error('创建失败'); return }
|
||||
}
|
||||
setShowVipRoleModal(false); loadVipRoles()
|
||||
} catch { toast.error('保存失败') } finally { setIsSaving(false) }
|
||||
const [showVipRoleModal, setShowVipRoleModal] = useState(false)
|
||||
const [vipRoleModalMember, setVipRoleModalMember] = useState<VipMember | null>(null)
|
||||
const [vipRoleInput, setVipRoleInput] = useState('')
|
||||
const [vipRoleSaving, setVipRoleSaving] = useState(false)
|
||||
|
||||
const VIP_ROLE_PRESETS = ['创业者', '资源整合者', '技术达人', '投资人', '产品经理', '流量操盘手']
|
||||
|
||||
const openVipRoleModal = (member: VipMember) => {
|
||||
setVipRoleModalMember(member)
|
||||
setVipRoleInput(member.vipRole || '')
|
||||
setShowVipRoleModal(true)
|
||||
}
|
||||
|
||||
async function handleDeleteVipRole(id: number) {
|
||||
if (!confirm('确定删除?')) return
|
||||
const handleSetVipRole = async (value: string) => {
|
||||
const trimmed = value.trim()
|
||||
if (!vipRoleModalMember) return
|
||||
if (!trimmed) {
|
||||
toast.error('请选择或输入标签')
|
||||
return
|
||||
}
|
||||
setVipRoleSaving(true)
|
||||
try {
|
||||
const data = await del<{ success?: boolean }>(`/api/db/vip-roles?id=${id}`)
|
||||
if (data?.success) loadVipRoles()
|
||||
} catch { }
|
||||
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: vipRoleModalMember.id,
|
||||
vipRole: trimmed,
|
||||
})
|
||||
if (!res?.success) {
|
||||
toast.error(res?.error || '更新超级个体标签失败')
|
||||
return
|
||||
}
|
||||
toast.success('已更新超级个体标签')
|
||||
setShowVipRoleModal(false)
|
||||
setVipRoleModalMember(null)
|
||||
await loadVipMembers()
|
||||
} catch {
|
||||
toast.error('更新超级个体标签失败')
|
||||
} finally {
|
||||
setVipRoleSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [showVipSortModal, setShowVipSortModal] = useState(false)
|
||||
const [vipSortModalMember, setVipSortModalMember] = useState<VipMember | null>(null)
|
||||
const [vipSortInput, setVipSortInput] = useState('')
|
||||
const [vipSortSaving, setVipSortSaving] = useState(false)
|
||||
|
||||
const openVipSortModal = (member: VipMember) => {
|
||||
setVipSortModalMember(member)
|
||||
setVipSortInput(member.vipSort != null ? String(member.vipSort) : '')
|
||||
setShowVipSortModal(true)
|
||||
}
|
||||
|
||||
const handleSetVipSort = async () => {
|
||||
if (!vipSortModalMember) return
|
||||
const num = Number(vipSortInput)
|
||||
if (!Number.isFinite(num)) {
|
||||
toast.error('请输入有效的数字序号')
|
||||
return
|
||||
}
|
||||
setVipSortSaving(true)
|
||||
try {
|
||||
const res = await put<{ success?: boolean; error?: string }>('/api/db/users', {
|
||||
id: vipSortModalMember.id,
|
||||
vipSort: num,
|
||||
})
|
||||
if (!res?.success) {
|
||||
toast.error(res?.error || '更新排序序号失败')
|
||||
return
|
||||
}
|
||||
toast.success('已更新排序序号')
|
||||
setShowVipSortModal(false)
|
||||
setVipSortModalMember(null)
|
||||
await loadVipMembers()
|
||||
} catch {
|
||||
toast.error('更新排序序号失败')
|
||||
} finally {
|
||||
setVipSortSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVipRowDragStart = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', id)
|
||||
setDraggingVipId(id)
|
||||
}
|
||||
|
||||
const handleVipRowDragOver = (e: React.DragEvent<HTMLTableRowElement>, id: string) => {
|
||||
e.preventDefault()
|
||||
if (dragOverVipId !== id) setDragOverVipId(id)
|
||||
}
|
||||
|
||||
const handleVipRowDragEnd = () => {
|
||||
setDraggingVipId(null)
|
||||
setDragOverVipId(null)
|
||||
}
|
||||
|
||||
const handleVipRowDrop = async (e: React.DragEvent<HTMLTableRowElement>, targetId: string) => {
|
||||
e.preventDefault()
|
||||
const fromId = e.dataTransfer.getData('text/plain') || draggingVipId
|
||||
setDraggingVipId(null)
|
||||
setDragOverVipId(null)
|
||||
if (!fromId || fromId === targetId) return
|
||||
|
||||
const fromMember = vipMembers.find((m) => m.id === fromId)
|
||||
const targetMember = vipMembers.find((m) => m.id === targetId)
|
||||
if (!fromMember || !targetMember) return
|
||||
|
||||
const fromSort = fromMember.vipSort ?? vipMembers.findIndex((m) => m.id === fromId) + 1
|
||||
const targetSort = targetMember.vipSort ?? vipMembers.findIndex((m) => m.id === targetId) + 1
|
||||
|
||||
// 本地先交换顺序,提升交互流畅度
|
||||
setVipMembers((prev) => {
|
||||
const list = [...prev]
|
||||
const fromIdx = list.findIndex((m) => m.id === fromId)
|
||||
const toIdx = list.findIndex((m) => m.id === targetId)
|
||||
if (fromIdx === -1 || toIdx === -1) return prev
|
||||
const next = [...list]
|
||||
const [m1, m2] = [next[fromIdx], next[toIdx]]
|
||||
next[fromIdx] = { ...m2, vipSort: fromSort }
|
||||
next[toIdx] = { ...m1, vipSort: targetSort }
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
const [res1, res2] = await Promise.all([
|
||||
put<{ success?: boolean; error?: string }>('/api/db/users', { id: fromId, vipSort: targetSort }),
|
||||
put<{ success?: boolean; error?: string }>('/api/db/users', { id: targetId, vipSort: fromSort }),
|
||||
])
|
||||
if (!res1?.success || !res2?.success) {
|
||||
toast.error(res1?.error || res2?.error || '更新排序失败')
|
||||
await loadVipMembers()
|
||||
return
|
||||
}
|
||||
toast.success('已更新排序')
|
||||
await loadVipMembers()
|
||||
} catch {
|
||||
toast.error('更新排序失败')
|
||||
await loadVipMembers()
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 用户旅程总览 =====
|
||||
@@ -381,8 +515,8 @@ export function UsersPage() {
|
||||
<TabsTrigger value="rules" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadRules}>
|
||||
<Settings className="w-4 h-4" /> 规则配置
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vip-roles" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadVipRoles}>
|
||||
<Crown className="w-4 h-4" /> VIP 角色
|
||||
<TabsTrigger value="vip-roles" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac] flex items-center gap-1.5" onClick={loadVipMembers}>
|
||||
<Crown className="w-4 h-4" /> 超级个体列表
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -707,46 +841,136 @@ export function UsersPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== VIP 角色 ===== */}
|
||||
{/* ===== 超级个体列表(VIP 用户) ===== */}
|
||||
<TabsContent value="vip-roles">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-gray-400 text-sm">管理用户 VIP 角色分类,这些角色将在用户详情和会员展示中使用</p>
|
||||
<div className="space-y-1">
|
||||
<p className="text-gray-400 text-sm">
|
||||
展示当前所有有效的超级个体(VIP 用户),用于检查会员信息与排序值。
|
||||
</p>
|
||||
<p className="text-xs text-[#38bdac]">
|
||||
提示:按住任意一行即可拖拽排序,释放后将同步更新小程序展示顺序。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={loadVipRoles} disabled={vipRolesLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${vipRolesLoading ? 'animate-spin' : ''}`} /> 刷新
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Plus className="w-4 h-4 mr-2" /> 添加角色
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadVipMembers}
|
||||
disabled={vipMembersLoading}
|
||||
className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${vipMembersLoading ? 'animate-spin' : ''}`}
|
||||
/>{' '}
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{vipRolesLoading ? (
|
||||
<div className="flex items-center justify-center py-12"><RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" /></div>
|
||||
) : vipRoles.length === 0 ? (
|
||||
{vipMembersLoading ? (
|
||||
<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>
|
||||
) : vipMembers.length === 0 ? (
|
||||
<div className="text-center py-16 bg-[#0f2137] rounded-lg border border-gray-700/50">
|
||||
<Crown className="w-12 h-12 text-amber-400/30 mx-auto mb-4" />
|
||||
<p className="text-gray-400 mb-4">暂无 VIP 角色</p>
|
||||
<Button onClick={() => { setEditingVipRole(null); setVipRoleForm({ name: '', sort: 0 }); setShowVipRoleModal(true) }} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Plus className="w-4 h-4 mr-2" /> 添加第一个角色</Button>
|
||||
<p className="text-gray-400 mb-4">当前没有有效的超级个体用户。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{vipRoles.map((role) => (
|
||||
<div key={role.id} className="p-4 bg-[#0f2137] border border-amber-500/20 rounded-xl hover:border-amber-500/40 transition-all group">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-white font-medium">{role.name}</span>
|
||||
</div>
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" onClick={() => { setEditingVipRole(role); setVipRoleForm({ name: role.name, sort: role.sort }); setShowVipRoleModal(true) }} className="text-gray-500 hover:text-[#38bdac]"><Edit3 className="w-3.5 h-3.5" /></button>
|
||||
<button type="button" onClick={() => handleDeleteVipRole(role.id)} className="text-gray-500 hover:text-red-400"><Trash2 className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-xs">排序: {role.sort}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] hover:bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400 w-16">序号</TableHead>
|
||||
<TableHead className="text-gray-400">成员</TableHead>
|
||||
<TableHead className="text-gray-400 w-40">超级个体标签</TableHead>
|
||||
<TableHead className="text-gray-400 w-24">排序值</TableHead>
|
||||
<TableHead className="text-gray-400 w-40 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vipMembers.map((m, index) => {
|
||||
const isDragging = draggingVipId === m.id
|
||||
const isOver = dragOverVipId === m.id
|
||||
return (
|
||||
<TableRow
|
||||
key={m.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleVipRowDragStart(e, m.id)}
|
||||
onDragOver={(e) => handleVipRowDragOver(e, m.id)}
|
||||
onDrop={(e) => handleVipRowDrop(e, m.id)}
|
||||
onDragEnd={handleVipRowDragEnd}
|
||||
className={`border-gray-700/50 cursor-grab active:cursor-grabbing select-none ${
|
||||
isDragging ? 'opacity-60' : ''
|
||||
} ${isOver ? 'bg-[#38bdac]/10' : ''}`}
|
||||
>
|
||||
<TableCell className="text-gray-300">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{m.avatar ? (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<img
|
||||
src={m.avatar}
|
||||
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
|
||||
{m.name?.[0] || '创'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm truncate">{m.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{m.vipRole || <span className="text-gray-500">(未设置超级个体标签)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{m.vipSort ?? index + 1}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs text-gray-300">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 px-0 text-amber-300 hover:text-amber-200"
|
||||
onClick={() => openVipRoleModal(m)}
|
||||
title="设置超级个体标签"
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 px-0 text-[#38bdac] hover:text-[#5fe0cd]"
|
||||
onClick={() => {
|
||||
setSelectedUserIdForDetail(m.id)
|
||||
setShowDetailModal(true)
|
||||
}}
|
||||
title="编辑资料"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 px-0 text-sky-300 hover:text-sky-200"
|
||||
onClick={() => openVipSortModal(m)}
|
||||
title="设置排序序号"
|
||||
>
|
||||
<ArrowUpDown className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -754,6 +978,81 @@ export function UsersPage() {
|
||||
{/* ===== 弹框组件 ===== */}
|
||||
|
||||
{/* 添加/编辑用户 */}
|
||||
{/* 设置排序 */}
|
||||
<Dialog open={showVipSortModal} onOpenChange={(open) => { setShowVipSortModal(open); if (!open) setVipSortModalMember(null) }}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<ArrowUpDown className="w-5 h-5 text-[#38bdac]" />
|
||||
设置排序 — {vipSortModalMember?.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Label className="text-gray-300 text-sm">排序序号(数字越小越靠前)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如:1"
|
||||
value={vipSortInput}
|
||||
onChange={(e) => setVipSortInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowVipSortModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<X className="w-4 h-4 mr-2" />取消
|
||||
</Button>
|
||||
<Button onClick={handleSetVipSort} disabled={vipSortSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />{vipSortSaving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 设置超级个体标签 */}
|
||||
<Dialog open={showVipRoleModal} onOpenChange={(open) => { setShowVipRoleModal(open); if (!open) setVipRoleModalMember(null) }}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<Crown className="w-5 h-5 text-amber-400" />
|
||||
设置超级个体标签 — {vipRoleModalMember?.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Label className="text-gray-300 text-sm">选择或输入标签</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{VIP_ROLE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant={vipRoleInput === preset ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className={vipRoleInput === preset ? 'bg-[#38bdac] hover:bg-[#2da396] text-white' : 'border-gray-600 text-gray-300 hover:bg-gray-700/50'}
|
||||
onClick={() => setVipRoleInput(preset)}
|
||||
>
|
||||
{preset}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-400 text-xs">或手动输入</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如:创业者、资源整合者等"
|
||||
value={vipRoleInput}
|
||||
onChange={(e) => setVipRoleInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowVipRoleModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
|
||||
<X className="w-4 h-4 mr-2" />取消
|
||||
</Button>
|
||||
<Button onClick={() => handleSetVipRole(vipRoleInput)} disabled={vipRoleSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />{vipRoleSaving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showUserModal} onOpenChange={setShowUserModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-lg">
|
||||
<DialogHeader><DialogTitle className="text-white flex items-center gap-2">{editingUser ? <Edit3 className="w-5 h-5 text-[#38bdac]" /> : <UserPlus className="w-5 h-5 text-[#38bdac]" />}{editingUser ? '编辑用户' : '添加用户'}</DialogTitle></DialogHeader>
|
||||
@@ -788,21 +1087,6 @@ export function UsersPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 添加/编辑 VIP 角色 */}
|
||||
<Dialog open={showVipRoleModal} onOpenChange={setShowVipRoleModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-md">
|
||||
<DialogHeader><DialogTitle className="text-white flex items-center gap-2"><Crown className="w-5 h-5 text-amber-400" />{editingVipRole ? '编辑 VIP 角色' : '添加 VIP 角色'}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2"><Label className="text-gray-300">角色名称 *</Label><Input className="bg-[#0a1628] border-gray-700 text-white" placeholder="例:创业者、资源整合者、技术咖" value={vipRoleForm.name} onChange={(e) => setVipRoleForm({ ...vipRoleForm, name: e.target.value })} /></div>
|
||||
<div className="space-y-2"><Label className="text-gray-300">排序(越小越前)</Label><Input type="number" className="bg-[#0a1628] border-gray-700 text-white" value={vipRoleForm.sort} onChange={(e) => setVipRoleForm({ ...vipRoleForm, sort: parseInt(e.target.value) || 0 })} /></div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowVipRoleModal(false)} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent"><X className="w-4 h-4 mr-2" />取消</Button>
|
||||
<Button onClick={handleSaveVipRole} disabled={isSaving} className="bg-[#38bdac] hover:bg-[#2da396] text-white"><Save className="w-4 h-4 mr-2" />{isSaving ? '保存中...' : '保存'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 绑定关系 */}
|
||||
<Dialog open={showReferralsModal} onOpenChange={setShowReferralsModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-2xl max-h-[80vh] overflow-auto">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import toast from '@/utils/toast'
|
||||
import toast from '@/utils/toast'
|
||||
import { useState, useEffect } 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 {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -12,130 +9,59 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Crown, Plus, Edit3, Trash2, X, Save } from 'lucide-react'
|
||||
import { get, post, put, del } from '@/api/client'
|
||||
import { Crown } from 'lucide-react'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
interface VipRole {
|
||||
id: number
|
||||
interface VipMember {
|
||||
id: string
|
||||
name: string
|
||||
sort: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
avatar?: string
|
||||
vipRole?: string
|
||||
vipSort?: number
|
||||
}
|
||||
|
||||
export function VipRolesPage() {
|
||||
const [roles, setRoles] = useState<VipRole[]>([])
|
||||
const [members, setMembers] = useState<VipMember[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingRole, setEditingRole] = useState<VipRole | null>(null)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formSort, setFormSort] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function loadRoles() {
|
||||
async function loadMembers() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await get<{ success?: boolean; data?: VipRole[] }>('/api/db/vip-roles')
|
||||
if (data?.success && data.data) setRoles(data.data)
|
||||
const data = await get<{ success?: boolean; data?: VipMember[] }>(
|
||||
'/api/db/vip-members?limit=100',
|
||||
)
|
||||
if (data?.success && data.data) {
|
||||
const list = [...data.data].map((m, idx) => ({
|
||||
...m,
|
||||
vipSort: typeof (m as any).vipSort === 'number' ? (m as any).vipSort : idx + 1,
|
||||
}))
|
||||
list.sort((a, b) => (a.vipSort ?? 999999) - (b.vipSort ?? 999999))
|
||||
setMembers(list)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load roles error:', e)
|
||||
console.error('Load VIP members error:', e)
|
||||
toast.error('加载 VIP 成员失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles()
|
||||
loadMembers()
|
||||
}, [])
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingRole(null)
|
||||
setFormName('')
|
||||
setFormSort(roles.length > 0 ? Math.max(...roles.map((r) => r.sort)) + 1 : 0)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleEdit = (role: VipRole) => {
|
||||
setEditingRole(role)
|
||||
setFormName(role.name)
|
||||
setFormSort(role.sort)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) {
|
||||
toast.error('角色名称不能为空')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingRole) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
|
||||
id: editingRole.id,
|
||||
name: formName.trim(),
|
||||
sort: formSort,
|
||||
})
|
||||
if (data?.success) {
|
||||
setShowModal(false)
|
||||
loadRoles()
|
||||
} else {
|
||||
toast.error('更新失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/db/vip-roles', {
|
||||
name: formName.trim(),
|
||||
sort: formSort,
|
||||
})
|
||||
if (data?.success) {
|
||||
setShowModal(false)
|
||||
loadRoles()
|
||||
} else {
|
||||
toast.error('新增失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save error:', e)
|
||||
toast.error('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该角色?已设置该角色的 VIP 用户将保留角色名称。')) return
|
||||
try {
|
||||
const data = await del<{ success?: boolean; error?: string }>(`/api/db/vip-roles?id=${id}`)
|
||||
if (data?.success) loadRoles()
|
||||
else toast.error('删除失败: ' + (data as { error?: string })?.error)
|
||||
} catch (e) {
|
||||
console.error('Delete error:', e)
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||
<Crown className="w-5 h-5 text-amber-400" />
|
||||
VIP 角色管理
|
||||
用户管理 / 超级个体列表
|
||||
</h2>
|
||||
<p className="text-gray-400 mt-1">
|
||||
超级个体固定角色,在「设置 VIP」时可选择或手动填写
|
||||
这里展示所有有效超级个体用户,仅用于查看其基本信息与排序值。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAdd} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
新增角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="bg-[#0f2137] border-gray-700/50">
|
||||
@@ -146,42 +72,44 @@ export function VipRolesPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#0a1628] border-gray-700">
|
||||
<TableHead className="text-gray-400">ID</TableHead>
|
||||
<TableHead className="text-gray-400">角色名称</TableHead>
|
||||
<TableHead className="text-gray-400">排序</TableHead>
|
||||
<TableHead className="text-right text-gray-400">操作</TableHead>
|
||||
<TableHead className="text-gray-400 w-20">序号</TableHead>
|
||||
<TableHead className="text-gray-400">成员</TableHead>
|
||||
<TableHead className="text-gray-400 w-40">超级个体</TableHead>
|
||||
<TableHead className="text-gray-400 w-28">排序值</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((r) => (
|
||||
<TableRow key={r.id} className="border-gray-700/50">
|
||||
<TableCell className="text-gray-300">{r.id}</TableCell>
|
||||
<TableCell className="text-white">{r.name}</TableCell>
|
||||
<TableCell className="text-gray-400">{r.sort}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(r)}
|
||||
className="text-gray-400 hover:text-[#38bdac]"
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="text-gray-400 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
{members.map((m, index) => (
|
||||
<TableRow key={m.id} className="border-gray-700/50">
|
||||
<TableCell className="text-gray-300">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{m.avatar ? (
|
||||
// eslint-disable-next-line jsx-a11y/alt-text
|
||||
<img
|
||||
src={m.avatar}
|
||||
className="w-8 h-8 rounded-full object-cover border border-amber-400/60"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-amber-500/20 border border-amber-400/60 flex items-center justify-center text-amber-300 text-sm">
|
||||
{m.name?.[0] || '创'}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm truncate">{m.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">
|
||||
{m.vipRole || <span className="text-gray-500">(未设置超级个体)</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-300">{m.vipSort ?? index + 1}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{roles.length === 0 && (
|
||||
{members.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-12 text-gray-500">
|
||||
暂无角色,点击「新增角色」添加
|
||||
<TableCell colSpan={5} className="text-center py-12 text-gray-500">
|
||||
当前没有有效的超级个体用户。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -190,54 +118,7 @@ export function VipRolesPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{editingRole ? '编辑角色' : '新增角色'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">角色名称</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="如:创始人、投资人"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">排序(下拉展示顺序,越小越前)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={formSort}
|
||||
onChange={(e) => setFormSort(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="border-gray-600 text-gray-300"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user