diff --git a/src/app/admin/resources/_client/long.tsx b/src/app/admin/resources/_client/long.tsx deleted file mode 100644 index a042e72..0000000 --- a/src/app/admin/resources/_client/long.tsx +++ /dev/null @@ -1,332 +0,0 @@ -'use client' - -import {useStatus} from '@/lib/states' -import {useEffect, useState} from 'react' -import {ExtraResp} from '@/lib/api' -import {listResourceLong} from '@/actions/resource' -import zod from 'zod' -import {useSearchParams} from 'next/navigation' -import {useForm} from 'react-hook-form' -import {zodResolver} from '@hookform/resolvers/zod' -import {Form, FormField} from '@/components/ui/form' -import {Input} from '@/components/ui/input' -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select' -import {Label} from '@/components/ui/label' -import DatePicker from '@/components/date-picker' -import {Button} from '@/components/ui/button' -import {Box, Eraser, Search, Timer} from 'lucide-react' -import DataTable from '@/components/data-table' -import {format, intlFormatDistance, isAfter} from 'date-fns' - -export type LongResourceProps = {} - -export default function LongResource(props: LongResourceProps) { - // ====================== - // 查询 - // ====================== - - const [status, setStatus] = useStatus() - const [data, setData] = useState>({ - page: 1, - size: 10, - total: 0, - list: [], - }) - - const refresh = async (page: number, size: number) => { - setStatus('load') - try { - const type = { - all: undefined, - expire: 1, - quota: 2, - }[form.getValues('type')] - const create_after = form.getValues('create_after') - const create_before = form.getValues('create_before') - const expire_after = form.getValues('expire_after') - const expire_before = form.getValues('expire_before') - const resource_no = form.getValues('resource_no') - - const res = await listResourceLong({ - page, size, - type, - create_after, - create_before, - expire_after, - expire_before, - resource_no, - }) - - if (res.success) { - setData(res.data) - setStatus('done') - } - else { - throw new Error('Failed to load short resource') - } - } - catch (e) { - setStatus('fail') - } - } - - useEffect(() => { - refresh(1, 10).then() - }, []) - - // ====================== - // 筛选 - // ====================== - - const filterSchema = zod.object({ - resource_no: zod.string().optional().default(''), - type: zod.enum(['expire', 'quota', 'all']).default('all'), - create_after: zod.date().optional(), - create_before: zod.date().optional(), - expire_after: zod.date().optional(), - expire_before: zod.date().optional(), - }) - - type FilterSchema = zod.infer - - const params = useSearchParams() - let paramType = params.get('type') - if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') { - paramType = 'all' - } - - const form = useForm({ - resolver: zodResolver(filterSchema), - defaultValues: { - resource_no: params.get('resource_no') || '', - type: paramType as 'expire' | 'quota' | 'all', - create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined, - create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined, - expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined, - expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined, - }, - }) - - const onSubmit = async (value: FilterSchema) => { - await refresh(1, data.size) - } - - return ( - <> - {/* 操作区 */} -
-
- -
- -
- 套餐编号}> - {({id, field}) => ( - - )} - - 类型}> - {({field}) => ( - - )} - -
- -
- - {({field}) => ( - - )} - - - - - {({field}) => ( - - )} - -
-
-
- -
- - {({field}) => ( - - )} - - - - - {({field}) => ( - - )} - -
-
-
- - -
-
-
- - {/* 数据表 */} - { - await refresh(page, data.size) - }, - onSizeChange: async (size: number) => { - await refresh(data.page, size) - }, - }} - columns={[ - { - accessorKey: 'resource_no', header: `套餐编号`, - }, - { - accessorKey: 'type', header: `类型`, cell: ({row}) => ( -
- {row.original.long.type === 1 && ( -
- - 包时 -
- )} - {row.original.long.type === 2 && ( -
- - 包量 -
- )} -
- ), - }, - { - accessorKey: 'live', header: `IP 时效`, cell: ({row}) => ( - - {row.original.long.live} - {' '} - 小时 - - ), - }, - { - accessorKey: 'expire', header: `使用情况`, cell: ({row}) => ( -
- {row.original.long.type === 1 ? ( -
- {isAfter(row.original.long.expire_at, new Date()) - ? 正常 - : 过期} - | - - 今日限额: {row.original.long.last_at - && new Date(row.original.long.last_at).toDateString() === new Date().toDateString() - ? row.original.long.daily - : 0}/{row.original.long.quota} - - | - - {intlFormatDistance(row.original.long.expire_at, new Date())} - {' '} - 到期 - -
- ) : row.original.long.type === 2 ? ( -
- {row.original.long.used < row.original.long.quota - ? 正常 - : 已用完} - | - - 用量统计: - {row.original.long.used} - {' '} - / - {row.original.long.quota} - -
- ) : ( - - - )} -
- ), - }, - { - accessorKey: 'last_at', - header: '最近使用时间', - cell: ({row}) => { - const lastAt = row.original.long.last_at - if (!lastAt) { - return '暂未使用' - } - return format(lastAt, 'yyyy-MM-dd HH:mm') - }, - }, - { - accessorKey: 'created_at', header: '开通时间', cell: ({row}) => ( - format(row.getValue('created_at'), 'yyyy-MM-dd HH:mm') - ), - }, - { - accessorKey: 'action', header: `操作`, cell: item => ( -
- - -
- ), - }, - ]} - /> - - ) -} diff --git a/src/app/admin/resources/_client/short.tsx b/src/app/admin/resources/_client/short.tsx deleted file mode 100644 index af854c5..0000000 --- a/src/app/admin/resources/_client/short.tsx +++ /dev/null @@ -1,298 +0,0 @@ -'use client' -import {useCallback, useEffect, useState} from 'react' -import {Form, FormField} from '@/components/ui/form' -import {Input} from '@/components/ui/input' -import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select' -import {Label} from '@/components/ui/label' -import DatePicker from '@/components/date-picker' -import {Button} from '@/components/ui/button' -import {Box, Eraser, Search, Timer} from 'lucide-react' -import DataTable from '@/components/data-table' -import {format, isAfter, isSameDay} from 'date-fns' -import {useStatus} from '@/lib/states' -import {ExtraResp} from '@/lib/api' -import {listResourceShort} from '@/actions/resource' -import zod from 'zod' -import {useSearchParams} from 'next/navigation' -import {useForm} from 'react-hook-form' -import {zodResolver} from '@hookform/resolvers/zod' - -const filterSchema = zod.object({ - resource_no: zod.string().optional().default(''), - type: zod.enum(['expire', 'quota', 'all']).default('all'), - create_after: zod.date().optional(), - create_before: zod.date().optional(), - expire_after: zod.date().optional(), - expire_before: zod.date().optional(), -}) - -type FilterSchema = zod.infer - -export default function ShortResource() { - const [status, setStatus] = useStatus() - const [data, setData] = useState>({ - page: 1, - size: 10, - total: 0, - list: [], - }) - - const params = useSearchParams() - let paramType = params.get('type') - if (paramType !== 'all' && paramType !== 'expire' && paramType !== 'quota') { - paramType = 'all' - } - - // 筛选表单 - const form = useForm({ - resolver: zodResolver(filterSchema), - defaultValues: { - resource_no: params.get('resource_no') || '', - type: paramType as 'expire' | 'quota' | 'all', - create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined, - create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined, - expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined, - expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined, - }, - }) - const handler = form.handleSubmit(async (value: FilterSchema) => { - await refresh(1, data.size) - }) - - // 查询 - const refresh = useCallback(async (page: number, size: number) => { - setStatus('load') - try { - const type = { - all: undefined, - expire: 1, - quota: 2, - }[form.getValues('type')] - const create_after = form.getValues('create_after') - const create_before = form.getValues('create_before') - const expire_after = form.getValues('expire_after') - const expire_before = form.getValues('expire_before') - const resource_no = form.getValues('resource_no') - - const res = await listResourceShort({ - page, size, - type, - create_after, - create_before, - expire_after, - expire_before, - resource_no, - }) - - if (res.success) { - setData(res.data) - setStatus('done') - } - else { - throw new Error(`Failed to load short resource`) - } - } - catch (e) { - setStatus('fail') - } - }, [form, setStatus]) - - useEffect(() => { - refresh(1, 10).then() - }, [refresh]) - - // 筛选 - - return ( - <> - {/* 操作区 */} -
-
-
- -
- 套餐编号}> - {({id, field}) => ( - - )} - - 类型}> - {({field}) => ( - - )} - -
- -
- - {({field}) => ( - - )} - - - - - {({field}) => ( - - )} - -
-
-
- -
- - {({field}) => ( - - )} - - - - - {({field}) => ( - - )} - -
-
-
- - -
-
-
- - {/* 数据表 */} - { - await refresh(page, data.size) - }, - onSizeChange: async (size: number) => { - await refresh(data.page, size) - }, - }} - columns={[ - { - accessorKey: 'resource_no', header: `套餐编号`, - }, - { - accessorKey: 'type', header: `类型`, cell: ({row}) => ( -
- {row.original.short.type === 1 && ( -
- - 包时 -
- )} - {row.original.short.type === 2 && ( -
- - 包量 -
- )} -
- ), - }, - { - accessorKey: 'live', header: `IP 时效`, cell: ({row}) => ( - {row.original.short.live / 60}分钟 - ), - }, - { - accessorKey: 'expire', header: `使用情况`, cell: ({row}) => ( -
- {row.original.short.type === 1 ? ( -
- {isAfter(row.original.short.expire_at, new Date()) - ? 正常 - : 过期} - | - - {row.original.short.last_at && isSameDay(row.original.short.expire_at, new Date()) - ? row.original.short.daily - : 0 - }/{row.original.short.quota} - - | -
- ) : row.original.short.type === 2 ? ( -
- {row.original.short.used < row.original.short.quota - ? 正常 - : 已用完} - | - - 用量统计: - {row.original.short.used} - {' '} - / - {row.original.short.quota} - -
- ) : ( - - - )} -
- ), - }, - { - header: '最近使用时间', cell: ({row}) => row.original.short.last_at - ? format(row.original.short.last_at, 'yyyy-MM-dd HH:mm') - : '-', - }, - {header: '开通时间', cell: ({row}) => format(row.original.created_at, 'yyyy-MM-dd HH:mm')}, - {header: '到期时间', cell: ({row}) => format(row.original.short.expire_at, 'yyyy-MM-dd HH:mm')}, - ]} - /> - - ) -} diff --git a/src/app/admin/resources/_components/filter.tsx b/src/app/admin/resources/_components/filter.tsx new file mode 100644 index 0000000..d7f9723 --- /dev/null +++ b/src/app/admin/resources/_components/filter.tsx @@ -0,0 +1,119 @@ +'use client' + +import {UseFormReturn} from 'react-hook-form' +import {Form, FormField} from '@/components/ui/form' +import {Input} from '@/components/ui/input' +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select' +import {Label} from '@/components/ui/label' +import DatePicker from '@/components/date-picker' +import {Button} from '@/components/ui/button' +import {Eraser, Search} from 'lucide-react' + +export interface ResourceFilterValues { + resource_no: string + type: 'expire' | 'quota' | 'all' + create_after?: Date + create_before?: Date + expire_after?: Date + expire_before?: Date +} + +interface ResourceFilterProps { + form: UseFormReturn + onSubmit: () => void | Promise + onReset: () => void +} + +export default function ResourceFilter({form, onSubmit, onReset}: ResourceFilterProps) { + const handler = form.handleSubmit(onSubmit) + + return ( +
+ 套餐编号}> + {({id, field}) => ( + + )} + + 类型}> + {({field}) => ( + + )} + +
+ +
+ + {({field}) => ( + + )} + + - + + {({field}) => ( + + )} + +
+
+
+ +
+ + {({field}) => ( + + )} + + - + + {({field}) => ( + + )} + +
+
+
+ + +
+
+ ) +} diff --git a/src/app/admin/resources/_components/list.tsx b/src/app/admin/resources/_components/list.tsx new file mode 100644 index 0000000..5966164 --- /dev/null +++ b/src/app/admin/resources/_components/list.tsx @@ -0,0 +1,260 @@ +'use client' + +import {useCallback, useEffect, useMemo, useState} from 'react' +import {useSearchParams} from 'next/navigation' +import {useForm} from 'react-hook-form' +import {zodResolver} from '@hookform/resolvers/zod' +import zod from 'zod' +import {toast} from 'sonner' +import {useStatus} from '@/lib/states' +import {ExtraResp} from '@/lib/api' +import {listResourceLong, listResourceShort} from '@/actions/resource' +import DataTable from '@/components/data-table' +import {ColumnDef} from '@tanstack/react-table' +import {Resource} from '@/lib/models/resource' +import ResourceFilter, {ResourceFilterValues} from './filter' +import { + ExpireBadge, + formatDateTime, + getTodayUsage, + isValidResourceType, + ResourceTypeBadge, +} from './utils' + +const filterSchema = zod.object({ + resource_no: zod.string().optional().default(''), + type: zod.enum(['expire', 'quota', 'all']).default('all'), + create_after: zod.date().optional(), + create_before: zod.date().optional(), + expire_after: zod.date().optional(), + expire_before: zod.date().optional(), +}) + +interface ResourceListProps { + resourceType: 'long' | 'short' +} + +export default function ResourceList({resourceType}: ResourceListProps) { + const isLong = resourceType === 'long' + const [status, setStatus] = useStatus() + const [data, setData] = useState>({ + page: 1, + size: 10, + total: 0, + list: [], + }) + + // 从 URL 参数初始化筛选条件 + const params = useSearchParams() + const paramType = params.get('type') + + const form = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + resource_no: params.get('resource_no') || '', + type: isValidResourceType(paramType) ? paramType : 'all', + create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined, + create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined, + expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined, + expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined, + }, + }) + + const getValues = form.getValues + + // 查询数据 + const refresh = useCallback(async (page: number, size: number) => { + setStatus('load') + try { + const type = { + all: undefined, + expire: 1, + quota: 2, + }[getValues('type')] + const create_after = getValues('create_after') + const create_before = getValues('create_before') + const expire_after = getValues('expire_after') + const expire_before = getValues('expire_before') + const resource_no = getValues('resource_no') + + const listFn = isLong ? listResourceLong : listResourceShort + const res = await listFn({ + page, + size, + type, + create_after, + create_before, + expire_after, + expire_before, + resource_no, + }) + + if (res.success) { + setData(res.data) + setStatus('done') + } + else { + throw new Error(`Failed to load ${resourceType} resource`) + } + } + catch (e) { + setStatus('fail') + toast.error(e instanceof Error ? e.message : `加载${isLong ? '长' : '短'}效资源失败`) + } + }, [getValues, setStatus, isLong, resourceType]) + + useEffect(() => { + refresh(1, 10).then() + }, [refresh]) + + // 处理筛选提交 + const handleSubmit = async () => { + await refresh(1, data.size) + } + + // 处理重置 + const handleReset = () => { + form.reset({ + type: 'all', + resource_no: '', + create_after: undefined, + create_before: undefined, + expire_after: undefined, + expire_before: undefined, + }) + } + + // 表格列定义 + const columns = useMemo | Resource<2>>[]>(() => { + const resourceKey = isLong ? 'long' : 'short' + + const baseColumns: ColumnDef | Resource<2>>[] = [ + { + header: '套餐编号', + cell: ({row}) => { + const expireAt = resourceKey === 'long' + ? (row.original as Resource<2>).long.expire_at + : (row.original as Resource<1>).short.expire_at + + return ( +
+ {row.original.resource_no} + +
+ ) + }, + }, + { + header: '类型', + cell: ({row}) => { + const type = resourceKey === 'long' + ? (row.original as Resource<2>).long.type + : (row.original as Resource<1>).short.type + return + }, + }, + { + header: 'IP 时效', + cell: ({row}) => { + const live = resourceKey === 'long' + ? (row.original as Resource<2>).long.live + : (row.original as Resource<1>).short.live + return {isLong ? `${live}小时` : `${live / 60}分钟`} + }, + }, + { + header: '使用情况', + cell: ({row}) => { + const resource = resourceKey === 'long' + ? (row.original as Resource<2>).long + : (row.original as Resource<1>).short + + if (resource.type === 1) { + const todayUsage = getTodayUsage(resource.last_at, resource.daily) + return ( +
+ {todayUsage}/{resource.quota} +
+ ) + } + else if (resource.type === 2) { + if (isLong) { + return ( +
+ {resource.used < resource.quota + ? 正常 + : 已用完} + | + 用量统计:{resource.used} / {resource.quota} +
+ ) + } + else { + return ( +
+ {resource.used}/{resource.quota} +
+ ) + } + } + return - + }, + }, + { + header: '最近使用时间', + cell: ({row}) => { + const lastAt = resourceKey === 'long' + ? (row.original as Resource<2>).long.last_at + : (row.original as Resource<1>).short.last_at + return lastAt ? formatDateTime(lastAt) : '暂未使用' + }, + }, + { + header: '开通时间', + cell: ({row}) => formatDateTime(row.original.created_at), + }, + ] + + // 短效资源增加到期时间列 + if (!isLong) { + baseColumns.push({ + header: '到期时间', + cell: ({row}) => formatDateTime((row.original as Resource<1>).short.expire_at), + }) + } + + return baseColumns + }, [isLong]) + + return ( + <> + {/* 操作区 */} +
+
+ +
+ + {/* 数据表 */} + { + await refresh(page, data.size) + }, + onSizeChange: async (size: number) => { + await refresh(data.page, size) + }, + }} + columns={columns} + /> + + ) +} diff --git a/src/app/admin/resources/_components/utils.tsx b/src/app/admin/resources/_components/utils.tsx new file mode 100644 index 0000000..10e3ff6 --- /dev/null +++ b/src/app/admin/resources/_components/utils.tsx @@ -0,0 +1,51 @@ +import {format, isBefore, isSameDay} from 'date-fns' +import {Badge} from '@/components/ui/badge' +import {Box, Timer} from 'lucide-react' + +// 类型守卫函数 +export function isValidResourceType(type: string | null): type is 'expire' | 'quota' | 'all' { + return type === 'expire' || type === 'quota' || type === 'all' +} + +// 资源类型徽章 +export function ResourceTypeBadge({type}: {type: number}) { + if (type === 1) { + return ( +
+ + 包时 +
+ ) + } + if (type === 2) { + return ( +
+ + 包量 +
+ ) + } + return null +} + +// 过期徽章 +export function ExpireBadge({expireAt}: {expireAt: Date}) { + if (isBefore(expireAt, new Date())) { + return 过期 + } + return null +} + +// 格式化日期 +export function formatDateTime(date: Date | null | undefined) { + if (!date) return '-' + return format(date, 'yyyy-MM-dd HH:mm') +} + +// 计算今日使用量 +export function getTodayUsage(lastAt: Date | null | undefined, daily: number) { + if (lastAt && isSameDay(lastAt, new Date())) { + return daily + } + return 0 +} diff --git a/src/app/admin/resources/page.tsx b/src/app/admin/resources/page.tsx index 2c088eb..7c8fde0 100644 --- a/src/app/admin/resources/page.tsx +++ b/src/app/admin/resources/page.tsx @@ -1,8 +1,7 @@ import Page from '@/components/page' import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs' -import ShortResource from '@/app/admin/resources/_client/short' -import LongResource from '@/app/admin/resources/_client/long' import {Suspense} from 'react' +import ResourceList from './_components/list' export default async function ResourcesPage() { // ====================== @@ -18,12 +17,12 @@ export default async function ResourcesPage() { - + - + diff --git a/src/components/composites/purchase/pay.tsx b/src/components/composites/purchase/pay.tsx index bbea5ae..ebe2aea 100644 --- a/src/components/composites/purchase/pay.tsx +++ b/src/components/composites/purchase/pay.tsx @@ -1,8 +1,8 @@ 'use client' import {Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog' import {Button} from '@/components/ui/button' -import balance from './_assets/balance.svg' import Image from 'next/image' +import imgBalance from './_assets/balance.svg' import {useState} from 'react' import {useProfileStore} from '@/components/stores/profile' import {Alert, AlertTitle} from '@/components/ui/alert' @@ -105,11 +105,11 @@ export default function Pay(props: PayProps) { } } - const balanceEnough = balance >= Number(props.amount) + const balanceEnough = props.method == 'balance' && props.balance >= Number(props.amount) return ( - <> - @@ -119,7 +119,7 @@ export default function Pay(props: PayProps) { - 余额 + 余额 余额支付 @@ -129,7 +129,7 @@ export default function Pay(props: PayProps) {
账户余额 - {balance} 元 + {props.balance} 元
@@ -142,7 +142,7 @@ export default function Pay(props: PayProps) {
支付后余额 - {(balance - Number(props.amount)).toFixed(2)} 元 + {(props.balance - Number(props.amount)).toFixed(2)} 元
@@ -190,6 +190,6 @@ export default function Pay(props: PayProps) { }} /> )} - + ) } diff --git a/src/components/composites/user-center/index.tsx b/src/components/composites/user-center/index.tsx index 717ba93..70282a7 100644 --- a/src/components/composites/user-center/index.tsx +++ b/src/components/composites/user-center/index.tsx @@ -16,7 +16,7 @@ export default function UserCenter(props: { const refreshProfile = useProfileStore(store => store.refreshProfile) const isAdminPage = pathname.startsWith('/admin') // 判断是否在后台页面 - const displayedName = !isAdminPage + const displayedName = isAdminPage ? props.profile.username || props.profile.phone.substring(0, 3) + '****' + props.profile.phone.substring(7) || '用户' : '进入控制台' diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 7424d7e..5b0d67b 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,8 +1,10 @@ import {ReactNode} from 'react' import {ClassNameValue, twMerge} from 'tailwind-merge' +// 通用组件类型 +export type Children = {children: ReactNode} + +// 合并 className export function merge(...inputs: ClassNameValue[]) { return twMerge(inputs) } - -export type Children = {children: ReactNode}