压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
+378
View File
@@ -0,0 +1,378 @@
"use client"
import * as React from "react"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { Input } from "@/components/ui/input"
import { formatDateTime } from "@/lib/utils"
import type { UserPayConfig } from "@/lib/services"
import { useAdmin } from "@/contexts/admin-context"
/**
* 积分配置详情面板组件
* 显示积分配置的详细信息和编辑面板
*
* @example
* ```tsx
* <PayConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {UserPayConfig} config - 积分配置
* @param {Partial<UserPayConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 积分配置详情面板组件
*/
function PayConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: UserPayConfig | null
editData: Partial<UserPayConfig>
onEditDataChange: (field: keyof UserPayConfig, value: UserPayConfig[keyof UserPayConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-2 gap-0">
<div className="border border-dashed rounded-l-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置等级</label>
<p className="text-xs text-muted-foreground">Level {config?.level}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置ID</label>
<p className="text-xs text-muted-foreground">{config?.id}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed">
<label className="text-xs font-medium text-muted-foreground">更新时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.updated_at) : ''}</p>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0" aria-hidden="true">
<span className="text-xs text-muted-foreground">&nbsp;</span>
<span className="text-xs text-muted-foreground">&nbsp;</span>
</div>
</div>
<div className="border border-dashed rounded-r-lg border-l-0">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最低分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.min_score !== undefined ? editData.min_score.toString() : (config?.min_score?.toString() || '')}
placeholder={editData.min_score === undefined && !config?.min_score ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('min_score', 0)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('min_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">最高分数</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.max_score !== undefined ? (editData.max_score?.toString() || '') : (config?.max_score?.toString() || '')}
placeholder={(editData.max_score === null || editData.max_score === undefined) && (config?.max_score === null || config?.max_score === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('max_score', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('max_score', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">手续费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.fee_rate !== undefined
? (parseFloat(editData.fee_rate.toString()) * 100).toString()
: (config?.fee_rate ? (parseFloat(config.fee_rate.toString()) * 100).toString() : '')
}
placeholder={editData.fee_rate === undefined && !config?.fee_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('fee_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('fee_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分数转化率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.score_rate !== undefined
? (parseFloat(editData.score_rate.toString()) * 100).toString()
: (config?.score_rate ? (parseFloat(config.score_rate.toString()) * 100).toString() : '')
}
placeholder={editData.score_rate === undefined && !config?.score_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('score_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('score_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">分发费率</label>
<div className="flex items-right gap-1">
<Input
type="number"
step="1"
min="0"
max="100"
value={
editData.distribute_rate !== undefined
? (parseFloat(editData.distribute_rate.toString()) * 100).toString()
: (config?.distribute_rate ? (parseFloat(config.distribute_rate.toString()) * 100).toString() : '')
}
placeholder={editData.distribute_rate === undefined && !config?.distribute_rate ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('distribute_rate', '0')
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0 && numValue <= 100) {
onEditDataChange('distribute_rate', (numValue / 100).toString())
}
}}
className="text-xs text-right h-4 w-16 px-0 mr-3 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">%</p>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">每日支付上限</label>
<div className="flex items-center gap-1">
<Input
type="number"
step="1"
min="0"
value={editData.daily_limit !== undefined ? (editData.daily_limit?.toString() || '') : (config?.daily_limit?.toString() || '')}
placeholder={(editData.daily_limit === null || editData.daily_limit === undefined) && (config?.daily_limit === null || config?.daily_limit === undefined) ? '无限制' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('daily_limit', null)
return
}
const numValue = parseInt(value)
if (isNaN(numValue)) {
return
}
if (numValue >= 0) {
onEditDataChange('daily_limit', numValue)
}
}}
className="text-xs text-right h-4 w-16 px-0 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-[12px]"
/>
<p className="text-xs text-muted-foreground">LDC</p>
</div>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 用户积分配置管理组件
*
* @example
* ```tsx
* <UserPayConfigs />
* ```
* @returns {React.ReactNode} 用户积分配置管理组件
*/
export function UserPayConfigs() {
const {
userPayConfigs: configs,
userPayConfigsLoading: loading,
userPayConfigsError: error,
refetchUserPayConfigs,
updateUserPayConfig,
deleteUserPayConfig
} = useAdmin()
const getInitialEditData = (config: UserPayConfig) => ({
min_score: config.min_score,
max_score: config.max_score,
daily_limit: config.daily_limit,
fee_rate: config.fee_rate.toString(),
score_rate: config.score_rate.toString(),
distribute_rate: config.distribute_rate.toString(),
})
const handleSave = async (config: UserPayConfig, editData: Partial<UserPayConfig>) => {
if (!config) return
await updateUserPayConfig(config.id, {
min_score: editData.min_score ?? config.min_score,
max_score: editData.max_score,
daily_limit: editData.daily_limit,
fee_rate: editData.fee_rate?.toString() ?? config.fee_rate.toString(),
score_rate: editData.score_rate?.toString() ?? config.score_rate.toString(),
distribute_rate: editData.distribute_rate?.toString() ?? config.distribute_rate.toString(),
})
await refetchUserPayConfigs()
}
const handleDelete = async (config: UserPayConfig) => {
await deleteUserPayConfig(config.id)
}
return (
<ManagePage<UserPayConfig>
title="积分配置"
data={configs}
loading={loading}
error={error}
onReload={refetchUserPayConfigs}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.id}
emptyDescription="未发现积分配置"
loadingDescription="配置加载中"
columns={[
{ header: "等级", cell: (item) => <span className="font-medium">Level {item.level}</span>, width: "min-w-[80px]", align: "left" },
{ header: "最低分", cell: (item) => item.min_score, width: "min-w-[200px]", align: "left" },
{ header: "最高分", cell: (item) => item.max_score || "无限制", width: "min-w-[200px]", align: "left" },
{ header: "每日限额", cell: (item) => item.daily_limit ? `LDC ${ item.daily_limit.toLocaleString() }` : "无限制", width: "min-w-[200px]", align: "left" },
{ header: "费率", cell: (item) => `${ (Number(item.fee_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "分数转化率", cell: (item) => `${ (Number(item.score_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "分发费率", cell: (item) => `${ (Number(item.distribute_rate) * 100).toFixed(2) }%`, width: "min-w-[200px]", align: "left" },
{ header: "更新时间", cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span>, width: "min-w-[200px]", align: "left" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<PayConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { ManagePage, ManageDetailPanel } from "@/components/common/general/manage-pannel"
import { formatDateTime } from "@/lib/utils"
import type { SystemConfig } from "@/lib/services"
import { useAdmin } from "@/contexts/admin-context"
/**
* 系统配置
* 显示系统配置的详细信息和编辑面板
*
* @example
* ```tsx
* <SystemConfigDetailPanel
* config={config}
* editData={editData}
* onEditDataChange={onEditDataChange}
* onSave={onSave}
* saving={saving}
* />
* ```
* @param {SystemConfig} config - 系统配置
* @param {Partial<SystemConfig>} editData - 编辑数据
* @param {function} onEditDataChange - 编辑数据改变回调
* @param {function} onSave - 保存回调
* @param {boolean} saving - 是否正在保存
* @returns {React.ReactNode} 系统配置详情面板组件
*/
function SystemConfigDetailPanel({
config,
editData,
onEditDataChange,
onSave,
saving
}: {
config: SystemConfig | null
editData: Partial<SystemConfig>
onEditDataChange: (field: keyof SystemConfig, value: SystemConfig[keyof SystemConfig]) => void
onSave: () => void
saving: boolean
}) {
return (
<ManageDetailPanel
isEmpty={!config}
onSave={onSave}
saving={saving}
>
<div className="grid grid-cols-1 gap-0">
<div className="border border-dashed rounded-lg">
<div className="px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置键</label>
<p className="text-xs text-muted-foreground font-mono">{config?.key}</p>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置值</label>
<div className="flex gap-1 w-[90%] justify-end items-center pr-3">
{config?.key.endsWith('_enabled') ? (
<Switch
checked={editData.value !== undefined ? editData.value === 'true' : (config?.value === 'true')}
onCheckedChange={(checked) => {
onEditDataChange('value', checked ? 'true' : 'false')
}}
/>
) : (
<Input
type="number"
step="1"
min="0"
value={editData.value !== undefined ? editData.value : (config?.value || '')}
placeholder={editData.value === undefined && !config?.value ? '必需' : ''}
onChange={(e) => {
const value = e.target.value
if (value === '') {
onEditDataChange('value', '')
return
}
onEditDataChange('value', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-inner-spin-button]"
style={{
MozAppearance: 'textfield'
}}
/>
)}
</div>
</div>
<div className="pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0">
<label className="text-xs font-medium text-muted-foreground">配置描述</label>
<div className="flex gap-1 w-[90%]">
<Input
type="text"
value={editData.description !== undefined ? editData.description : (config?.description || '')}
placeholder="可选描述"
onChange={(e) => {
const value = e.target.value
onEditDataChange('description', value)
}}
className="!text-[12px] text-right h-4 rounded-none border-none shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:!text-[12px]"
/>
</div>
</div>
<div className="px-3 py-2 flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">创建时间</label>
<p className="text-xs text-muted-foreground">{config ? formatDateTime(config.created_at) : ''}</p>
</div>
</div>
</div>
</ManageDetailPanel>
)
}
/**
* 系统配置管理组件
*
* @example
* ```tsx
* <SystemConfigs />
* ```
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const {
systemConfigs: configs,
systemConfigsLoading: loading,
systemConfigsError: error,
refetchSystemConfigs,
updateSystemConfig,
deleteSystemConfig
} = useAdmin()
const getInitialEditData = (config: SystemConfig) => ({
value: config.value,
description: config.description
})
const handleSave = async (config: SystemConfig, editData: Partial<SystemConfig>) => {
if (!config) return
await updateSystemConfig(config.key, {
value: editData.value ?? config.value,
description: editData.description ?? config.description
})
await refetchSystemConfigs()
}
const handleDelete = async (config: SystemConfig) => {
await deleteSystemConfig(config.key)
}
return (
<ManagePage<SystemConfig>
title="系统配置"
data={configs}
loading={loading}
error={error}
onReload={refetchSystemConfigs}
getInitialEditData={getInitialEditData}
onSave={handleSave}
onDelete={handleDelete}
getId={(config) => config.key}
emptyDescription="未发现系统配置"
loadingDescription="配置加载中"
columns={[
{ header: "配置键", cell: (item) => <span className="font-mono font-medium">{item.key}</span>, width: "200px" },
{ header: "配置值", cell: (item) => <span className="truncate max-w-[120px] inline-block" title={item.value}>{item.value}</span>, width: "120px" },
{ header: "描述", cell: (item) => <span className="truncate max-w-[200px] inline-block text-muted-foreground" title={item.description}>{item.description}</span>, width: "200px" },
]}
renderDetail={({ selected, hovered, editData, onEditDataChange, onSave, saving }) => (
<SystemConfigDetailPanel
config={selected || hovered}
editData={editData}
onEditDataChange={onEditDataChange}
onSave={onSave}
saving={saving}
/>
)}
/>
)
}
+370
View File
@@ -0,0 +1,370 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Layers, Play, Clock, Info } from "lucide-react"
import { TaskMeta, AdminService, DispatchTaskRequest } from "@/lib/services"
import { ErrorInline } from "@/components/layout/error"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
import { Calendar as CalendarIcon } from "lucide-react"
import { Calendar } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const TASK_CONFIGS: Record<string, { icon: React.ComponentType<{ className?: string }>, color: string, gradient: string }> = {
'order_sync': {
icon: Layers,
color: "text-blue-600 dark:text-blue-400",
gradient: "from-blue-500/10 via-blue-500/5 to-transparent border-blue-200/50 dark:border-blue-800/50 hover:border-blue-400 dark:hover:border-blue-500",
},
'user_gamification': {
icon: Play,
color: "text-amber-600 dark:text-amber-400",
gradient: "from-amber-500/10 via-amber-500/5 to-transparent border-amber-200/50 dark:border-amber-800/50 hover:border-amber-400 dark:hover:border-amber-500",
},
'dispute_auto_refund': {
icon: Clock,
color: "text-rose-600 dark:text-rose-400",
gradient: "from-rose-500/10 via-rose-500/5 to-transparent border-rose-200/50 dark:border-rose-800/50 hover:border-rose-400 dark:hover:border-rose-500",
}
}
const DEFAULT_TASK_CONFIG = {
icon: Layers,
color: "text-zinc-600 dark:text-zinc-400",
gradient: "from-zinc-500/10 via-zinc-500/5 to-transparent border-zinc-200/50 dark:border-zinc-800/50 hover:border-zinc-400 dark:hover:border-zinc-500",
}
function DatePickerWithTime({ date, setDate }: { date: Date | undefined, setDate: (date: Date | undefined) => void }) {
const timeString = date ? format(date, "HH:mm:ss") : ""
const handleDateSelect = (newDate: Date | undefined) => {
if (!newDate) {
setDate(undefined)
return
}
const updated = new Date(newDate)
if (date) {
updated.setHours(date.getHours())
updated.setMinutes(date.getMinutes())
updated.setSeconds(date.getSeconds())
} else {
updated.setHours(0, 0, 0, 0)
}
setDate(updated)
}
const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value
if (!newTime) return
if (date) {
const [hours, minutes, seconds] = newTime.split(':').map(Number)
const updated = new Date(date)
updated.setHours(hours || 0)
updated.setMinutes(minutes || 0)
updated.setSeconds(seconds || 0)
setDate(updated)
} else {
const today = new Date()
const [hours, minutes, seconds] = newTime.split(':').map(Number)
today.setHours(hours || 0)
today.setMinutes(minutes || 0)
today.setSeconds(seconds || 0)
setDate(today)
}
}
return (
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal text-xs h-8",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-1 size-3" />
{date ? format(date, "yyyy-MM-dd") : <span>选择日期</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
</div>
<div className="w-[120px]">
<Input
type="time"
step="1"
value={timeString}
onChange={handleTimeChange}
className="text-xs font-mono"
/>
</div>
</div>
)
}
export function TaskManager() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([])
const [dispatching, setDispatching] = useState(false)
const [selectedTaskType, setSelectedTaskType] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const [startTime, setStartTime] = useState<Date | undefined>(undefined)
const [endTime, setEndTime] = useState<Date | undefined>(undefined)
const [userId, setUserId] = useState("")
const fetchTaskTypes = useCallback(async () => {
try {
setLoading(true)
setError(null)
const data = await AdminService.getTaskTypes()
setTaskTypes(data)
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchTaskTypes()
}, [fetchTaskTypes])
const handleDispatch = async () => {
if (!selectedTaskType) return
try {
setDispatching(true)
const targetTask = taskTypes.find(t => t.type === selectedTaskType)
const params: DispatchTaskRequest = {
task_type: selectedTaskType
}
if (targetTask?.type === 'order_sync') {
if (startTime) params.start_time = startTime.toISOString()
if (endTime) params.end_time = endTime.toISOString()
}
if (targetTask?.type === 'user_gamification') {
if (userId) params.user_id = userId
}
await AdminService.dispatchTask(params)
toast.success('任务下发成功', {
description: `已成功将任务 ${ targetTask?.name || selectedTaskType } 加入队列`
})
setDialogOpen(false)
setStartTime(undefined)
setEndTime(undefined)
setUserId("")
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setDispatching(false)
}
}
const openDispatchDialog = (type: string) => {
setSelectedTaskType(type)
setDialogOpen(true)
}
const getSelectedTaskMeta = () => {
return taskTypes.find(t => t.type === selectedTaskType)
}
return (
<div className="py-6 space-y-6">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold tracking-tight">任务管理</div>
</div>
</div>
<div className="space-y-6">
{error ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={error} onRetry={fetchTaskTypes} className="justify-center" />
</div>
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载任务类型中..." />
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description="暂无可用任务类型" />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{taskTypes.map((task, index) => {
const config = TASK_CONFIGS[task.type] || DEFAULT_TASK_CONFIG
return (
<div
key={`${ task.type }-${ index }`}
className={cn(
"relative group overflow-hidden rounded-xl border bg-gradient-to-br transition-all duration-500",
config.gradient
)}
>
<div className="relative h-full bg-card/40 backdrop-blur-sm p-4 flex flex-col justify-between hover:bg-card/0 transition-colors duration-500">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h3 className="font-semibold text-base tracking-tight">{task.name}</h3>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 min-h-[36px]">
{task.description}
</p>
</div>
<Badge variant="secondary" className="font-mono text-[10px] bg-background/50 backdrop-blur-md border px-1.5 h-5">
{task.queue}
</Badge>
</div>
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
类型:{task.type}
</Badge>
<Badge variant="outline" className="text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1">
重试:{task.max_retry}
</Badge>
</div>
</div>
<div className="pt-4 mt-1">
<Button
className="w-full h-7 text-xs"
variant="secondary"
onClick={() => openDispatchDialog(task.type)}
>
<Play className="size-3 mr-1" />
立即执行
</Button>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>执行任务</Label>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted/50">
{(() => {
const meta = getSelectedTaskMeta()
if (!meta) return null
const config = TASK_CONFIGS[meta.type] || DEFAULT_TASK_CONFIG
const Icon = config.icon
return (
<>
<div className={cn("p-2", config.color)}>
<Icon className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-xs font-medium">{meta.name}</span>
<span className="text-[10px] text-muted-foreground font-mono">{meta.type}</span>
</div>
</>
)
})()}
</div>
</div>
{selectedTaskType === 'order_sync' && (
<>
<div className="grid gap-2">
<Label>开始时间</Label>
<DatePickerWithTime date={startTime} setDate={setStartTime} />
<p className="text-xs text-muted-foreground">选择同步的起始时间点</p>
</div>
<div className="grid gap-2">
<Label>结束时间</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className="text-xs text-muted-foreground">选择同步的结束时间点</p>
</div>
</>
)}
{selectedTaskType === 'user_gamification' && (
<div className="grid gap-2">
<Label htmlFor="user-id">用户 ID</Label>
<Input
id="user-id"
type="number"
placeholder="e.g. 10086"
className="font-mono text-xs"
value={userId}
onChange={(e) => setUserId(e.target.value)}
/>
<p className="text-xs text-muted-foreground">指定需要更新积分数据的用户 ID</p>
</div>
)}
{!['order_sync', 'user_gamification'].includes(selectedTaskType || '') && (
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed">
<Info className="h-4 w-4" />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setDialogOpen(false)} disabled={dispatching} className="h-8 text-xs">
取消
</Button>
<Button onClick={handleDispatch} disabled={dispatching} className="h-8 text-xs">
{dispatching ? <Spinner className="size-3" /> : <Play className="size-3" />}
{dispatching ? '下发中' : '启动'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+598
View File
@@ -0,0 +1,598 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Sheet, SheetTitle, SheetContent } from "@/components/ui/sheet"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Layers, Search, UserX, UserCheck, Eye, Wallet, CreditCard, ShieldCheck, Filter, X, ChevronDown, ChevronLeft, ChevronRight, Users, Loader2 } from "lucide-react"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { AdminUser, AdminService, DispatchTaskRequest } from "@/lib/services"
import { toast } from "sonner"
import { formatDateTime } from "@/lib/utils"
import { EmptyStateWithBorder } from "@/components/layout/empty"
import { LoadingStateWithBorder } from "@/components/layout/loading"
import { ErrorInline } from "@/components/layout/error"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { useAdminUsers } from "@/contexts/admin-users-context"
export function UsersManager() {
const {
users,
total,
loading,
error,
page,
pageSize,
searchUserId,
searchUsername,
statusFilter,
setPage,
setPageSize,
setSearchUserId,
setSearchUsername,
setStatusFilter,
fetchUsers,
updateUserStatus
} = useAdminUsers()
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null)
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const handleStatusToggle = async (user: AdminUser) => {
await updateUserStatus(user)
if (selectedUser?.id === user.id) {
setSelectedUser(prev => prev ? { ...prev, is_active: !prev.is_active } : null)
}
}
const handleShowDetail = (user: AdminUser) => {
setSelectedUser(user)
setDetailOpen(true)
}
const handleUpdateCredits = async (user: AdminUser) => {
try {
setUpdatingUserId(user.id)
const params: DispatchTaskRequest = {
task_type: 'user_gamification',
user_id: user.id
}
await AdminService.dispatchTask(params)
toast.success('积分更新任务已下发', {
description: `正在更新用户 ${ user.username } 的积分数据`
})
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误'
})
} finally {
setUpdatingUserId(null)
}
}
const totalPages = Math.ceil(total / pageSize)
const hasSearchFilter = Boolean(searchUserId || searchUsername)
const renderFilterBar = () => (
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
hasSearchFilter && "bg-primary/5 border-primary/20"
)}
>
<Search className="size-3 mr-1" />
搜索
{hasSearchFilter && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
!
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 p-3" align="start">
<div className="space-y-2.5">
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入用户 ID..."
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
placeholder="输入 username..."
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
{hasSearchFilter && (
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-xs"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
}}
>
清除
</Button>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={cn(
"h-5 border-dashed text-[10px] font-medium shadow-none focus-visible:ring-0",
statusFilter !== 'all' && "bg-primary/5 border-primary/20"
)}
>
<Filter className="size-3" />
状态
{statusFilter !== 'all' && (
<>
<Separator orientation="vertical" className="mx-1" />
<Badge
variant="secondary"
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
>
1
</Badge>
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-[120px]" align="start">
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('all') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'all'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">全部状态</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('active') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'active'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">正常</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => { e.preventDefault(); setStatusFilter('inactive') }}
>
<div className={cn(
"mr-2 flex size-3 items-center justify-center rounded-sm border border-primary",
statusFilter === 'inactive'
? "bg-primary text-primary-foreground"
: "opacity-50"
)} />
<span className="text-xs">禁用</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{(hasSearchFilter || statusFilter !== 'all') && (
<>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchUserId("")
setSearchUsername("")
setStatusFilter('all')
}}
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
清空筛选
</Button>
</>
)}
</div>
<Separator className="lg:hidden" />
<div className="flex items-center gap-1.5 self-end lg:self-auto">
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{total} 条记录
</span>
<div className="flex items-center border border-dashed rounded-md shadow-none">
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-l-md disabled:opacity-30"
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground px-2 border-x border-dashed">
{page}/{totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-5.5 w-6 rounded-none rounded-r-md disabled:opacity-30"
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page >= totalPages || loading}
>
<ChevronRight className="size-3" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 border-dashed text-[10px] px-2 font-mono shadow-none" disabled={loading}>
{pageSize}条/页
<ChevronDown className="size-3 opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{[20, 50, 100].map(size => (
<DropdownMenuItem
key={size}
onClick={() => setPageSize(size)}
className={cn("font-mono text-xs", pageSize === size && "bg-accent")}
>
{size}条/页
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="icon"
className="h-6 w-6 border-dashed shadow-none"
onClick={() => fetchUsers(true)}
disabled={loading}
title="刷新数据"
>
<Loader2 className={cn("size-3", loading && "animate-spin")} />
</Button>
</div>
</div>
)
return (
<div className="py-6 space-y-4">
<div className="flex items-center justify-between border-b border-border pb-2">
<div className="flex flex-col gap-1">
<div className="text-2xl font-semibold">用户管理</div>
</div>
</div>
{renderFilterBar()}
{error ? (
<div className="p-8 border border-dashed rounded-lg">
<ErrorInline error={error} onRetry={() => fetchUsers(true)} className="justify-center" />
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description="加载用户列表中..." />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description="暂无用户数据" />
) : (
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
<Table className="w-full caption-bottom text-sm min-w-full">
<TableHeader className="sticky top-0 z-20 bg-background">
<TableRow className="border-b border-dashed hover:bg-transparent">
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">余额</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">基准分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">支付分</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总收益</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono">总消耗</TableHead>
<TableHead className="text-right whitespace-nowrap min-w-[80px] py-2 h-8 font-mono is-last-money">总划转</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
<TableHead className="sticky right-0 text-center bg-background z-10 w-[80px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow
key={user.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleShowDetail(user)}
>
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{user.id}</TableCell>
<TableCell className="py-1">
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7 rounded-sm border">
<AvatarImage src={user.avatar_url} />
<AvatarFallback className="rounded-sm text-[10px]">
{user.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight max-w-[100px] truncate" title={user.nickname}>{user.nickname}</span>
{user.is_admin && (
<Badge variant="secondary" className="text-[9px] h-3.5 px-0.5 rounded-[2px] font-normal leading-none tracking-tighter">
ADM
</Badge>
)}
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground font-mono leading-tight">@{user.username}</span>
</div>
</div>
</div>
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 dark:text-green-500 font-medium py-1">
{Number(user.available_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.community_balance).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] py-1">
{Number(user.pay_score).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-green-600 py-1">
{Number(user.total_receive).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-red-500 py-1">
{Number(user.total_payment).toFixed(2)}
</TableCell>
<TableCell className="text-right font-mono text-[11px] text-purple-500 py-1 is-last-money">
{Number(user.total_community).toFixed(2)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
{formatDateTime(user.last_login_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.created_at)}
</TableCell>
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1">
{formatDateTime(user.updated_at)}
</TableCell>
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-0.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={user.is_active}
onCheckedChange={() => handleStatusToggle(user)}
disabled={user.is_admin}
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
/>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{user.is_admin ? '管理员账户' : user.is_active ? '禁用账户' : '启用账户'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => handleUpdateCredits(user)}
disabled={updatingUserId === user.id}
>
<Loader2 className={cn("size-3", updatingUserId === user.id && "animate-spin")} />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
更新积分
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => handleShowDetail(user)}>
<Eye className="size-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
查看详情
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className="sm:max-w-[400px] w-full p-0 flex flex-col gap-0">
<SheetTitle className="px-5 py-3">用户档案</SheetTitle>
{selectedUser && (
<>
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
<div className="flex flex-col pb-6">
<div className="px-5 py-6 border-b border-border/50">
<div className="flex flex-col items-center text-center gap-3">
<Avatar className="h-20 w-20 rounded-full border-4 border-background ring-1 ring-border/20">
<AvatarImage src={selectedUser.avatar_url} />
<AvatarFallback className="rounded-full text-xl font-medium bg-secondary text-secondary-foreground">
{selectedUser.username.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1.5">
<h3 className="text-lg font-bold tracking-tight">{selectedUser.nickname}</h3>
<div className="flex items-center justify-center gap-2">
<code className="px-1.5 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">@{selectedUser.username}</code>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
UID: {selectedUser.id}
</Badge>
<Badge variant="secondary" className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
Lv.{selectedUser.trust_level}
</Badge>
{selectedUser.is_admin && (
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
Admin
</Badge>
)}
</div>
</div>
<div className="gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50">
<div className="flex flex-col gap-0.5">
<span className="text-[9px] uppercase tracking-widest text-muted-foreground font-medium">注册时间</span>
<span className="font-mono text-xs font-semibold">{formatDateTime(selectedUser.created_at).split(' ')[0]}</span>
</div>
</div>
</div>
</div>
<div className="p-6">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-4 px-1">核心资产</h4>
<div className="grid grid-cols-3 gap-2">
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Wallet className="w-4 h-4" />
<span className="text-[10px] font-medium">可用余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.available_balance).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<CreditCard className="w-4 h-4" />
<span className="text-[10px] font-medium">支付积分</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.pay_score).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-xl border bg-card/50 hover:bg-card/80 transition-colors space-y-2">
<div className="flex flex-col gap-1 text-muted-foreground">
<Users className="w-4 h-4" />
<span className="text-[10px] font-medium">社区余额</span>
</div>
<div className="text-lg font-mono font-bold tracking-tight text-foreground truncate">
{Number(selectedUser.community_balance).toFixed(2)}
</div>
</div>
</div>
</div>
<Separator className="mx-6 w-auto opacity-50" />
<div className="p-6 space-y-6">
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">财务概况</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收益</span>
<span className="font-mono text-[10px] font-medium text-purple-600 dark:text-purple-400">+{Number(selectedUser.total_community).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计收入</span>
<span className="font-mono text-[10px] font-medium text-green-600">+{Number(selectedUser.total_receive).toFixed(2)}</span>
</div>
<div className="flex items-center justify-between p-3.5">
<span className="text-[10px]">累计支出</span>
<span className="font-mono text-[10px] font-medium text-red-500">{Number(selectedUser.total_payment) === 0 ? '0.00' : `-${ Number(selectedUser.total_payment).toFixed(2) }`}</span>
</div>
</div>
</div>
<div className="space-y-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1">系统记录</h4>
<div className="rounded-lg border divide-y bg-background/50">
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后登录</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.last_login_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">注册时间</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.created_at)}</span>
</div>
<div className="flex items-center justify-between p-3.5 text-sm">
<span className="text-[10px]">最后更新</span>
<span className="font-mono text-[10px]">{formatDateTime(selectedUser.updated_at)}</span>
</div>
</div>
</div>
</div>
</div>
</div>
{!selectedUser.is_admin && (
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
<Button
variant={selectedUser.is_active ? "destructive" : "default"}
className={cn(
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
selectedUser.is_active
? "bg-red-500 hover:bg-red-600 text-white"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
onClick={() => handleStatusToggle(selectedUser)}
>
{selectedUser.is_active ? (
<>
<ShieldCheck className="size-3 mr-1" />
封禁账户
</>
) : (
<>
<UserCheck className="size-3 mr-1" />
解除封禁
</>
)}
</Button>
</div>
)}
</>
)}
</SheetContent>
</Sheet>
</div>
)
}