优化登录流程,添加白名单管理功能,调整页面布局与样式

This commit is contained in:
2025-04-07 15:42:09 +08:00
parent a16faadaab
commit a2c18a1be8
21 changed files with 1388 additions and 102 deletions

View File

@@ -1,3 +1,10 @@
## TODO ## TODO
保存客户端信息时用 jwt 序列化 保存客户端信息时用 jwt 序列化
---
页面数据:
- [ ] dashboard

View File

@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^4.1.3", "@hookform/resolvers": "^4.1.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
@@ -32,16 +33,16 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^9",
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-config-next": "15.2.1",
"@next/eslint-plugin-next": "^15.2.1", "@next/eslint-plugin-next": "^15.2.1",
"@stylistic/eslint-plugin": "^4.2.0", "@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.1",
"eslint-plugin-react-hooks": "^5.2.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
}, },

30
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^4.1.3 specifier: ^4.1.3
version: 4.1.3(react-hook-form@7.54.2(react@19.0.0)) 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': '@radix-ui/react-checkbox':
specifier: ^1.1.4 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) 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': '@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} 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': '@radix-ui/react-arrow@1.1.2':
resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==} resolution: {integrity: sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg==}
peerDependencies: peerDependencies:
@@ -2488,6 +2504,20 @@ snapshots:
'@radix-ui/primitive@1.1.1': {} '@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)': '@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: 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) '@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)

View File

@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ['@tailwindcss/postcss'],
}; }
export default config; export default config

View File

