diff --git a/README.md b/README.md index 17f3fed..d1b5073 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,10 @@ ## TODO 保存客户端信息时用 jwt 序列化 + +--- + +页面数据: + - [ ] dashboard + + diff --git a/package.json b/package.json index 1803ac8..9549a24 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0825811..2b0041e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/postcss.config.mjs b/postcss.config.mjs index c7bcb4b..86e8e3c 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ const config = { - plugins: ["@tailwindcss/postcss"], -}; + plugins: ['@tailwindcss/postcss'], +} -export default config; +export default config diff --git a/src/actions/auth/login.ts b/src/actions/auth/login.ts index e44b7f1..4e5f414 100644 --- a/src/actions/auth/login.ts +++ b/src/actions/auth/login.ts @@ -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 { try { // 尝试登录 @@ -35,13 +34,20 @@ export async function login(props: LoginParams): Promise { 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', diff --git a/src/actions/whitelist.ts b/src/actions/whitelist.ts new file mode 100644 index 0000000..f8c4972 --- /dev/null +++ b/src/actions/whitelist.ts @@ -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>('/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, +} diff --git a/src/app/(root)/@header/_server/user-center.tsx b/src/app/(root)/@header/_server/user-center.tsx index a12b859..6ab1e65 100644 --- a/src/app/(root)/@header/_server/user-center.tsx +++ b/src/app/(root)/@header/_server/user-center.tsx @@ -33,9 +33,11 @@ export default async function UserCenter(props: UserCenterProps) { : <> - + + + {/* profile */}
+ 下午好,{data?.payload.name} +
+ ) +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..e32f7ff --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -0,0 +1,41 @@ +export type DashboardPageProps = {} + +export default async function DashboardPage(props: DashboardPageProps) { + + return ( +
+ {/* banner */} +
+ +
+ + {/* 短效 */} +
+ +
+ {/* 长效 */} +
+ +
+ {/* 固定 */} +
+ +
+ + {/* 图表 */} +
+ +
+ + {/* 信息 */} +
+ +
+ + {/* 通知 */} +
+ +
+
+ ) +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..fc27305 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -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 ( +
+ {/* background */} +
+
+
+
+
+ + {/* content */} +
+ {/* logo */} +
+ {`logo`} +
+ + {/* title */} +
+ 欢迎来到,蓝狐代理 +
+ + {/* profile */} +
+ +
+
+ +
+ + + {props.children} +
+
+ ) +} + +function NavTitle(props: { + label: string +}) { + return ( +

+ {props.label} +

+ ) +} + +function NavItem(props: { + href: string + icon?: ReactNode + label: string +}) { + return ( + + {props.icon} + {props.label} + + ) +} diff --git a/src/app/admin/whitelist/page.tsx b/src/app/admin/whitelist/page.tsx new file mode 100644 index 0000000..498d0b5 --- /dev/null +++ b/src/app/admin/whitelist/page.tsx @@ -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 + +export type WhitelistPageProps = {} + +export default function WhitelistPage(props: WhitelistPageProps) { + const [wait, setWait] = useState(false) + + // ====================== + // 数据 + // ====================== + + const [status, setStatus] = useStatus() + const [data, setData] = useState>({ + 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() + + 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(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()) + + 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() + data.list.forEach(item => { + newSelection.add(item.id) + }) + setSelection(newSelection) + } + } + + // ====================== + // 表单 + // ====================== + + const form = useForm({ + 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
加载失败,请重试
+ } + + return ( +
+ + {/* 全局操作 */} +
+ + +
+ + {/* 数据表 */} +
+ + + + + toggleSelectAll()}/> + + IP + 添加时间 + 备注 + 操作 + + + + + {wait && data.list.length === 0 ? ( + + + + + + ) : data.list.length === 0 ? ( + + + 暂无数据 + + + ) : ( + data.list.map(item => ( + + + toggleSelect(item.id)}/> + + {item.host} + {item.createdAt} + {item.remark || '无'} + +
+ + +
+
+
+ )) + )} +
+
+
+ + {/* 分页器 */} + {data.total > 0 && ( + + )} + + {/* 编辑表单 */} + + + + + {dialogType === 'add' ? '添加白名单' : '编辑白名单'} + + + + className={`flex flex-col gap-4 py-4`} + form={form} + onSubmit={onSubmit}> + + {({id, field}) => ( + + )} + + + {({id, field}) => ( +