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/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./dist/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
15
package.json
15
package.json
@@ -11,10 +11,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@kevisual/api": "^0.0.26",
|
||||
"@kevisual/api": "^0.0.28",
|
||||
"@kevisual/cache": "^0.0.5",
|
||||
"@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-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -27,7 +27,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"antd": "^6.2.1",
|
||||
"antd": "^6.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -37,12 +37,12 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^17.0.1",
|
||||
"next": "16.1.4",
|
||||
"react": "19.2.3",
|
||||
"next": "16.1.5",
|
||||
"react": "19.2.4",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -51,6 +51,7 @@
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/context": "^0.0.4",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@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