初始提交:一场soul的创业实验-永平 网站与小程序
Made-with: Cursor
This commit is contained in:
280
soul-admin/src/components/modules/user/SetVipModal.tsx
Normal file
280
soul-admin/src/components/modules/user/SetVipModal.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Crown, Save, X } from 'lucide-react'
|
||||
import { get, put } from '@/api/client'
|
||||
|
||||
interface SetVipModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
userId: string | null
|
||||
userNickname?: string
|
||||
onSaved?: () => void
|
||||
}
|
||||
|
||||
interface VipRole {
|
||||
id: number
|
||||
name: string
|
||||
sort: number
|
||||
}
|
||||
|
||||
interface VipForm {
|
||||
isVip: boolean
|
||||
vipExpireDate: string
|
||||
vipSort: number | ''
|
||||
vipRole: string
|
||||
vipRoleCustom: string
|
||||
vipName: string
|
||||
vipProject: string
|
||||
vipContact: string
|
||||
vipBio: string
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: VipForm = {
|
||||
isVip: false,
|
||||
vipExpireDate: '',
|
||||
vipSort: '',
|
||||
vipRole: '',
|
||||
vipRoleCustom: '',
|
||||
vipName: '',
|
||||
vipProject: '',
|
||||
vipContact: '',
|
||||
vipBio: '',
|
||||
}
|
||||
|
||||
export function SetVipModal({
|
||||
open,
|
||||
onClose,
|
||||
userId,
|
||||
userNickname = '',
|
||||
onSaved,
|
||||
}: SetVipModalProps) {
|
||||
const [form, setForm] = useState<VipForm>(DEFAULT_FORM)
|
||||
const [roles, setRoles] = useState<VipRole[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setForm(DEFAULT_FORM)
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
Promise.all([
|
||||
get<{ success?: boolean; data?: VipRole[] }>('/api/db/vip-roles'),
|
||||
userId ? get<{ success?: boolean; user?: Record<string, unknown> }>(`/api/db/users?id=${encodeURIComponent(userId)}`) : Promise.resolve(null),
|
||||
]).then(([rolesRes, userRes]) => {
|
||||
if (cancelled) return
|
||||
const rolesList = (rolesRes as { success?: boolean; data?: VipRole[] })?.success && (rolesRes as { data?: VipRole[] }).data ? (rolesRes as { data?: VipRole[] }).data! : []
|
||||
setRoles(rolesList)
|
||||
const u = userRes && (userRes as { user?: Record<string, unknown> }).user ? (userRes as { user?: Record<string, unknown> }).user! : null
|
||||
if (u) {
|
||||
const vipRole = String(u.vipRole ?? '')
|
||||
const inRoles = rolesList.some((r: VipRole) => r.name === vipRole)
|
||||
setForm({
|
||||
isVip: !!(u.isVip ?? false),
|
||||
vipExpireDate: u.vipExpireDate ? String(u.vipExpireDate).slice(0, 10) : '',
|
||||
vipSort: typeof u.vipSort === 'number' ? u.vipSort : '',
|
||||
vipRole: inRoles ? vipRole : (vipRole ? '__custom__' : ''),
|
||||
vipRoleCustom: inRoles ? '' : vipRole,
|
||||
vipName: String(u.vipName ?? ''),
|
||||
vipProject: String(u.vipProject ?? ''),
|
||||
vipContact: String(u.vipContact ?? ''),
|
||||
vipBio: String(u.vipBio ?? ''),
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (!cancelled) console.error('Load error:', e)
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open, userId])
|
||||
|
||||
async function handleSave() {
|
||||
if (!userId) return
|
||||
if (form.isVip && !form.vipExpireDate.trim()) {
|
||||
alert('开启 VIP 时请填写有效到期日')
|
||||
return
|
||||
}
|
||||
if (form.isVip && form.vipExpireDate.trim()) {
|
||||
const d = new Date(form.vipExpireDate)
|
||||
if (isNaN(d.getTime())) {
|
||||
alert('到期日格式无效,请使用 YYYY-MM-DD')
|
||||
return
|
||||
}
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const roleValue = form.vipRole === '__custom__' ? form.vipRoleCustom.trim() : form.vipRole
|
||||
const payload: Record<string, unknown> = {
|
||||
id: userId,
|
||||
isVip: form.isVip,
|
||||
vipExpireDate: form.isVip ? form.vipExpireDate : undefined,
|
||||
vipSort: form.vipSort === '' ? undefined : form.vipSort,
|
||||
vipRole: roleValue || undefined,
|
||||
vipName: form.vipName || undefined,
|
||||
vipProject: form.vipProject || undefined,
|
||||
vipContact: form.vipContact || undefined,
|
||||
vipBio: form.vipBio || undefined,
|
||||
}
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
|
||||
if (data?.success) {
|
||||
alert('VIP 设置已保存')
|
||||
onSaved?.()
|
||||
onClose()
|
||||
} else {
|
||||
alert('保存失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save VIP error:', e)
|
||||
alert('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => onClose()}>
|
||||
<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" />
|
||||
设置 VIP - {userNickname || userId}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div className="py-8 text-center text-gray-400">加载中...</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-gray-300">VIP 会员</Label>
|
||||
<Switch
|
||||
checked={form.isVip}
|
||||
onCheckedChange={(checked) => setForm((f) => ({ ...f, isVip: checked }))}
|
||||
/>
|
||||
</div>
|
||||
{form.isVip && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">
|
||||
到期日 (YYYY-MM-DD) <span className="text-amber-400">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
value={form.vipExpireDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vipExpireDate: 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"
|
||||
placeholder="数字越小越靠前,留空按时间"
|
||||
value={form.vipSort === '' ? '' : form.vipSort}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setForm((f) => ({ ...f, vipSort: v === '' ? '' : parseInt(v, 10) || 0 }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">角色</Label>
|
||||
<select
|
||||
className="w-full bg-[#0a1628] border border-gray-700 text-white rounded-md px-3 py-2"
|
||||
value={form.vipRole}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vipRole: e.target.value }))}
|
||||
>
|
||||
<option value="">请选择或下方手动填写</option>
|
||||
{roles.map((r) => (
|
||||
<option key={r.id} value={r.name}>{r.name}</option>
|
||||
))}
|
||||
<option value="__custom__">其他(手动填写)</option>
|
||||
</select>
|
||||
{form.vipRole === '__custom__' && (
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white mt-1"
|
||||
placeholder="输入自定义角色"
|
||||
value={form.vipRoleCustom}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vipRoleCustom: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">VIP 展示名</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="创业老板排行展示名"
|
||||
value={form.vipName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vipName: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">项目/公司</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="项目名称"
|
||||
value={form.vipProject}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vipProject: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">联系方式</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="微信号或手机"
|
||||
value={form.vipContact}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vipContact: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">一句话简介</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="简要描述业务"
|
||||
value={form.vipBio}
|
||||
onChange={(e) => setForm((f) => ({ ...f, vipBio: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
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={handleSave}
|
||||
disabled={saving || loading}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
487
soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
487
soul-admin/src/components/modules/user/UserDetailModal.tsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
User,
|
||||
Phone,
|
||||
History,
|
||||
RefreshCw,
|
||||
Link2,
|
||||
BookOpen,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
MessageCircle,
|
||||
Clock,
|
||||
Save,
|
||||
X,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import { get, put, post } from '@/api/client'
|
||||
|
||||
interface UserDetailModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
userId: string | null
|
||||
onUserUpdated?: () => void
|
||||
}
|
||||
|
||||
interface UserDetail {
|
||||
id: string
|
||||
phone?: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
wechatId?: string
|
||||
openId?: string
|
||||
referralCode?: string
|
||||
referredBy?: string
|
||||
hasFullBook?: boolean
|
||||
isAdmin?: boolean
|
||||
earnings?: number
|
||||
pendingEarnings?: number
|
||||
referralCount?: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
tags?: string
|
||||
ckbTags?: string
|
||||
ckbSyncedAt?: string
|
||||
isVip?: boolean
|
||||
vipExpireDate?: string | null
|
||||
vipName?: string | null
|
||||
vipAvatar?: string | null
|
||||
vipProject?: string | null
|
||||
vipContact?: string | null
|
||||
vipBio?: string | null
|
||||
}
|
||||
|
||||
interface UserTrack {
|
||||
id: string
|
||||
action: string
|
||||
actionLabel: string
|
||||
target?: string
|
||||
chapterTitle?: string
|
||||
createdAt: string
|
||||
timeAgo: string
|
||||
}
|
||||
|
||||
export function UserDetailModal({
|
||||
open,
|
||||
onClose,
|
||||
userId,
|
||||
onUserUpdated,
|
||||
}: UserDetailModalProps) {
|
||||
const [user, setUser] = useState<UserDetail | null>(null)
|
||||
const [tracks, setTracks] = useState<UserTrack[]>([])
|
||||
const [referrals, setReferrals] = useState<unknown[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('info')
|
||||
const [editPhone, setEditPhone] = useState('')
|
||||
const [editNickname, setEditNickname] = useState('')
|
||||
const [editTags, setEditTags] = useState<string[]>([])
|
||||
const [newTag, setNewTag] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (open && userId) loadUserDetail()
|
||||
}, [open, userId])
|
||||
|
||||
async function loadUserDetail() {
|
||||
if (!userId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const userData = await get<{ success?: boolean; user?: UserDetail }>(
|
||||
`/api/db/users?id=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (userData?.success && userData.user) {
|
||||
const u = userData.user
|
||||
setUser(u)
|
||||
setEditPhone(u.phone || '')
|
||||
setEditNickname(u.nickname || '')
|
||||
setEditTags(typeof u.tags === 'string' ? (JSON.parse(u.tags || '[]') as string[]) : [])
|
||||
}
|
||||
try {
|
||||
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
|
||||
`/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
|
||||
)
|
||||
if (trackData?.success && trackData.tracks) setTracks(trackData.tracks)
|
||||
} catch {
|
||||
setTracks([])
|
||||
}
|
||||
try {
|
||||
const refData = await get<{ success?: boolean; referrals?: unknown[] }>(
|
||||
`/api/db/users/referrals?userId=${encodeURIComponent(userId)}`,
|
||||
)
|
||||
if (refData?.success && refData.referrals) setReferrals(refData.referrals)
|
||||
} catch {
|
||||
setReferrals([])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Load user detail error:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSyncCKB() {
|
||||
if (!user?.phone) {
|
||||
alert('用户未绑定手机号,无法同步')
|
||||
return
|
||||
}
|
||||
setSyncing(true)
|
||||
try {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/ckb/sync', {
|
||||
action: 'full_sync',
|
||||
phone: user.phone,
|
||||
userId: user.id,
|
||||
})
|
||||
if (data?.success) {
|
||||
alert('同步成功')
|
||||
loadUserDetail()
|
||||
} else {
|
||||
alert('同步失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Sync CKB error:', e)
|
||||
alert('同步失败')
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!user) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: user.id,
|
||||
phone: editPhone || undefined,
|
||||
nickname: editNickname || undefined,
|
||||
tags: JSON.stringify(editTags),
|
||||
}
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/db/users', payload)
|
||||
if (data?.success) {
|
||||
alert('保存成功')
|
||||
loadUserDetail()
|
||||
onUserUpdated?.()
|
||||
} else {
|
||||
alert('保存失败: ' + (data as { error?: string })?.error)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save user error:', e)
|
||||
alert('保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
if (newTag && !editTags.includes(newTag)) {
|
||||
setEditTags([...editTags, newTag])
|
||||
setNewTag('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setEditTags(editTags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
const getActionIcon = (action: string) => {
|
||||
const icons: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
view_chapter: BookOpen,
|
||||
purchase: ShoppingBag,
|
||||
match: Users,
|
||||
login: User,
|
||||
register: User,
|
||||
share: Link2,
|
||||
bind_phone: Phone,
|
||||
bind_wechat: MessageCircle,
|
||||
}
|
||||
const Icon = icons[action] || History
|
||||
return <Icon className="w-4 h-4" />
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={() => onClose()}>
|
||||
<DialogContent className="bg-[#0f2137] border-gray-700 text-white max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-[#38bdac]" />
|
||||
用户详情
|
||||
{user?.phone && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-2">已绑定手机</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<RefreshCw className="w-6 h-6 text-[#38bdac] animate-spin" />
|
||||
<span className="ml-2 text-gray-400">加载中...</span>
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="flex flex-col h-[70vh]">
|
||||
<div className="flex items-center gap-4 p-4 bg-[#0a1628] rounded-lg mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-2xl text-[#38bdac]">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} className="w-full h-full rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
user.nickname?.charAt(0) || '?'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-bold text-white">{user.nickname}</h3>
|
||||
{user.isAdmin && (
|
||||
<Badge className="bg-purple-500/20 text-purple-400 border-0">管理员</Badge>
|
||||
)}
|
||||
{user.hasFullBook && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0">全书已购</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
{user.phone ? `📱 ${user.phone}` : '未绑定手机'}
|
||||
{user.wechatId && ` · 💬 ${user.wechatId}`}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
ID: {user.id} · 推广码: {user.referralCode ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[#38bdac] font-bold">¥{(user.earnings || 0).toFixed(2)}</p>
|
||||
<p className="text-gray-500 text-xs">累计收益</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden">
|
||||
<TabsList className="bg-[#0a1628] border border-gray-700/50 p-1 mb-4">
|
||||
<TabsTrigger value="info" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
基础信息
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
标签体系
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tracks" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
行为轨迹
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="relations" className="data-[state=active]:bg-[#38bdac]/20 data-[state=active]:text-[#38bdac]">
|
||||
关系链路
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="info" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">手机号</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="输入手机号"
|
||||
value={editPhone}
|
||||
onChange={(e) => setEditPhone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-gray-300">昵称</Label>
|
||||
<Input
|
||||
className="bg-[#0a1628] border-gray-700 text-white"
|
||||
placeholder="输入昵称"
|
||||
value={editNickname}
|
||||
onChange={(e) => setEditNickname(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-400 text-sm">推荐人数</p>
|
||||
<p className="text-2xl font-bold text-white">{user.referralCount ?? 0}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-400 text-sm">待提现</p>
|
||||
<p className="text-2xl font-bold text-yellow-400">
|
||||
¥{(user.pendingEarnings ?? 0).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<p className="text-gray-400 text-sm">创建时间</p>
|
||||
<p className="text-sm text-white">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">存客宝同步</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSyncCKB}
|
||||
disabled={syncing || !user.phone}
|
||||
className="bg-[#38bdac] hover:bg-[#2da396] text-white"
|
||||
>
|
||||
{syncing ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1 animate-spin" /> 同步中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" /> 同步数据
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">同步状态:</span>
|
||||
{user.ckbSyncedAt ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 ml-1">已同步</Badge>
|
||||
) : (
|
||||
<Badge className="bg-gray-500/20 text-gray-400 border-0 ml-1">未同步</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">最后同步:</span>
|
||||
<span className="text-gray-300 ml-1">
|
||||
{user.ckbSyncedAt ? new Date(user.ckbSyncedAt).toLocaleString() : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Tag className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">系统标签</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{editTags.map((tag, i) => (
|
||||
<Badge key={i} className="bg-[#38bdac]/20 text-[#38bdac] border-0 pr-1">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="ml-1 hover:text-red-400">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
{editTags.length === 0 && <span className="text-gray-500 text-sm">暂无标签</span>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="bg-[#162840] border-gray-700 text-white flex-1"
|
||||
placeholder="添加新标签"
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addTag()}
|
||||
/>
|
||||
<Button onClick={addTag} className="bg-[#38bdac] hover:bg-[#2da396]">
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tracks" className="flex-1 overflow-auto">
|
||||
<div className="space-y-2">
|
||||
{tracks.length > 0 ? (
|
||||
tracks.map((track) => (
|
||||
<div key={track.id} className="flex items-start gap-3 p-3 bg-[#0a1628] rounded-lg">
|
||||
<div className="w-8 h-8 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-[#38bdac]">
|
||||
{getActionIcon(track.action)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-medium">{track.actionLabel}</span>
|
||||
{track.chapterTitle && (
|
||||
<span className="text-gray-400 text-sm">- {track.chapterTitle}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-500 text-xs mt-1">
|
||||
<Clock className="w-3 h-3 inline mr-1" />
|
||||
{track.timeAgo} · {new Date(track.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<History className="w-10 h-10 text-[#38bdac]/40 mx-auto mb-4" />
|
||||
<p className="text-gray-400">暂无行为轨迹</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="relations" className="flex-1 overflow-auto space-y-4">
|
||||
<div className="p-4 bg-[#0a1628] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="w-4 h-4 text-[#38bdac]" />
|
||||
<span className="text-white font-medium">推荐的用户</span>
|
||||
</div>
|
||||
<Badge className="bg-[#38bdac]/20 text-[#38bdac] border-0">共 {referrals.length} 人</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{referrals.length > 0 ? (
|
||||
referrals.map((ref: unknown, i: number) => {
|
||||
const r = ref as { id?: string; nickname?: string; status?: string; createdAt?: string }
|
||||
return (
|
||||
<div key={r.id || i} className="flex items-center justify-between p-2 bg-[#162840] rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-xs text-[#38bdac]">
|
||||
{r.nickname?.charAt(0) || '?'}
|
||||
</div>
|
||||
<span className="text-white text-sm">{r.nickname}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{r.status === 'vip' && (
|
||||
<Badge className="bg-green-500/20 text-green-400 border-0 text-xs">已购</Badge>
|
||||
)}
|
||||
<span className="text-gray-500 text-xs">
|
||||
{r.createdAt ? new Date(r.createdAt).toLocaleDateString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm text-center py-4">暂无推荐用户</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-gray-700 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
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={handleSave} disabled={saving} className="bg-[#38bdac] hover:bg-[#2da396] text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{saving ? '保存中...' : '保存修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">用户不存在</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
81
soul-admin/src/components/ui/Pagination.tsx
Normal file
81
soul-admin/src/components/ui/Pagination.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
pageSize: number
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange?: (pageSize: number) => void
|
||||
pageSizeOptions?: number[]
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50, 100],
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1 && !onPageSizeChange) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-4 px-5 border-t border-gray-700/50">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<span>共 {total} 条</span>
|
||||
{onPageSizeChange && (
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
className="bg-[#0f2137] border border-gray-600 rounded px-2 py-1 text-gray-300 text-sm"
|
||||
>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} 条/页
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page <= 1}
|
||||
className="px-2 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-3 py-1 text-gray-400 text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-3 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page >= totalPages}
|
||||
className="px-2 py-1 rounded border border-gray-600 text-gray-400 hover:bg-gray-700/50 disabled:opacity-40 text-sm"
|
||||
>
|
||||
末页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
soul-admin/src/components/ui/badge.tsx
Normal file
31
soul-admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive text-white',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span'
|
||||
return <Comp className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
52
soul-admin/src/components/ui/button.tsx
Normal file
52
soul-admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-white hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
42
soul-admin/src/components/ui/card.tsx
Normal file
42
soul-admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
85
soul-admin/src/components/ui/dialog.tsx
Normal file
85
soul-admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal(props: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal {...props} />
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn('fixed inset-0 z-50 bg-black/50', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = 'DialogOverlay'
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border bg-background p-6 shadow-lg sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div className={cn('flex flex-col gap-2 text-center sm:text-left', className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle(props: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return <DialogPrimitive.Title className="text-lg font-semibold leading-none" {...props} />
|
||||
}
|
||||
|
||||
function DialogDescription(props: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return <DialogPrimitive.Description className="text-sm text-muted-foreground" {...props} />
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
export const DialogClose = DialogPrimitive.Close
|
||||
export const DialogTrigger = DialogPrimitive.Trigger
|
||||
18
soul-admin/src/components/ui/input.tsx
Normal file
18
soul-admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none placeholder:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 md:text-sm focus-visible:ring-2 focus-visible:ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
soul-admin/src/components/ui/label.tsx
Normal file
20
soul-admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
79
soul-admin/src/components/ui/select.tsx
Normal file
79
soul-admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md',
|
||||
position === 'popper' && 'data-[side=bottom]:translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem }
|
||||
48
soul-admin/src/components/ui/slider.tsx
Normal file
48
soul-admin/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from 'react'
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
)
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="bg-gray-600 relative grow overflow-hidden rounded-full h-1.5 w-full">
|
||||
<SliderPrimitive.Range className="bg-[#38bdac] absolute h-full rounded-full" />
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
key={index}
|
||||
className="block size-4 shrink-0 rounded-full border-2 border-[#38bdac] bg-white shadow-sm focus-visible:ring-2 focus-visible:ring-[#38bdac] focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Slider }
|
||||
26
soul-admin/src/components/ui/switch.tsx
Normal file
26
soul-admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#38bdac] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a1628] disabled:cursor-not-allowed disabled:opacity-50 data-[state=unchecked]:bg-gray-600 data-[state=checked]:bg-[#38bdac]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
63
soul-admin/src/components/ui/table.tsx
Normal file
63
soul-admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
49
soul-admin/src/components/ui/tabs.tsx
Normal file
49
soul-admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
19
soul-admin/src/components/ui/textarea.tsx
Normal file
19
soul-admin/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
Reference in New Issue
Block a user