feat: add token management and configuration UI
- Implemented a new app structure with context management for the router. - Created a token management page with a table to display configurations. - Added forms for creating and editing token configurations with validation. - Integrated Zustand for state management, including fetching, updating, and deleting configurations. - Added dialogs for viewing and copying tokens.
This commit is contained in:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./dist/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -11,10 +11,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
"@kevisual/api": "^0.0.26",
|
"@kevisual/api": "^0.0.28",
|
||||||
"@kevisual/cache": "^0.0.5",
|
"@kevisual/cache": "^0.0.5",
|
||||||
"@kevisual/query": "^0.0.38",
|
"@kevisual/query": "^0.0.38",
|
||||||
"@kevisual/router": "^0.0.60",
|
"@kevisual/router": "^0.0.63",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"antd": "^6.2.1",
|
"antd": "^6.2.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@@ -37,12 +37,12 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"es-toolkit": "^1.44.0",
|
"es-toolkit": "^1.44.0",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.563.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
"next": "16.1.4",
|
"next": "16.1.5",
|
||||||
"react": "19.2.3",
|
"react": "19.2.4",
|
||||||
"react-day-picker": "^9.13.0",
|
"react-day-picker": "^9.13.0",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.4",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
"zustand": "^5.0.10"
|
"zustand": "^5.0.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@kevisual/context": "^0.0.4",
|
||||||
"@kevisual/types": "^0.0.12",
|
"@kevisual/types": "^0.0.12",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^25",
|
"@types/node": "^25",
|
||||||
|
|||||||
1840
pnpm-lock.yaml
generated
1840
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
src/app.ts
Normal file
4
src/app.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { QueryRouterServer as App } from '@kevisual/router/browser'
|
||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
|
||||||
|
export const app = useContextKey('app', () => new App());
|
||||||
269
src/app/token/page.tsx
Normal file
269
src/app/token/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { Plus, Pencil, Trash2, Calendar as CalendarIcon, Eye, Copy } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useConfigStore, type Item } from './store';
|
||||||
|
import { LayoutMain } from '@/modules/layout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const TableList = () => {
|
||||||
|
const { list, setShowEdit, setFormData, deleteConfig, getItem } = useConfigStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>过期时间</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
<TableHead>操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{list.map((item: Item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.title}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs ${item.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||||||
|
{item.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.expiredTime ? new Date(item.expiredTime).toLocaleString() : '-'}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">{item.description || '-'}</TableCell>
|
||||||
|
<TableCell>{new Date(item.createdAt).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => getItem(item.id)}>
|
||||||
|
<Eye className="w-4 h-4 mr-1" />
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
setFormData(item);
|
||||||
|
}}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => deleteConfig(item.id)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormModal = () => {
|
||||||
|
const { showEdit, setShowEdit, formData, setFormData, updateData } = useConfigStore();
|
||||||
|
const { handleSubmit, reset, register, control } = useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEdit) return;
|
||||||
|
const defaultExpiredTime = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
if (formData?.id) {
|
||||||
|
reset({ title: formData.title, description: formData.description, status: formData.status, expiredTime: formData.expiredTime });
|
||||||
|
} else {
|
||||||
|
reset({ title: '', description: '', status: 'active', expiredTime: defaultExpiredTime });
|
||||||
|
}
|
||||||
|
}, [formData, showEdit, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
const submitData = formData?.id ? { ...formData, ...data } : data;
|
||||||
|
const res = await updateData(submitData);
|
||||||
|
if (res?.code === 200) {
|
||||||
|
setShowEdit(false);
|
||||||
|
setFormData({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showEdit} onOpenChange={(open) => {
|
||||||
|
setShowEdit(open);
|
||||||
|
if (!open) setFormData({});
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{formData?.id ? '编辑配置' : '添加配置'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="p-4">
|
||||||
|
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>标题</Label>
|
||||||
|
<Input
|
||||||
|
{...register('title', { required: '请输入标题' })}
|
||||||
|
placeholder="请输入标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>状态</Label>
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
defaultValue="active"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
<SelectItem value="expired">Expired</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>过期时间</Label>
|
||||||
|
<Controller
|
||||||
|
name="expiredTime"
|
||||||
|
control={control}
|
||||||
|
defaultValue=""
|
||||||
|
render={({ field }) => (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start text-left font-normal",
|
||||||
|
!field.value && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{field.value ? new Date(field.value).toLocaleDateString() : "选择日期"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={field.value ? new Date(field.value) : undefined}
|
||||||
|
onSelect={(date) => field.onChange(date?.toISOString() || '')}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>描述</Label>
|
||||||
|
<Input
|
||||||
|
{...register('description')}
|
||||||
|
placeholder="请输入描述"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setShowEdit(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit">提交</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TokenModal = () => {
|
||||||
|
const { showToken, setShowToken, tokenData } = useConfigStore();
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (tokenData?.token) {
|
||||||
|
navigator.clipboard.writeText(tokenData.token);
|
||||||
|
toast.success('复制成功');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={showToken} onOpenChange={(open) => setShowToken(open)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Token</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-2 bg-slate-100 p-3 rounded">
|
||||||
|
<span className="flex-1 text-sm break-all">{tokenData?.token || '-'}</span>
|
||||||
|
<Button variant="ghost" size="icon" onClick={handleCopy}>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const List = () => {
|
||||||
|
const { getConfigList, setShowEdit, setFormData } = useConfigStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getConfigList();
|
||||||
|
}, [getConfigList]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 w-full h-full overflow-auto">
|
||||||
|
<div className="flex mb-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowEdit(true);
|
||||||
|
setFormData({});
|
||||||
|
}}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<TableList />
|
||||||
|
<FormModal />
|
||||||
|
<TokenModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
return <LayoutMain><List /></LayoutMain>;
|
||||||
|
};
|
||||||
83
src/app/token/store/index.ts
Normal file
83
src/app/token/store/index.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { QueryConfig } from '@kevisual/api/secret';
|
||||||
|
|
||||||
|
export const queryConfig = new QueryConfig({ query: query as any });
|
||||||
|
|
||||||
|
export type Item = {
|
||||||
|
id: string;
|
||||||
|
description: string | null;
|
||||||
|
status: 'active' | 'inactive' | 'expired';
|
||||||
|
title: string;
|
||||||
|
expiredTime: string;
|
||||||
|
userId: string;
|
||||||
|
data: Record<string, any>;
|
||||||
|
orgId: string | null;
|
||||||
|
token?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ConfigStore {
|
||||||
|
list: Item[];
|
||||||
|
getConfigList: () => Promise<void>;
|
||||||
|
updateData: (data: any) => Promise<any>;
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
formData: any;
|
||||||
|
setFormData: (formData: any) => void;
|
||||||
|
deleteConfig: (id: string) => Promise<void>;
|
||||||
|
showToken: boolean;
|
||||||
|
setShowToken: (showToken: boolean) => void;
|
||||||
|
tokenData: Item | null;
|
||||||
|
setTokenData: (tokenData: Item) => void;
|
||||||
|
getItem: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfigStore = create<ConfigStore>((set, get) => ({
|
||||||
|
list: [],
|
||||||
|
getConfigList: async () => {
|
||||||
|
const res = await queryConfig.listItems();
|
||||||
|
if (res.code === 200) {
|
||||||
|
set({ list: (res.data?.list || []) as Item[] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data: any) => {
|
||||||
|
const res = await queryConfig.updateItem(data);
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().setFormData(res.data);
|
||||||
|
get().getConfigList();
|
||||||
|
toast.success('保存成功');
|
||||||
|
} else {
|
||||||
|
toast.error('保存失败');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit: boolean) => set({ showEdit }),
|
||||||
|
formData: {},
|
||||||
|
setFormData: (formData: any) => set({ formData }),
|
||||||
|
deleteConfig: async (id: string) => {
|
||||||
|
const res = await queryConfig.deleteItem({ id });
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().getConfigList();
|
||||||
|
toast.success('删除成功');
|
||||||
|
} else {
|
||||||
|
toast.error('删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showToken: false,
|
||||||
|
setShowToken: (showToken: boolean) => set({ showToken }),
|
||||||
|
tokenData: null,
|
||||||
|
setTokenData: (tokenData: any) => set({ tokenData }),
|
||||||
|
getItem: async (id: string) => {
|
||||||
|
const res = await queryConfig.getItem({ id });
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().setTokenData(res.data as Item);
|
||||||
|
get().setShowToken(true);
|
||||||
|
} else {
|
||||||
|
toast.error('获取失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user