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:
2026-01-28 01:52:19 +08:00
parent 44aef38631
commit 0de344c7ad
6 changed files with 1542 additions and 671 deletions

2
next-env.d.ts vendored
View File

@@ -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.

View File

@@ -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

File diff suppressed because it is too large Load Diff

4
src/app.ts Normal file
View 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
View 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>;
};

View 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('获取失败');
}
}
}));