优化登录流程,添加白名单管理功能,调整页面布局与样式
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
@@ -32,16 +33,16 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-config-next": "15.2.1",
|
||||
"@next/eslint-plugin-next": "^15.2.1",
|
||||
"@stylistic/eslint-plugin": "^4.2.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
},
|
||||
|
||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3(react-hook-form@7.54.2(react@19.0.0))
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@@ -389,6 +392,19 @@ packages:
|
||||
'@radix-ui/primitive@1.1.1':
|
||||
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.6':
|
||||
resolution: {integrity: sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-arrow@1.1.2':
|
||||
resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==}
|
||||
peerDependencies:
|
||||
@@ -2488,6 +2504,20 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.1': {}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.1
|
||||
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0)
|
||||
'@radix-ui/react-dialog': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@radix-ui/react-slot': 1.1.2(@types/react@19.0.10)(react@19.0.0)
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.0.10
|
||||
'@types/react-dom': 19.0.4(@types/react@19.0.10)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
}
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -10,13 +10,12 @@ export interface LoginParams {
|
||||
}
|
||||
|
||||
type LoginResp = {
|
||||
token: string
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires: number
|
||||
auth: AuthContext
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function login(props: LoginParams): Promise<ApiResponse> {
|
||||
try {
|
||||
// 尝试登录
|
||||
@@ -35,13 +34,20 @@ export async function login(props: LoginParams): Promise<ApiResponse> {
|
||||
const future = data.expires - current
|
||||
|
||||
// 保存到 cookies
|
||||
console.log("token!!!!", data)
|
||||
const cookieStore = await cookies()
|
||||
cookieStore.set('auth_token', data.token, {
|
||||
cookieStore.set('auth_token', data.access_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: Math.max(future, 0),
|
||||
})
|
||||
cookieStore.set('auth_refresh', data.refresh_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600,
|
||||
})
|
||||
cookieStore.set('auth_info', JSON.stringify(data.auth), {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
|
||||
48
src/actions/whitelist.ts
Normal file
48
src/actions/whitelist.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
'use server'
|
||||
import {callWithUserToken, PageRecord} from '@/lib/api'
|
||||
|
||||
type Whitelist = {
|
||||
id: number
|
||||
host: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
async function listWhitelist(props: {
|
||||
page: number
|
||||
size: number
|
||||
}) {
|
||||
return await callWithUserToken<PageRecord<Whitelist>>('/api/whitelist/list', props)
|
||||
}
|
||||
|
||||
async function createWhitelist(props: {
|
||||
host: string
|
||||
remark?: string
|
||||
}) {
|
||||
console.log(props)
|
||||
return await callWithUserToken('/api/whitelist/create', props)
|
||||
}
|
||||
|
||||
async function updateWhitelist(props: {
|
||||
id: number
|
||||
host?: string
|
||||
remark?: string
|
||||
}) {
|
||||
console.log(props)
|
||||
return await callWithUserToken('/api/whitelist/update', props)
|
||||
}
|
||||
|
||||
async function removeWhitelist(props: {
|
||||
id: number
|
||||
}[]) {
|
||||
return await callWithUserToken('/api/whitelist/remove', props)
|
||||
}
|
||||
|
||||
export {
|
||||
listWhitelist,
|
||||
createWhitelist,
|
||||
updateWhitelist,
|
||||
removeWhitelist,
|
||||
type Whitelist,
|
||||
}
|
||||
@@ -33,9 +33,11 @@ export default async function UserCenter(props: UserCenterProps) {
|
||||
</Link>
|
||||
</>
|
||||
: <>
|
||||
<Button>
|
||||
进入控制台
|
||||
</Button>
|
||||
<Link href={`/admin/dashboard`}>
|
||||
<Button variant={`gradient`}>
|
||||
进入控制台
|
||||
</Button>
|
||||
</Link>
|
||||
{/* profile */}
|
||||
<div>
|
||||
<img
|
||||
|
||||
15
src/app/admin/_server/profile.tsx
Normal file
15
src/app/admin/_server/profile.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import {ReactNode} from 'react'
|
||||
import {cookies} from 'next/headers'
|
||||
|
||||
export type ProfileProps = {}
|
||||
|
||||
export default async function Profile(props: ProfileProps) {
|
||||
const store = await cookies()
|
||||
const info = store.get('auth_info')?.value
|
||||
const data = info ? JSON.parse(info) : undefined
|
||||
return (
|
||||
<div>
|
||||
下午好,{data?.payload.name}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/app/admin/dashboard/page.tsx
Normal file
41
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
export type DashboardPageProps = {}
|
||||
|
||||
export default async function DashboardPage(props: DashboardPageProps) {
|
||||
|
||||
return (
|
||||
<main className={`flex-auto overflow-hidden grid grid-cols-4 grid-rows-4 p-4 pt-0 gap-4`}>
|
||||
{/* banner */}
|
||||
<section className={`col-start-1 row-start-1 col-span-3 bg-red-200`}>
|
||||
|
||||
</section>
|
||||
|
||||
{/* 短效 */}
|
||||
<section className={`col-start-1 row-start-2 bg-red-200`}>
|
||||
|
||||
</section>
|
||||
{/* 长效 */}
|
||||
<section className={`col-start-2 row-start-2 bg-red-200`}>
|
||||
|
||||
</section>
|
||||
{/* 固定 */}
|
||||
<section className={`col-start-3 row-start-2 bg-red-200`}>
|
||||
|
||||
</section>
|
||||
|
||||
{/* 图表 */}
|
||||
<section className={`col-start-1 row-start-3 col-span-3 row-span-2 bg-red-200`}>
|
||||
|
||||
</section>
|
||||
|
||||
{/* 信息 */}
|
||||
<section className={`col-start-4 row-start-1 row-span-2 bg-red-200`}>
|
||||
|
||||
</section>
|
||||
|
||||
{/* 通知 */}
|
||||
<section className={`col-start-4 row-start-3 row-span-2 bg-red-200`}>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
92
src/app/admin/layout.tsx
Normal file
92
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import {ReactNode} from 'react'
|
||||
import Image from 'next/image'
|
||||
import logo from '@/assets/logo.webp'
|
||||
import Profile from '@/app/admin/_server/profile'
|
||||
import Wrap from '@/components/wrap'
|
||||
import {merge} from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type DashboardLayoutProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default async function DashboardLayout(props: DashboardLayoutProps) {
|
||||
return (
|
||||
<div className={`h-screen flex flex-col overflow-hidden`}>
|
||||
{/* background */}
|
||||
<div className={`-z-10 relative`}>
|
||||
<div className={`absolute w-screen h-screen bg-gray-50`}></div>
|
||||
<div className={`absolute w-[2000px] h-[2000px] -left-[1000px] -top-[1000px] bg-radial from-blue-50 from-10% to-transparent to-50%`}></div>
|
||||
<div className={`absolute w-[2000px] h-[2000px] -right-[1000px] -top-[1000px] bg-radial from-blue-50 from-10% to-transparent to-50%`}></div>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<header className={`flex-none basis-20 flex items-stretch`}>
|
||||
{/* logo */}
|
||||
<div className={`flex-none basis-60 flex items-center justify-center`}>
|
||||
<Image src={logo} alt={`logo`} height={40}/>
|
||||
</div>
|
||||
|
||||
{/* title */}
|
||||
<div className={`flex-auto overflow-hidden flex items-center`}>
|
||||
欢迎来到,蓝狐代理
|
||||
</div>
|
||||
|
||||
{/* profile */}
|
||||
<div className={`flex-none basis-80 flex items-center justify-end`}>
|
||||
<Profile/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={`flex-auto overflow-hidden flex items-stretch gap-4`}>
|
||||
<nav className={merge(
|
||||
`flex-none basis-60 rounded-tr-xl bg-white p-4`,
|
||||
`flex flex-col overflow-auto`,
|
||||
)}>
|
||||
<NavItem href={'/admin/dashboard'} icon={`🏠`} label={`账户总览`}/>
|
||||
<NavTitle label={`套餐管理`}/>
|
||||
<NavItem href={`/admin/package`} icon={`🛒`} label={`购买套餐`}/>
|
||||
<NavItem href={`/admin/package/my`} icon={`📦`} label={`我的套餐`}/>
|
||||
<NavTitle label={`IP 管理`}/>
|
||||
<NavItem href={`/admin/ip/extract`} icon={`📤`} label={`IP提取`}/>
|
||||
<NavItem href={`/admin/ip/extract/record`} icon={`📜`} label={`IP提取记录`}/>
|
||||
<NavItem href={`/admin/ip/view`} icon={`👁️`} label={`查看提取IP`}/>
|
||||
<NavItem href={`/admin/ip/view/used`} icon={`🗂️`} label={`查看使用IP`}/>
|
||||
<NavTitle label={`个人信息`}/>
|
||||
<NavItem href={`/admin/profile`} icon={`📝`} label={`个人信息修改`}/>
|
||||
<NavItem href={`/admin/bill`} icon={`💰`} label={`我的账单`}/>
|
||||
<NavItem href={`/admin/identify`} icon={`🆔`} label={`实名认证`}/>
|
||||
<NavItem href={`/admin/whitelist`} icon={`🔒`} label={`白名单`}/>
|
||||
</nav>
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavTitle(props: {
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<p className={`px-4 py-2 text-sm text-gray-500`}>
|
||||
{props.label}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function NavItem(props: {
|
||||
href: string
|
||||
icon?: ReactNode
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<Link className={merge(
|
||||
`px-4 py-2 flex items-center rounded-md`,
|
||||
`hover:bg-gray-100`,
|
||||
)} href={props.href}>
|
||||
{props.icon}
|
||||
<span className={`ml-2`}>{props.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
383
src/app/admin/whitelist/page.tsx
Normal file
383
src/app/admin/whitelist/page.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
'use client'
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table'
|
||||
import {createWhitelist, removeWhitelist, listWhitelist, Whitelist, updateWhitelist} from '@/actions/whitelist'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {PageRecord} from '@/lib/api'
|
||||
import {useStatus} from '@/lib/states'
|
||||
import {toast} from 'sonner'
|
||||
import {Form, FormField} from '@/components/ui/form'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Textarea} from '@/components/ui/textarea'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {z} from 'zod'
|
||||
import {Edit, Plus, Trash2, Loader2} from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {Pagination} from '@/components/ui/pagination'
|
||||
import {Checkbox} from '@/components/ui/checkbox'
|
||||
|
||||
const schema = z.object({
|
||||
host: z.string().min(1, {message: 'IP地址不能为空'}),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
type SchemaType = z.infer<typeof schema>
|
||||
|
||||
export type WhitelistPageProps = {}
|
||||
|
||||
export default function WhitelistPage(props: WhitelistPageProps) {
|
||||
const [wait, setWait] = useState(false)
|
||||
|
||||
// ======================
|
||||
// 数据
|
||||
// ======================
|
||||
|
||||
const [status, setStatus] = useStatus()
|
||||
const [data, setData] = useState<PageRecord<Whitelist>>({
|
||||
page: 1,
|
||||
size: 10,
|
||||
total: 0,
|
||||
list: [],
|
||||
})
|
||||
|
||||
const refresh = async (page: number, size: number) => {
|
||||
setStatus('load')
|
||||
setWait(true)
|
||||
try {
|
||||
const resp = await listWhitelist({page, size})
|
||||
if (resp && resp.success) {
|
||||
setStatus('done')
|
||||
setData(resp.data)
|
||||
}
|
||||
else {
|
||||
setStatus('fail')
|
||||
toast.error('加载数据失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
setStatus('fail')
|
||||
toast.error('加载数据失败')
|
||||
}
|
||||
finally {
|
||||
setWait(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 处理分页
|
||||
const changePage = (newPage: number) => {
|
||||
refresh(newPage, data.size).then()
|
||||
}
|
||||
|
||||
// 处理每页数量变化
|
||||
const changeSize = (newSize: number) => {
|
||||
refresh(1, newSize).then()
|
||||
}
|
||||
|
||||
// ======================
|
||||
// 弹窗
|
||||
// ======================
|
||||
|
||||
const [dialogVisible, setDialogVisible] = useState(false)
|
||||
const [dialogType, setDialogType] = useState<'add' | 'edit'>('add')
|
||||
const [dialogData, setDialogData] = useState<Whitelist>()
|
||||
|
||||
const openDialog = (type: 'add' | 'edit', data?: Whitelist) => {
|
||||
form.reset({
|
||||
host: data?.host || '',
|
||||
remark: data?.remark || '',
|
||||
})
|
||||
setDialogVisible(true)
|
||||
setDialogType(type)
|
||||
setDialogData(data)
|
||||
}
|
||||
|
||||
const toggleDialog = (open: boolean) => {
|
||||
setDialogVisible(open)
|
||||
if (!open) {
|
||||
form.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const [alertVisible, setAlertVisible] = useState(false)
|
||||
const removeId = useRef<number | undefined>(undefined)
|
||||
const confirmRemove = (id?: number) => {
|
||||
setAlertVisible(true)
|
||||
if (id) {
|
||||
removeId.current = id
|
||||
}
|
||||
else {
|
||||
removeId.current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
setWait(true)
|
||||
try {
|
||||
const ids = removeId.current === undefined
|
||||
? Array.from(selection).map(id => ({id}))
|
||||
: [{id: removeId.current}]
|
||||
const resp = await removeWhitelist(ids)
|
||||
if (resp && resp.success) {
|
||||
setAlertVisible(false)
|
||||
toast.success('删除成功')
|
||||
await refresh(data.page, data.size)
|
||||
}
|
||||
else {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error('删除失败')
|
||||
}
|
||||
finally {
|
||||
setWait(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [selection, setSelection] = useState(new Set<number>())
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
if (selection.has(id)) {
|
||||
selection.delete(id)
|
||||
}
|
||||
else {
|
||||
selection.add(id)
|
||||
}
|
||||
setSelection(new Set(selection))
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selection.size === data.list.length) {
|
||||
setSelection(new Set())
|
||||
}
|
||||
else {
|
||||
const newSelection = new Set<number>()
|
||||
data.list.forEach(item => {
|
||||
newSelection.add(item.id)
|
||||
})
|
||||
setSelection(newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// 表单
|
||||
// ======================
|
||||
|
||||
const form = useForm<SchemaType>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
host: '',
|
||||
remark: '',
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (value: SchemaType) => {
|
||||
setWait(true)
|
||||
try {
|
||||
if (dialogType === 'add') {
|
||||
// 添加白名单
|
||||
const resp = await createWhitelist(value)
|
||||
if (resp && resp.success) {
|
||||
await refresh(data.page, data.size)
|
||||
toggleDialog(false)
|
||||
toast.success('添加成功')
|
||||
}
|
||||
else {
|
||||
toast.error('添加失败')
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 编辑白名单
|
||||
if (!dialogData) {
|
||||
toast.error('编辑失败')
|
||||
return
|
||||
}
|
||||
const resp = await updateWhitelist({...value, id: dialogData.id})
|
||||
if (resp && resp.success) {
|
||||
await refresh(data.page, data.size)
|
||||
toggleDialog(false)
|
||||
toast.success('编辑成功')
|
||||
}
|
||||
else {
|
||||
toast.error('编辑失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(dialogType === 'add' ? '添加失败' : '编辑失败')
|
||||
}
|
||||
finally {
|
||||
setWait(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// region render
|
||||
// ======================
|
||||
|
||||
useEffect(() => {
|
||||
refresh(1, 10).then()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
if (status === 'fail') {
|
||||
return <div className="flex items-center justify-center h-full">加载失败,请重试</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`flex-auto bg-white rounded-tl-lg p-4 flex flex-col gap-4`}>
|
||||
|
||||
{/* 全局操作 */}
|
||||
<section>
|
||||
<Button onClick={() => openDialog('add')} disabled={wait}>
|
||||
<Plus/>
|
||||
添加白名单
|
||||
</Button>
|
||||
<Button
|
||||
variant={`danger`}
|
||||
className={`ml-2`}
|
||||
disabled={selection.size === 0 || wait}
|
||||
onClick={() => confirmRemove()}>
|
||||
<Trash2/>
|
||||
删除选中
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
{/* 数据表 */}
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className={`w-12 text-center px-0`}>
|
||||
<Checkbox checked={selection.size === data.list.length} onClick={() => toggleSelectAll()}/>
|
||||
</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>添加时间</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{wait && data.list.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto"/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.list.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.list.map(item => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className={`w-12 text-center px-0`}>
|
||||
<Checkbox checked={selection.has(item.id)} onClick={() => toggleSelect(item.id)}/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{item.host}</TableCell>
|
||||
<TableCell>{item.createdAt}</TableCell>
|
||||
<TableCell>{item.remark || '无'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
className={`h-9 w-9`}
|
||||
variant="outline"
|
||||
onClick={() => openDialog('edit', item)}
|
||||
disabled={wait}
|
||||
>
|
||||
<Edit className="w-4 h-4"/>
|
||||
</Button>
|
||||
<Button
|
||||
className={`h-9 w-9`}
|
||||
onClick={() => confirmRemove(item.id)}
|
||||
variant={`danger`}
|
||||
disabled={wait}
|
||||
>
|
||||
<Trash2 className="w-4 h-4"/>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分页器 */}
|
||||
{data.total > 0 && (
|
||||
<Pagination
|
||||
page={data.page}
|
||||
size={data.size}
|
||||
total={data.total}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={changePage}
|
||||
onPageSizeChange={changeSize}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑表单 */}
|
||||
<Dialog open={dialogVisible} onOpenChange={toggleDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dialogType === 'add' ? '添加白名单' : '编辑白名单'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form<SchemaType>
|
||||
className={`flex flex-col gap-4 py-4`}
|
||||
form={form}
|
||||
onSubmit={onSubmit}>
|
||||
<FormField name={`host`} label={`IP地址`}>
|
||||
{({id, field}) => (
|
||||
<Input {...field} id={id} placeholder="输入IP地址"/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name={`remark`} label={`备注`}>
|
||||
{({id, field}) => (
|
||||
<Textarea {...field} id={id} placeholder="输入备注信息(可选)" disabled={wait}/>
|
||||
)}
|
||||
</FormField>
|
||||
<DialogFooter className={`gap-4 mt-4`}>
|
||||
<Button variant={`outline`} type="button" onClick={() => toggleDialog(false)} disabled={wait}>取消</Button>
|
||||
<Button type={`submit`} disabled={wait}>
|
||||
{wait && <Loader2 className="w-4 h-4 mr-2 animate-spin"/>}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除表单 */}
|
||||
<AlertDialog open={alertVisible} onOpenChange={setAlertVisible}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除白名单</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除该白名单吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<Button variant="outline" onClick={() => setAlertVisible(false)}>取消</Button>
|
||||
<Button variant="danger" onClick={() => remove()}>删除</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</main>
|
||||
)
|
||||
|
||||
// endregion
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
--card-foreground: oklch(0.13 0.028 261.692);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.13 0.028 261.692);
|
||||
--primary: oklch(0.21 0.034 264.665);
|
||||
--primary: oklch(0.64 0.1597 265);
|
||||
--primary-foreground: oklch(0.985 0.002 247.839);
|
||||
--secondary: oklch(0.967 0.003 264.542);
|
||||
--secondary-foreground: oklch(0.21 0.034 264.665);
|
||||
@@ -20,10 +20,11 @@
|
||||
--muted-foreground: oklch(0.551 0.027 264.364);
|
||||
--accent: oklch(0.967 0.003 264.542);
|
||||
--accent-foreground: oklch(0.21 0.034 264.665);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive: oklch(0.64 0.1597 25);
|
||||
--destructive-foreground: oklch(0.985 0.002 247.839);
|
||||
--border: oklch(0.928 0.006 264.531);
|
||||
--input: oklch(0.928 0.006 264.531);
|
||||
--ring: oklch(0.707 0.022 261.325);
|
||||
--ring: oklch(0.882 0.059 254.128);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
||||
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: ReactNode;
|
||||
children: ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-Cn">
|
||||
|
||||
156
src/components/ui/alert-dialog.tsx
Normal file
156
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||
|
||||
import {merge} from '@/lib/utils'
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={merge(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay/>
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={merge(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={merge('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={merge(
|
||||
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={merge('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={merge('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={merge(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={merge(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {Slot} from '@radix-ui/react-slot'
|
||||
import {merge} from '@/lib/utils'
|
||||
|
||||
type ButtonProps = React.ComponentProps<'button'> & {
|
||||
variant?: 'default' | 'outline' | 'gradient'
|
||||
variant?: 'default' | 'outline' | 'gradient' | 'danger'
|
||||
}
|
||||
|
||||
function Button(rawProps: ButtonProps) {
|
||||
@@ -23,6 +23,7 @@ function Button(rawProps: ButtonProps) {
|
||||
{
|
||||
gradient: 'bg-gradient-to-r from-blue-400 to-cyan-300 text-white ring-offset-2',
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
danger: 'bg-destructive text-white shadow-xs hover:bg-destructive/90',
|
||||
outline: 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
}[variant ?? 'default'],
|
||||
className,
|
||||
|
||||
@@ -13,7 +13,7 @@ function Input({className, type, ...props}: React.ComponentProps<'input'>) {
|
||||
' placeholder:text-muted-foreground',
|
||||
'selection:bg-primary selection:text-primary-foreground',
|
||||
'flex rounded-md border bg-transparent px-3 py-1 text-base shadow-xs',
|
||||
'outline-none focus-visible:ring-4 ring-blue-200',
|
||||
'outline-none focus-visible:ring-4 ring-ring/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40 dark:bg-input/30',
|
||||
'file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none',
|
||||
|
||||
284
src/components/ui/pagination.tsx
Normal file
284
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
import * as React from 'react'
|
||||
import {useState, useEffect} from 'react'
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {merge} from '@/lib/utils'
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './select'
|
||||
|
||||
export interface PaginationProps {
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pageSizeOptions?: number[]
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange?: (size: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Pagination({
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
pageSizeOptions = [10, 20, 50, 100],
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
className,
|
||||
}: PaginationProps) {
|
||||
const [currentPage, setCurrentPage] = useState(page)
|
||||
const totalPages = Math.ceil(total / size)
|
||||
|
||||
// 同步外部 page 变化
|
||||
useEffect(() => {
|
||||
setCurrentPage(page)
|
||||
}, [page])
|
||||
|
||||
// 分页器逻辑
|
||||
const generatePaginationItems = () => {
|
||||
// 最多显示7个页码,其余用省略号
|
||||
const SIBLINGS = 1 // 当前页左右各显示的页码数
|
||||
const DOTS = -1 // 省略号标记
|
||||
|
||||
if (totalPages <= 7) {
|
||||
// 总页数少于7,全部显示
|
||||
return Array.from({length: totalPages}, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
// 是否需要显示左边的省略号
|
||||
const showLeftDots = currentPage > 2 + SIBLINGS
|
||||
|
||||
// 是否需要显示右边的省略号
|
||||
const showRightDots = currentPage < totalPages - (2 + SIBLINGS)
|
||||
|
||||
if (showLeftDots && showRightDots) {
|
||||
// 两边都有省略号
|
||||
const leftSiblingIndex = Math.max(currentPage - SIBLINGS, 1)
|
||||
const rightSiblingIndex = Math.min(currentPage + SIBLINGS, totalPages)
|
||||
|
||||
return [1, DOTS, ...Array.from(
|
||||
{length: rightSiblingIndex - leftSiblingIndex + 1},
|
||||
(_, i) => leftSiblingIndex + i,
|
||||
), DOTS, totalPages]
|
||||
}
|
||||
|
||||
if (!showLeftDots && showRightDots) {
|
||||
// 只有右边有省略号
|
||||
return [...Array.from({length: 3 + SIBLINGS * 2}, (_, i) => i + 1), DOTS, totalPages]
|
||||
}
|
||||
|
||||
if (showLeftDots && !showRightDots) {
|
||||
// 只有左边有省略号
|
||||
return [1, DOTS, ...Array.from(
|
||||
{length: 3 + SIBLINGS * 2},
|
||||
(_, i) => totalPages - (3 + SIBLINGS * 2) + i + 1,
|
||||
)]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage < 1 || newPage > totalPages || newPage === currentPage) {
|
||||
return
|
||||
}
|
||||
setCurrentPage(newPage)
|
||||
onPageChange(newPage)
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (newSize: string) => {
|
||||
const parsedSize = parseInt(newSize, 10)
|
||||
if (onPageSizeChange) {
|
||||
onPageSizeChange(parsedSize)
|
||||
}
|
||||
}
|
||||
|
||||
const paginationItems = generatePaginationItems()
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-4 ${className || ''}`}>
|
||||
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
|
||||
共 {total} 条记录,每页
|
||||
<Select
|
||||
value={size.toString()}
|
||||
onValueChange={handlePageSizeChange}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
<SelectValue/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map(option => (
|
||||
<SelectItem key={option} value={option.toString()}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
条
|
||||
</div>
|
||||
|
||||
<PaginationLayout>
|
||||
<PaginationContent>
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
className={currentPage === 1 ? 'opacity-50 pointer-events-none' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{paginationItems.map((pageNum, index) => {
|
||||
if (pageNum === -1) {
|
||||
return (
|
||||
<PaginationItem key={`dots-${index}`}>
|
||||
<PaginationEllipsis/>
|
||||
</PaginationItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PaginationItem key={pageNum}>
|
||||
<PaginationLink
|
||||
isActive={pageNum === currentPage}
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
})}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
className={currentPage === totalPages ? 'opacity-50 pointer-events-none' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
</PaginationContent>
|
||||
</PaginationLayout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationLayout({className, ...props}: React.ComponentProps<'nav'>) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={merge('flex-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'ul'>) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={merge('flex flex-row items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({...props}: React.ComponentProps<'li'>) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & React.ComponentProps<'a'>
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={merge(
|
||||
'inline-flex items-center justify-center text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 w-9 rounded-md border border-input hover:bg-accent hover:text-accent-foreground',
|
||||
isActive && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
className={merge('gap-1 px-2.5 sm:pl-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon/>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
className={merge('gap-1 px-2.5 sm:pr-2.5', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronRightIcon/>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={merge('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4"/>
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationLayout,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { merge } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={merge("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={merge("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={merge("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={merge(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={merge(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={merge(
|
||||
"text-gray-600 h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={merge(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={merge("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { merge } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={merge(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
245
src/lib/api.ts
245
src/lib/api.ts
@@ -1,6 +1,9 @@
|
||||
// API工具函数
|
||||
|
||||
// 定义后端服务URL和OAuth2配置
|
||||
import {cookies} from 'next/headers'
|
||||
import {redirect} from 'next/navigation'
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL
|
||||
const CLIENT_ID = process.env.CLIENT_ID
|
||||
const CLIENT_SECRET = process.env.CLIENT_SECRET
|
||||
@@ -11,6 +14,10 @@ interface TokenCache {
|
||||
expires: number // 过期时间戳
|
||||
}
|
||||
|
||||
// ======================
|
||||
// region device token
|
||||
// ======================
|
||||
|
||||
let tokenCache: TokenCache | null = null
|
||||
|
||||
// 获取OAuth2访问令牌
|
||||
@@ -21,7 +28,7 @@ export async function getAccessToken(forceRefresh = false): Promise<string> {
|
||||
return tokenCache.token
|
||||
}
|
||||
|
||||
const addr = `http://${API_BASE_URL}/api/auth/token`
|
||||
const addr = `${API_BASE_URL}/api/auth/token`
|
||||
const body = {
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
@@ -70,7 +77,7 @@ export async function call<R = undefined>(endpoint: string, data: unknown): Prom
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
let response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
let response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
// 如果返回401未授权,尝试刷新令牌并重试一次
|
||||
if (response.status === 401) {
|
||||
@@ -78,44 +85,21 @@ export async function call<R = undefined>(endpoint: string, data: unknown): Prom
|
||||
|
||||
// 使用新令牌重试请求
|
||||
requestOptions.headers['Authorization'] = `Bearer ${await accessToken}`
|
||||
response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
}
|
||||
|
||||
// 解析响应数据
|
||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||
if (type.indexOf('application/json') !== -1) {
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, json)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || '请求失败',
|
||||
}
|
||||
}
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, await response.text())
|
||||
return {
|
||||
success: true,
|
||||
data: json,
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: '请求失败',
|
||||
}
|
||||
}
|
||||
else if (type.indexOf('text/plain') !== -1) {
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, text)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: text || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as unknown as R, // 强转类型,考虑优化
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
return handleResponse(response)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('API call failed:', e)
|
||||
@@ -123,82 +107,169 @@ export async function call<R = undefined>(endpoint: string, data: unknown): Prom
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// endregion
|
||||
|
||||
// ======================
|
||||
// region user token
|
||||
// ======================
|
||||
|
||||
async function getUserToken(refresh = false): Promise<string> {
|
||||
// 从 cookie 中获取用户令牌
|
||||
const cookie = await cookies()
|
||||
const userToken = cookie.get('auth_token')?.value
|
||||
const userRefresh = cookie.get('auth_refresh')?.value
|
||||
|
||||
// 检查缓存的令牌是否可用
|
||||
if (!refresh && userToken) {
|
||||
return userToken
|
||||
}
|
||||
|
||||
// 如果没有刷新令牌,抛出异常
|
||||
if (!userRefresh) {
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
// 请求刷新访问令牌
|
||||
const addr = `${API_BASE_URL}/api/auth/token`
|
||||
const body = {
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
refresh_token: userRefresh,
|
||||
}
|
||||
|
||||
const response = await fetch(addr, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.log('刷新令牌失败', `status=${response.status}`, await response.text())
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
// 保存新的用户令牌到 cookie
|
||||
const data = await response.json()
|
||||
const nextAccessToken = data.access_token
|
||||
const nextRefreshToken = data.refresh_token
|
||||
const expiresIn = data.expires_in
|
||||
|
||||
cookie.set('auth_token', nextAccessToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: Math.max(expiresIn, 0),
|
||||
})
|
||||
cookie.set('auth_refresh', nextRefreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 7 * 24 * 3600, // 7天
|
||||
})
|
||||
|
||||
return nextAccessToken
|
||||
}
|
||||
|
||||
// 使用用户令牌的API调用函数
|
||||
export async function callWithUserToken<R = undefined>(
|
||||
endpoint: string,
|
||||
data: unknown,
|
||||
userToken: string,
|
||||
onTokenExpired?: () => void
|
||||
endpoint: string,
|
||||
data: unknown,
|
||||
): Promise<ApiResponse<R>> {
|
||||
try {
|
||||
let token = await getUserToken()
|
||||
|
||||
// 发送请求
|
||||
let response: Response
|
||||
const requestOptions = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${userToken}`,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
|
||||
const response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
// 如果返回401未授权,可能是用户令牌过期
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
if (response.status === 401) {
|
||||
// 通知调用者令牌已过期,需要刷新或重新登录
|
||||
if (onTokenExpired) {
|
||||
onTokenExpired()
|
||||
token = await getUserToken(true)
|
||||
requestOptions.headers['Authorization'] = `Bearer ${token}`
|
||||
response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw UnauthorizedError
|
||||
}
|
||||
|
||||
|
||||
console.log('响应不成功', `status=${response.status}`, await response.text())
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: '用户会话已过期,请重新登录',
|
||||
message: '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
// 解析响应数据
|
||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||
if (type.indexOf('application/json') !== -1) {
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, json)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: json,
|
||||
}
|
||||
}
|
||||
else if (type.indexOf('text/plain') !== -1) {
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, text)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: text || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as unknown as R,
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||
}
|
||||
return handleResponse(response)
|
||||
}
|
||||
catch (e) {
|
||||
// 重定向到登录页面
|
||||
if (e === UnauthorizedError) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
console.error('API call with user token failed:', e)
|
||||
throw new Error('服务调用失败', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// 统一响应解析
|
||||
async function handleResponse<R = undefined>(response: Response): Promise<ApiResponse<R>> {
|
||||
|
||||
// 解析响应数据
|
||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||
if (type.indexOf('application/json') !== -1) {
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, json)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: json,
|
||||
}
|
||||
}
|
||||
else if (type.indexOf('text/plain') !== -1) {
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
console.log('响应不成功', `status=${response.status}`, text)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: text || '请求失败',
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as unknown as R, // 强转类型,考虑优化
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的API响应类型
|
||||
export type ApiResponse<T = undefined> = {
|
||||
success: false
|
||||
@@ -209,3 +280,11 @@ export type ApiResponse<T = undefined> = {
|
||||
data: T
|
||||
}
|
||||
|
||||
export type PageRecord<T = unknown> = {
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
list: T[]
|
||||
}
|
||||
|
||||
export const UnauthorizedError = new Error('未授权访问')
|
||||
|
||||
6
src/lib/states.ts
Normal file
6
src/lib/states.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
'use client'
|
||||
import {useState} from 'react'
|
||||
|
||||
export function useStatus() {
|
||||
return useState<'done'|'fail'|'load'>('load')
|
||||
}
|
||||
Reference in New Issue
Block a user