@@ -10,13 +10,12 @@ export interface LoginParams {
} }
type LoginResp = { type LoginResp = {
token: string access_token: string
refresh_token: string
expires: number expires: number
auth: AuthContext auth: AuthContext
} }
export async function login(props: LoginParams): Promise<ApiResponse> { export async function login(props: LoginParams): Promise<ApiResponse> {
try { try {
// 尝试登录 // 尝试登录
@@ -35,13 +34,20 @@ export async function login(props: LoginParams): Promise<ApiResponse> {
const future = data.expires - current const future = data.expires - current
// 保存到 cookies // 保存到 cookies
console.log("token!!!!", data)
const cookieStore = await cookies() const cookieStore = await cookies()
cookieStore.set('auth_token', data.token, { cookieStore.set('auth_token', data.access_token, {
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
maxAge: Math.max(future, 0), 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), { cookieStore.set('auth_info', JSON.stringify(data.auth), {
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',

48
src/actions/whitelist.ts Normal file
View 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,
}

View File

@@ -33,9 +33,11 @@ export default async function UserCenter(props: UserCenterProps) {
</Link> </Link>
</> </>
: <> : <>
<Button> <Link href={`/admin/dashboard`}>
<Button variant={`gradient`}>
</Button>
</Button>
</Link>
{/* profile */} {/* profile */}
<div> <div>
<img <img

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

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

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

View File

@@ -12,7 +12,7 @@
--card-foreground: oklch(0.13 0.028 261.692); --card-foreground: oklch(0.13 0.028 261.692);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.028 261.692); --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); --primary-foreground: oklch(0.985 0.002 247.839);
--secondary: oklch(0.967 0.003 264.542); --secondary: oklch(0.967 0.003 264.542);
--secondary-foreground: oklch(0.21 0.034 264.665); --secondary-foreground: oklch(0.21 0.034 264.665);
@@ -20,10 +20,11 @@
--muted-foreground: oklch(0.551 0.027 264.364); --muted-foreground: oklch(0.551 0.027 264.364);
--accent: oklch(0.967 0.003 264.542); --accent: oklch(0.967 0.003 264.542);
--accent-foreground: oklch(0.21 0.034 264.665); --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); --border: oklch(0.928 0.006 264.531);
--input: 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-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704); --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392); --chart-3: oklch(0.398 0.07 227.392);

View File

@@ -16,7 +16,7 @@ export const metadata: Metadata = {
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: ReactNode; children: ReactNode
}>) { }>) {
return ( return (
<html lang="zh-Cn"> <html lang="zh-Cn">

View 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,
}

View File

@@ -3,7 +3,7 @@ import {Slot} from '@radix-ui/react-slot'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
type ButtonProps = React.ComponentProps<'button'> & { type ButtonProps = React.ComponentProps<'button'> & {
variant?: 'default' | 'outline' | 'gradient' variant?: 'default' | 'outline' | 'gradient' | 'danger'
} }
function Button(rawProps: ButtonProps) { 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', 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', 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', 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'], }[variant ?? 'default'],
className, className,

View File

@@ -13,7 +13,7 @@ function Input({className, type, ...props}: React.ComponentProps<'input'>) {
' placeholder:text-muted-foreground', ' placeholder:text-muted-foreground',
'selection:bg-primary selection:text-primary-foreground', 'selection:bg-primary selection:text-primary-foreground',
'flex rounded-md border bg-transparent px-3 py-1 text-base shadow-xs', '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', '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', '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', 'file:text-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:disabled:pointer-events-none',

View 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
View 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,
}

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

View File

@@ -1,6 +1,9 @@
// API工具函数 // API工具函数
// 定义后端服务URL和OAuth2配置 // 定义后端服务URL和OAuth2配置
import {cookies} from 'next/headers'
import {redirect} from 'next/navigation'
const API_BASE_URL = process.env.API_BASE_URL const API_BASE_URL = process.env.API_BASE_URL
const CLIENT_ID = process.env.CLIENT_ID const CLIENT_ID = process.env.CLIENT_ID
const CLIENT_SECRET = process.env.CLIENT_SECRET const CLIENT_SECRET = process.env.CLIENT_SECRET
@@ -11,6 +14,10 @@ interface TokenCache {
expires: number // 过期时间戳 expires: number // 过期时间戳
} }
// ======================
// region device token
// ======================
let tokenCache: TokenCache | null = null let tokenCache: TokenCache | null = null
// 获取OAuth2访问令牌 // 获取OAuth2访问令牌
@@ -21,7 +28,7 @@ export async function getAccessToken(forceRefresh = false): Promise<string> {
return tokenCache.token return tokenCache.token
} }
const addr = `http://${API_BASE_URL}/api/auth/token` const addr = `${API_BASE_URL}/api/auth/token`
const body = { const body = {
client_id: CLIENT_ID, client_id: CLIENT_ID,
client_secret: CLIENT_SECRET, client_secret: CLIENT_SECRET,
@@ -70,7 +77,7 @@ export async function call<R = undefined>(endpoint: string, data: unknown): Prom
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
} }
let response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions) let response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
// 如果返回401未授权尝试刷新令牌并重试一次 // 如果返回401未授权尝试刷新令牌并重试一次
if (response.status === 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}` 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 (!response.ok) {
if (type.indexOf('application/json') !== -1) { console.log('响应不成功', `status=${response.status}`, await response.text())
const json = await response.json()
if (!response.ok) {
console.log('响应不成功', `status=${response.status}`, json)
return {
success: false,
status: response.status,
message: json.message || '请求失败',
}
}
return { return {
success: true, success: false,
data: json, status: response.status,
message: '请求失败',
} }
} }
else if (type.indexOf('text/plain') !== -1) {
const text = await response.text() // 检查响应状态
if (!response.ok) { return handleResponse(response)
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}`)
}
} }
catch (e) { catch (e) {
console.error('API call failed:', 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调用函数 // 使用用户令牌的API调用函数
export async function callWithUserToken<R = undefined>( export async function callWithUserToken<R = undefined>(
endpoint: string, endpoint: string,
data: unknown, data: unknown,
userToken: string,
onTokenExpired?: () => void
): Promise<ApiResponse<R>> { ): Promise<ApiResponse<R>> {
try { try {
let token = await getUserToken()
// 发送请求 // 发送请求
let response: Response
const requestOptions = { const requestOptions = {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${userToken}`, 'Authorization': `Bearer ${token}`,
}, },
body: JSON.stringify(data), 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 (response.status === 401) {
// 通知调用者令牌已过期,需要刷新或重新登录 token = await getUserToken(true)
if (onTokenExpired) { requestOptions.headers['Authorization'] = `Bearer ${token}`
onTokenExpired() 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 { return {
success: false, success: false,
status: response.status, status: response.status,
message: '用户会话已过期,请重新登录', message: '请求失败',
} }
} }
// 解析响应数据 return handleResponse(response)
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}`)
}
} }
catch (e) { catch (e) {
// 重定向到登录页面
if (e === UnauthorizedError) {
redirect('/login')
}
console.error('API call with user token failed:', e) console.error('API call with user token failed:', e)
throw new Error('服务调用失败', {cause: 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响应类型 // 统一的API响应类型
export type ApiResponse<T = undefined> = { export type ApiResponse<T = undefined> = {
success: false success: false
@@ -209,3 +280,11 @@ export type ApiResponse<T = undefined> = {
data: T 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
View File

@@ -0,0 +1,6 @@
'use client'
import {useState} from 'react'
export function useStatus() {
return useState<'done'|'fail'|'load'>('load')
}