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:
Alex-larget
2026-03-12 11:36:50 +08:00
parent da6d2c0852
commit d3b67681d7
27 changed files with 1464 additions and 393 deletions

View File

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

View File

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

View File

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